@torrent-tv/proxy 2.92.4 → 2.92.6

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.92.4",
3
+ "version": "2.92.6",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -137,7 +137,7 @@ export class MatroskaContainer extends Container {
137
137
  /** @type {Array<import("../tracks/ContainerTrack.js").ContainerTrack> | null} */
138
138
  #tracks = null;
139
139
  #packets = null;
140
- #packetState = {};
140
+ #packetStates = new Map();
141
141
 
142
142
  /**
143
143
  * The Cues reading: undefined until read, null when the file has none.
@@ -150,8 +150,8 @@ export class MatroskaContainer extends Container {
150
150
  }
151
151
 
152
152
  packetIndexBytes() {
153
- return (this.#packets?.allocatedBytes() ?? 0) +
154
- [...(this.#packetState.packets?.values() ?? [])].reduce((sum, records) => sum + records.allocatedBytes, 0);
153
+ return [...this.#packetStates.values()].reduce((sum, state) => sum +
154
+ [...(state.packets?.values() ?? [])].reduce((bytes, records) => bytes + records.allocatedBytes, 0), 0);
155
155
  }
156
156
 
157
157
  static detect(head) {
@@ -437,16 +437,50 @@ export class MatroskaContainer extends Container {
437
437
  if (!layout) throw new Error("Matroska Segment is absent.");
438
438
  const tracks = await this.readTracks();
439
439
  const info = await this.readMediaInfo();
440
+ const start = await this.#packetStart(layout, tracks, interval);
441
+ // Reuse a contiguous reading that has already reached this cue. A seek
442
+ // across an unindexed gap gets its own reading instead of walking the gap.
443
+ let state = [...this.#packetStates.values()]
444
+ .filter(one => one.startAt <= start && one.at >= start)
445
+ .sort((a, b) => b.startAt - a.startAt)[0];
446
+ if (!state) {
447
+ state = { startAt: start, at: start };
448
+ this.#packetStates.set(start, state);
449
+ }
440
450
  const index = await readMatroskaPackets({ readRange: this.readRange,
441
451
  fileSize: this.fileSize, portionBytes: this.portionBytes, layout, tracks,
442
- durationSeconds: info.durationSeconds, interval, state: this.#packetState, packetMemory: this.packetMemory });
443
- if (index.isComplete()) {
452
+ durationSeconds: info.durationSeconds, interval, state, packetMemory: this.packetMemory });
453
+ if (index.isComplete() && state.startAt === layout.firstClusterAt) {
444
454
  this.#packets = index;
445
- this.#packetState = {};
446
455
  }
447
456
  return index;
448
457
  }
449
458
 
459
+ async #packetStart(layout, tracks, interval) {
460
+ const first = layout.firstClusterAt ?? layout.segmentEnd;
461
+ if (!Number.isFinite(interval?.from) || interval.from <= 0) return first;
462
+ const cues = await this.readCues();
463
+ const requested = tracks.filter(track => interval.trackIds ? interval.trackIds.includes(track.trackNumber)
464
+ : ["video", "audio"].includes(track.type));
465
+ // A subtitle can remain visible across arbitrarily many picture Clusters;
466
+ // picture cues alone cannot prove that an earlier subtitle has ended.
467
+ if (requested.every(track => track.type === "subtitle")) return first;
468
+ const cueTrack = requested.find(track => track.type === "video") ??
469
+ tracks.find(track => track.type === "video") ?? requested[0];
470
+ if (!cueTrack) return first;
471
+ const points = (cues?.points ?? []).flatMap(point => point.positions
472
+ .filter(position => position.track === cueTrack.trackNumber && position.clusterAt >= first && position.clusterAt < layout.segmentEnd)
473
+ .map(position => ({ at: position.clusterAt, seconds: point.ticks * layout.secondsPerTick })))
474
+ .sort((a, b) => a.seconds - b.seconds);
475
+ const preroll = Math.max(0, ...requested.map(track =>
476
+ Math.max(track.seekPrerollSeconds ?? 0, track.defaultDurationSeconds ?? 0)));
477
+ let preceding = -1;
478
+ for (let index = 0; index < points.length && points[index].seconds <= interval.from - preroll; index++) preceding = index;
479
+ // The preceding cue's Cluster also retains audio that overlaps this cut
480
+ // and decoder dependencies before the first presented packet.
481
+ return preceding < 0 ? first : points[Math.max(0, preceding - 1)].at;
482
+ }
483
+
450
484
  /**
451
485
  * The Cues table, read once, every CuePoint with every track it names.
452
486
  *
@@ -15,7 +15,7 @@ export async function readMatroskaPackets({ readRange, fileSize, portionBytes, l
15
15
  const declared = new Map(tracks.filter(track => ["video", "audio", "subtitle"].includes(track.type)).map(track => [track.trackNumber, track]));
16
16
  if (!state.packets) {
17
17
  state.packets = new Map([...declared.keys()].map(id => [id, new PacketRecords(packetMemory)]));
18
- state.at = layout.firstClusterAt ?? layout.segmentEnd;
18
+ state.at ??= layout.firstClusterAt ?? layout.segmentEnd;
19
19
  }
20
20
  const packets = state.packets;
21
21
  if (intervalCovered(declared, packets, interval)) return buildIndex(declared, packets, durationSeconds, false, interval, packetMemory);
@@ -289,15 +289,16 @@ export class PieceLru {
289
289
  *
290
290
  * @returns {{ readers: number, names: string[], unionPieces: number, widestPieces: number, capacity: number }}
291
291
  */
292
- demand() {
292
+ demand({ memoryRequiredOnly = false } = {}) {
293
293
  // WHO THEY ARE, not only how many. A "reader" here is whoever declared a
294
294
  // range, and on 2026-09-08 the field said `5 reader(s) want 24 piece(s) of
295
295
  // 25` on a session with two encoders — because the priority map declares one
296
296
  // range per zone and four of its zones were arriving as four readers. The
297
297
  // count alone could not say that, and choosing between "narrow the windows"
298
298
  // and "raise the allowance" was guesswork until the names were printed.
299
- const names = [...this.#protected.keys()].map(String).sort();
300
- const ranges = [...this.#protected.values()]
299
+ const entries = [...this.#protected.entries()].filter(([, range]) => !memoryRequiredOnly || range.memoryRequired);
300
+ const names = entries.map(([name]) => String(name)).sort();
301
+ const ranges = entries.map(([, range]) => range)
301
302
  .map((range) => ({ from: range.from, to: range.to }))
302
303
  .sort((left, right) => left.from - right.from);
303
304
  let unionPieces = 0;
@@ -432,9 +433,11 @@ export class PieceLru {
432
433
  * urgent. A caller that states none is treated as wanting these pieces
433
434
  * least of everyone who did state one, so an unstated range can never
434
435
  * displace a stated one.
436
+ * @param {boolean} [memoryRequired] - Read windows require room; download
437
+ * preferences can yield under pressure.
435
438
  * @returns {void}
436
439
  */
437
- protect(readerId, from, to, urgency, deadlineAt, priority = 1) {
440
+ protect(readerId, from, to, urgency, deadlineAt, priority = 1, memoryRequired = true) {
438
441
  if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) {
439
442
  return;
440
443
  }
@@ -443,7 +446,8 @@ export class PieceLru {
443
446
  to,
444
447
  urgency: Number.isFinite(urgency) ? Number(urgency) : Number.MAX_SAFE_INTEGER,
445
448
  deadlineAt,
446
- priority
449
+ priority,
450
+ memoryRequired
447
451
  });
448
452
  }
449
453
 
@@ -658,10 +658,13 @@ export class SharedPieceStore {
658
658
  * range to declare.
659
659
  */
660
660
  get wantedBytes() {
661
+ // Download preferences may use spare RAM, but cannot become a required
662
+ // floor or a retained reading window after the map has been withdrawn.
661
663
  const demand = this.#lru.demand();
664
+ const required = this.#lru.demand({ memoryRequiredOnly: true });
662
665
  const pinned = this.#lru.pinnedCount;
663
666
  if (demand.readers > 0) {
664
- this.#widestSeenPieces = Math.max(this.#widestSeenPieces, demand.widestPieces);
667
+ this.#widestSeenPieces = Math.max(this.#widestSeenPieces, required.widestPieces);
665
668
  const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces, pinned);
666
669
  // Plus room to absorb what arrives while a write is finishing. Asking for
667
670
  // exactly what the readers want leaves no free place ever, so every
@@ -710,7 +713,7 @@ export class SharedPieceStore {
710
713
  // also pin pieces without a priority-map range, so those measured pins are
711
714
  // part of the same minimum. Exceeding the share is the lesser failure, and
712
715
  // the line below says when it happens.
713
- const demand = this.#lru.demand();
716
+ const demand = this.#lru.demand({ memoryRequiredOnly: true });
714
717
  const activeDemandPieces = Math.max(
715
718
  MIN_RESIDENT_PIECES,
716
719
  demand.readers > 0 ? demand.unionPieces : 0,
@@ -1848,8 +1851,8 @@ export class SharedPieceStore {
1848
1851
  * @param {number} [urgency]
1849
1852
  * @returns {void}
1850
1853
  */
1851
- protectRange(readerId, from, to, urgency, deadlineAt, priority) {
1852
- this.#lru.protect(readerId, from, to, urgency, deadlineAt, priority);
1854
+ protectRange(readerId, from, to, urgency, deadlineAt, priority, memoryRequired = true) {
1855
+ this.#lru.protect(readerId, from, to, urgency, deadlineAt, priority, memoryRequired);
1853
1856
  // A READER DECLARES ITSELF IN A MOMENT; the allowance was re-derived once a
1854
1857
  // minute. Between the two a read met whatever the store had shrunk to while
1855
1858
  // nobody was reading, and a claim gives up after five seconds — twelve
@@ -25,8 +25,7 @@ function requestArrival(wire, request, now) {
25
25
  return requestArrivals.get(request);
26
26
  }
27
27
 
28
- export function peerRequestCapacity(torrent, wire) {
29
- const speed = Math.max(0, Number(wire.downloadSpeed?.()) || 0);
28
+ export function peerRequestCapacity(torrent, wire, speed = Math.max(0, Number(wire.downloadSpeed?.()) || 0)) {
30
29
  if (wire.type === "webSeed") {
31
30
  return Math.min(1 + Math.ceil(speed / torrent.pieceLength), torrent.maxWebConns);
32
31
  }
@@ -36,8 +35,7 @@ export function peerRequestCapacity(torrent, wire) {
36
35
  return capacity;
37
36
  }
38
37
 
39
- export function requestCompletionAt(wire, bytes, now) {
40
- const speed = Number(wire.downloadSpeed?.());
38
+ export function requestCompletionAt(wire, bytes, now, speed = Number(wire.downloadSpeed?.())) {
41
39
  if (!(speed > 0)) return Number.POSITIVE_INFINITY;
42
40
  const queued = (wire.requests ?? []).reduce((total, request) => total + request.length, 0);
43
41
  return now + (queued + bytes) / speed * 1000;
@@ -116,26 +114,45 @@ export function downloadCandidates(entries, findStore = pieceStoreOf) {
116
114
  /** Run against protocol peers or an isolated future-state copy. */
117
115
  export function dispatchDownloadCandidates(candidates, now) {
118
116
  let requested = 0, reassigned = 0;
119
- const hasCapacity = torrent => (torrent.wires ?? []).some(wire =>
120
- !wire.destroyed && (wire.requests?.length ?? 0) < peerRequestCapacity(torrent, wire));
121
- const writable = new Set(candidates.map(candidate => candidate.torrent).filter(hasCapacity));
117
+ // Rates and advertised limits cannot change during this synchronous pass.
118
+ // Read each peer once, and only inspect torrents once regardless of map size.
119
+ const readings = new Map();
120
+ const readingOf = (torrent, wire) => {
121
+ let reading = readings.get(wire);
122
+ if (!reading) {
123
+ const speed = Math.max(0, Number(wire.downloadSpeed?.()) || 0);
124
+ readings.set(wire, reading = { speed, capacity: peerRequestCapacity(torrent, wire, speed) });
125
+ }
126
+ return reading;
127
+ };
128
+ const usable = wire => !wire.destroyed &&
129
+ (!wire.peerChoking || (wire.hasFast && wire.peerAllowedFastSet?.length > 0));
130
+ const available = (torrent, wire) => usable(wire) &&
131
+ (wire.requests?.length ?? 0) < readingOf(torrent, wire).capacity;
132
+ const completionAt = (torrent, wire, bytes) =>
133
+ requestCompletionAt(wire, bytes, now, readingOf(torrent, wire).speed);
134
+ const writable = new Map();
135
+ for (const torrent of new Set(candidates.map(candidate => candidate.torrent))) {
136
+ const peers = (torrent.wires ?? []).filter(wire => available(torrent, wire));
137
+ if (peers.length) writable.set(torrent, peers);
138
+ }
122
139
  for (const candidate of candidates) {
123
140
  if (!writable.size) break;
124
141
  const { torrent, piece, deadlineAt, requestId } = candidate;
125
142
  if (!writable.has(torrent)) continue;
126
- const wires = (torrent.wires ?? []).filter(wire => peerCanServe(wire, piece) &&
127
- (wire.requests?.length ?? 0) < peerRequestCapacity(torrent, wire));
143
+ const peers = writable.get(torrent);
144
+ const wires = peers.filter(wire => peerCanServe(wire, piece) && available(torrent, wire));
128
145
  while (wires.length) {
129
- wires.sort((a, b) => requestCompletionAt(a, BLOCK_BYTES, now) - requestCompletionAt(b, BLOCK_BYTES, now) ||
146
+ wires.sort((a, b) => completionAt(torrent, a, BLOCK_BYTES) - completionAt(torrent, b, BLOCK_BYTES) ||
130
147
  (a.requests?.length ?? 0) - (b.requests?.length ?? 0));
131
148
  const wire = wires[0];
132
149
  const before = wire.requests?.length ?? 0;
133
150
  let accepted = torrent._request(wire, piece, false);
134
151
  if (!accepted && Number.isFinite(deadlineAt)) {
135
- const fasterAt = requestCompletionAt(wire, BLOCK_BYTES, now);
152
+ const fasterAt = completionAt(torrent, wire, BLOCK_BYTES);
136
153
  for (const slower of torrent.wires ?? []) {
137
154
  if (slower === wire || typeof slower.cancel !== "function") continue;
138
- const lateAt = requestCompletionAt(slower, 0, now);
155
+ const lateAt = completionAt(torrent, slower, 0);
139
156
  if (!Number.isFinite(lateAt) || !(lateAt > deadlineAt && fasterAt < lateAt)) continue;
140
157
  const request = slower.requests?.find(one => one.piece === piece);
141
158
  if (!request) continue;
@@ -159,9 +176,9 @@ export function dispatchDownloadCandidates(candidates, now) {
159
176
  torrent.emit?.("download-requested", { piece, deadlineAt, requestId, peer: wire.remoteAddress, queued: wire.requests?.length ?? 0 });
160
177
  }
161
178
  if (!accepted || (wire.requests?.length ?? 0) <= before ||
162
- (wire.requests?.length ?? 0) >= peerRequestCapacity(torrent, wire)) wires.shift();
179
+ (wire.requests?.length ?? 0) >= readingOf(torrent, wire).capacity) wires.shift();
163
180
  }
164
- if (!hasCapacity(torrent)) writable.delete(torrent);
181
+ if (!peers.some(wire => available(torrent, wire))) writable.delete(torrent);
165
182
  }
166
183
  return { requested, reassigned };
167
184
  }
@@ -235,7 +235,9 @@ export class SwarmSelection {
235
235
  // The level goes with the range: it is what eviction compares when
236
236
  // everything resident is wanted by somebody, and dropping it here is
237
237
  // what left the store choosing by recency alone.
238
- store.protectRange(window.claimant, range.from, range.to, window.urgency, window.deadlineAt, window.priority);
238
+ // The download map is an eviction preference. Actual reads own pins;
239
+ // declaring a whole-file tail must not set a whole-file memory floor.
240
+ store.protectRange(window.claimant, range.from, range.to, window.urgency, window.deadlineAt, window.priority, false);
239
241
  holding.add(window.claimant);
240
242
  }
241
243
  }