@torrent-tv/proxy 2.98.17 → 2.98.19

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.98.17",
3
+ "version": "2.98.19",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -65,6 +65,7 @@ import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
65
65
  import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/NativeSourceMap.js";
66
66
  import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
67
67
  import { SegmentInputs } from "./services/media/SegmentInputs.js";
68
+ import { directSegmentInput } from "./services/media/direct-segment-input.js";
68
69
  import { ContainerTrack } from "./services/media/tracks/ContainerTrack.js";
69
70
  import { pauseCoefficient, viewerStartsOn } from "./services/viewer/PriorityMap.js";
70
71
  import { coalescing } from "./utils/coalesce.js";
@@ -551,6 +552,17 @@ export async function startProxyServer({
551
552
  `${Math.round(passCosts.ms)}ms from start to result in all, longest ${Math.round(passCosts.maxMs)}ms`);
552
553
  Object.assign(passCosts, { since: now, passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 });
553
554
  };
555
+ // A container whose packet table a pass did not wait for builds the map
556
+ // again once, when the table is built or refused (`directSegmentInput`).
557
+ const refreshAfterTable = new WeakSet();
558
+ const refreshWhenBuilt = (container, built, { sourceKey, fileIndex }) => {
559
+ if (refreshAfterTable.has(container)) return;
560
+ refreshAfterTable.add(container);
561
+ void built.then(() => {
562
+ refreshAfterTable.delete(container);
563
+ return downloadMaps.refresh(sourceKey, fileIndex);
564
+ }).catch(error => logger.warn(`download map after a packet table: ${error?.message ?? error}`));
565
+ };
554
566
  const downloadMaps = new DownloadMaps({
555
567
  // The ranges a picture reads at open are named again by every segment;
556
568
  // only the zone the swarm would rank first is sent (torrent-tv/meta#166).
@@ -693,13 +705,15 @@ export async function startProxyServer({
693
705
  // packets, which the same index states (torrent-tv/meta#166).
694
706
  let value = null;
695
707
  try {
696
- if (await direct.supportsOriginalSourceRanges(interval) === true) value = await direct.readSourceRanges(interval);
697
- else {
698
- const index = await direct.readPacketIndex(interval);
699
- await index.prepareAudioDependencies?.(interval, direct.readRange);
700
- value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
701
- }
708
+ value = await directSegmentInput(direct, { interval, tracks: wanted, mode: track => modes.get(track) },
709
+ built => refreshWhenBuilt(direct, built, map));
702
710
  } catch { value = null; }
711
+ // A table still being built holds this output's segments, and only
712
+ // this output's: the map is built again when it is.
713
+ if (value?.kind === "pending") {
714
+ waiting.add(zone.outputKey);
715
+ continue;
716
+ }
703
717
  // The pass shares the thread in equal turns (`takeTurns`): in one
704
718
  // stretch the first pass over a film's 4000 zones held the main thread
705
719
  // for all of its work, and handed back after every zone it took
@@ -199,6 +199,20 @@ export class AviContainer extends Container {
199
199
  return false;
200
200
  }
201
201
 
202
+ /**
203
+ * Whether reading the sound's packet table now would wait for it to be
204
+ * built: the index is read, the table is not, and no refusal of memory for
205
+ * it stands. A reader with other work may go on with it instead. Field r42
206
+ * (Home Assistant, 2026-10-14): the download map waited 3.2 s for the
207
+ * table, between the soundtrack's first segment and the picture's next
208
+ * ones, and the swarm was asked for the picture's next piece only after it
209
+ * (torrent-tv/meta#178).
210
+ */
211
+ packetTableWouldWait() {
212
+ if (!this.#index || this.#packets || this.#scan) return false;
213
+ return this.#soundPacketsRefused === null || this.packetMemory?.fits?.(this.#soundPacketsRefused) !== false;
214
+ }
215
+
202
216
  /** The index, read from the file; see `#readIndex`. */
203
217
  async #readIndexOnce() {
204
218
  const tracks = await this.readTracks();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @file The bytes one segment reads, from an index its container already
3
+ * holds, for the download map's pass (torrent-tv/meta#178).
4
+ *
5
+ * The pass works out every segment of every output a viewer watches, one at a
6
+ * time. A segment read from the original file is answered by arithmetic over
7
+ * the index; a soundtrack segment of an AVI needs the sound's packet table,
8
+ * which is built once for the whole film. While that table was being built the
9
+ * pass waited for it, and the picture's segments after the soundtrack's first
10
+ * were not stated to the swarm: field r42 (Home Assistant, 2026-10-14), the
11
+ * picture's next piece was asked for 4.0 s after its input asked for it, 3.2 s
12
+ * of that behind the table.
13
+ */
14
+ import { SegmentInputs } from "./SegmentInputs.js";
15
+
16
+ /**
17
+ * @param {object} container - Has `supportsOriginalSourceRanges`, `readSourceRanges` and `readPacketIndex`.
18
+ * @param {{ interval: object, tracks: object[], mode: (track: object) => string }} segment
19
+ * @param {(built: Promise<void>) => void} whenBuilt - Given a promise that settles when a table
20
+ * still being built is built or refused, when the answer is "pending".
21
+ * @returns {Promise<{ kind: string, ranges?: Array<[number, number]> }>} `pending` when the
22
+ * answer waits for a table still being built.
23
+ */
24
+ export async function directSegmentInput(container, { interval, tracks, mode }, whenBuilt) {
25
+ if (await container.supportsOriginalSourceRanges(interval) === true) return container.readSourceRanges(interval);
26
+ // The table is begun, or joined, and not waited for.
27
+ if (container.packetTableWouldWait?.() === true) {
28
+ whenBuilt(container.readPacketIndex(interval).then(() => undefined, () => undefined));
29
+ return { kind: "pending" };
30
+ }
31
+ const index = await container.readPacketIndex(interval);
32
+ await index.prepareAudioDependencies?.(interval, container.readRange);
33
+ return new SegmentInputs({ index, tracks }).forInterval({ ...interval, mode });
34
+ }
@@ -1545,7 +1545,13 @@ export class TorrentPool {
1545
1545
  wire.request = function (index, offset, length, callback) {
1546
1546
  const queued = this.requests.length;
1547
1547
  const answer = request.call(this, index, offset, length, callback);
1548
- if (this.requests.length > queued) timings.asked(index, offset, peer, Date.now());
1548
+ if (this.requests.length > queued) {
1549
+ const now = Date.now();
1550
+ timings.asked(index, offset, peer, now);
1551
+ // When this wire was asked for the block: how long it has held it is
1552
+ // what decides whether a second peer is asked (`duplicateTailFor`).
1553
+ this.requests.at(-1).askedAt = now;
1554
+ }
1549
1555
  return answer;
1550
1556
  };
1551
1557
  wire.on("piece", (index, offset) => timings.arrived(index, offset, peer, Date.now()));
@@ -264,25 +264,42 @@ export function describePieceTail(torrent, pieceIndex) {
264
264
  * @param {object} torrent
265
265
  * @param {number} pieceIndex
266
266
  * @param {object} [options]
267
+ * A BLOCK IS ASKED AGAIN ONLY ONCE IT IS LATE. A short tail is not by itself a
268
+ * delay: copied as soon as the tail was short, 12-16 blocks of nearly every
269
+ * piece were asked twice and 82 of 215 copies arrived after their piece was
270
+ * complete (r41, Home Assistant, torrent-tv/meta#178). So a block is asked of a
271
+ * second peer once its request to the first has been out `minAgeMs` — by
272
+ * default the second WebTorrent sizes a wire's queue to fill
273
+ * (`PIPELINE_MAX_DURATION`), a starting point to be checked in the field — and
274
+ * the earliest moment a held block becomes late is returned, so whoever calls
275
+ * this can look again then.
276
+ *
277
+ * @param {object} torrent
278
+ * @param {number} pieceIndex
279
+ * @param {object} [options]
267
280
  * @param {number} [options.maxCopies] - How many wires a missing block may be
268
281
  * in flight on at once: the first request and one copy beside it.
269
- * @returns {{ duplicated: number, missing: number, wires: number, unplaced: number }}
282
+ * @param {number} [options.minAgeMs] - How long the first request must have been out.
283
+ * @param {number} [options.now] - Wall clock, ms.
284
+ * @returns {{ duplicated: number, missing: number, wires: number, unplaced: number, dueAt: number | null }}
270
285
  * `duplicated` counts requests the library actually placed for a block that
271
- * was already outstanding elsewhere; `unplaced` the blocks in flight on one
272
- * wire alone that no other wire could be asked for now.
286
+ * was already outstanding elsewhere; `unplaced` the late blocks in flight on
287
+ * one wire alone that no other wire could be asked for now; `dueAt` when the
288
+ * first block held by one wire and not yet late becomes late, or null.
273
289
  */
274
- export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
290
+ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2, minAgeMs = 1000, now = Date.now() } = {}) {
275
291
  const piece = torrent?.pieces?.[pieceIndex];
276
292
  const buffer = Array.isArray(piece?._buffer) ? piece._buffer : null;
277
293
  if (!piece || !buffer || typeof piece.cancel !== "function" || !canPlaceRequests(torrent)) {
278
- return { duplicated: 0, missing: 0, wires: 0, unplaced: 0 };
294
+ return { duplicated: 0, missing: 0, wires: 0, unplaced: 0, dueAt: null };
279
295
  }
280
296
  const chunks = Number.isFinite(piece._chunks) ? piece._chunks : buffer.length;
281
297
  // Most calls are for a piece still arriving in bulk; its own count says so
282
298
  // without walking its blocks or every wire's queue.
283
299
  if (Number.isFinite(piece._buffered) && chunks - piece._buffered > SHORT_TAIL_BLOCKS) {
284
- return { duplicated: 0, missing: chunks - piece._buffered, wires: 0, unplaced: 0 };
300
+ return { duplicated: 0, missing: chunks - piece._buffered, wires: 0, unplaced: 0, dueAt: null };
285
301
  }
302
+
286
303
  const missing = [];
287
304
  for (let index = 0; index < chunks; index += 1) {
288
305
  if (!buffer[index]) {
@@ -291,7 +308,7 @@ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
291
308
  }
292
309
  const candidates = wiresForPiece(torrent, pieceIndex);
293
310
  if (missing.length === 0 || candidates.length === 0) {
294
- return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0 };
311
+ return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0, dueAt: null };
295
312
  }
296
313
 
297
314
  // A tail this short is what a blocked read is actually waiting on, and a
@@ -300,26 +317,34 @@ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
300
317
  // bytes that are already coming — measured 2026-08-19, the tails a reader
301
318
  // waits on are 2 to 14 blocks of 512.
302
319
  if (missing.length > SHORT_TAIL_BLOCKS) {
303
- return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0 };
320
+ return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0, dueAt: null };
304
321
  }
305
322
 
306
- // Which wires each missing block is in flight on, read from the wires' own
307
- // queues: the library keeps a block's reservation for one wire only, and a
308
- // copy asked here is visible nowhere else.
309
- const holders = new Map(missing.map(block => [block, new Set()]));
323
+ // Which wires each missing block is in flight on, and since when, read from
324
+ // the wires' own queues: the library keeps a block's reservation for one
325
+ // wire only, and a copy asked here is visible nowhere else. A request with no
326
+ // time of its own was asked before anybody watched, and is old.
327
+ const holders = new Map(missing.map(block => [block, { wires: new Set(), since: Number.POSITIVE_INFINITY }]));
310
328
  for (const wire of Array.isArray(torrent.wires) ? torrent.wires : []) {
311
329
  for (const request of Array.isArray(wire?.requests) ? wire.requests : []) {
312
330
  if (request?.piece !== pieceIndex) continue;
313
- holders.get(Math.floor(request.offset / BLOCK_BYTES))?.add(wire);
331
+ const holding = holders.get(Math.floor(request.offset / BLOCK_BYTES));
332
+ if (!holding) continue;
333
+ holding.wires.add(wire);
334
+ holding.since = Math.min(holding.since, Number.isFinite(request.askedAt) ? request.askedAt : Number.NEGATIVE_INFINITY);
314
335
  }
315
336
  }
316
337
 
317
- let duplicated = 0, unplaced = 0;
338
+ let duplicated = 0, unplaced = 0, dueAt = null;
318
339
  for (const block of missing) {
319
- const held = holders.get(block);
340
+ const { wires: held, since } = holders.get(block);
320
341
  // Not in flight anywhere: the library's own picker asks for it; a block
321
342
  // nobody has been asked for is not a tail held by a slow peer.
322
343
  if (held.size === 0 || held.size >= maxCopies) continue;
344
+ if (now - since < minAgeMs) {
345
+ dueAt = Math.min(dueAt ?? Number.POSITIVE_INFINITY, since + minAgeMs);
346
+ continue;
347
+ }
323
348
  let placed = false;
324
349
  for (const wire of candidates) {
325
350
  if (held.has(wire)) continue;
@@ -343,7 +368,7 @@ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
343
368
  }
344
369
  if (!placed) unplaced += 1;
345
370
  }
346
- return { duplicated, missing: missing.length, wires: candidates.length, unplaced };
371
+ return { duplicated, missing: missing.length, wires: candidates.length, unplaced, dueAt };
347
372
  }
348
373
 
349
374
  /**
@@ -18,10 +18,12 @@
18
18
  * peer becomes able or unable to give the piece; so it is looked at on those —
19
19
  * a block arriving, a wire unchoking, a peer stating a piece, a wire joining
20
20
  * or closing, a piece verified, a new map. Several in one turn of the event
21
- * loop are one look. A tail no other wire could be asked for now is looked at
22
- * once more after the time a wire's queue is sized to empty in — WebTorrent
23
- * sizes it to one second of the wire's speed (`PIPELINE_MAX_DURATION`) — in
24
- * case nothing else happens in between.
21
+ * loop are one look. A block is asked again only once its request has been out
22
+ * a wire's queue time (`duplicateTailFor`), so a block not yet late is looked
23
+ * at again when it becomes late, and a late one no other wire could take is
24
+ * looked at again a queue time later — once, in case nothing else happens in
25
+ * between. WebTorrent sizes a wire's queue to one second of its speed
26
+ * (`PIPELINE_MAX_DURATION`).
25
27
  */
26
28
 
27
29
  import { duplicateTailFor } from "./fastest-wires.js";
@@ -41,12 +43,15 @@ export class TailRepeat {
41
43
  #clear;
42
44
  #scheduled = false;
43
45
  #timer = null;
46
+ /** When the armed look runs, wall clock ms. */
47
+ #timerAt = Number.POSITIVE_INFINITY;
44
48
  #disposed = false;
45
49
  /** @type {Map<object, { onBlock: (index: number) => void, onPeer: () => void }>} */
46
50
  #wires = new Map();
47
51
  #onWire;
48
52
  #onPoke;
49
53
  #log;
54
+ #now;
50
55
  /**
51
56
  * Grow-only, for the log: block copies asked, looks taken, and blocks that
52
57
  * arrived after their piece was complete — what the copies cost.
@@ -62,9 +67,11 @@ export class TailRepeat {
62
67
  * @param {(work: () => void, ms: number) => unknown} [params.wake]
63
68
  * @param {(handle: unknown) => void} [params.clear]
64
69
  * @param {(line: string) => void} [params.log]
70
+ * @param {() => number} [params.now]
65
71
  */
66
- constructor({ torrent, requiredPieces, duplicate = duplicateTailFor, schedule = setImmediate, wake = setTimeout, clear = clearTimeout, log = null }) {
72
+ constructor({ torrent, requiredPieces, duplicate = duplicateTailFor, schedule = setImmediate, wake = setTimeout, clear = clearTimeout, log = null, now = Date.now }) {
67
73
  this.#log = log;
74
+ this.#now = now;
68
75
  this.#torrent = torrent;
69
76
  this.#requiredPieces = requiredPieces;
70
77
  this.#duplicate = duplicate;
@@ -95,6 +102,19 @@ export class TailRepeat {
95
102
  this.#timer = null;
96
103
  }
97
104
 
105
+ /** One look armed at a time, at the earliest moment asked for. */
106
+ #lookAgainAt(at, now) {
107
+ if (!Number.isFinite(at) || at >= this.#timerAt) return;
108
+ if (this.#timer !== null) this.#clear(this.#timer);
109
+ this.#timerAt = at;
110
+ this.#timer = this.#wake(() => {
111
+ this.#timer = null;
112
+ this.#timerAt = Number.POSITIVE_INFINITY;
113
+ this.poke();
114
+ }, Math.max(0, at - now));
115
+ this.#timer?.unref?.();
116
+ }
117
+
98
118
  #watch(wire) {
99
119
  if (!wire || this.#wires.has(wire) || typeof wire.on !== "function") return;
100
120
  const listeners = {
@@ -128,14 +148,16 @@ export class TailRepeat {
128
148
  this.#scheduled = false;
129
149
  if (this.#disposed) return;
130
150
  this.totals.looks += 1;
131
- let unplaced = 0;
151
+ const now = this.#now();
152
+ let unplaced = 0, dueAt = Number.POSITIVE_INFINITY;
132
153
  const asked = [];
133
154
  for (const { from, to } of this.#requiredPieces()) {
134
155
  for (let index = from; index <= to; index += 1) {
135
156
  if (this.#torrent.bitfield?.get?.(index)) continue;
136
- const result = this.#duplicate(this.#torrent, index);
157
+ const result = this.#duplicate(this.#torrent, index, { minAgeMs: WIRE_QUEUE_SECONDS * 1000, now });
137
158
  this.totals.duplicated += result.duplicated;
138
159
  unplaced += result.unplaced;
160
+ if (Number.isFinite(result.dueAt)) dueAt = Math.min(dueAt, result.dueAt);
139
161
  if (result.duplicated > 0) asked.push(`${index}:${result.duplicated}/${result.missing}`);
140
162
  }
141
163
  }
@@ -143,12 +165,7 @@ export class TailRepeat {
143
165
  this.#log?.(`tail: asked a second peer for the last blocks of piece:blocks/missing ${asked.join(" ")}; ` +
144
166
  `${this.totals.duplicated} block copies asked so far, ${this.totals.lateBlocks} block(s) arrived after their piece was complete`);
145
167
  }
146
- if (unplaced > 0 && this.#timer === null) {
147
- this.#timer = this.#wake(() => {
148
- this.#timer = null;
149
- this.poke();
150
- }, WIRE_QUEUE_SECONDS * 1000);
151
- this.#timer?.unref?.();
152
- }
168
+ if (unplaced > 0) dueAt = Math.min(dueAt, now + WIRE_QUEUE_SECONDS * 1000);
169
+ this.#lookAgainAt(dueAt, now);
153
170
  }
154
171
  }