@torrent-tv/proxy 2.96.8 → 2.96.10

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.96.8",
3
+ "version": "2.96.10",
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
@@ -54,6 +54,7 @@ import { wireOutputs } from "./services/server/wire-outputs.js";
54
54
  import { createPlaybackPlanner } from "./services/media/playback-planner.js";
55
55
  import { KeyframeTables } from "./services/media/KeyframeTables.js";
56
56
  import { contentsOf } from "./services/torrent/Contents.js";
57
+ import { joinedWithinPieces } from "./services/torrent/demand/pieces.js";
57
58
  import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
58
59
  import { containerOrchestrator, CONTAINER_HEAD_BYTES, describeWorkTags } from "./services/media/ContainerOrchestrator.js";
59
60
  import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
@@ -434,6 +435,9 @@ export async function startProxyServer({
434
435
  const media = await containerOrchestrator.inspect(params, "media-info");
435
436
  if (media.kind !== "result" || !map.isCurrent()) return [];
436
437
  const shift = Number(media.value?.startTimeSeconds) || 0;
438
+ // A zone's ranges are stated to the torrent at the precision it fetches:
439
+ // ranges closer than a piece hold the same pieces as one.
440
+ const pieceLength = Number(torrentPool.knownTorrent(map.sourceKey)?.pieceLength);
437
441
  const converted = [];
438
442
  const demands = [];
439
443
  for (const output of outputParts.outputs.values()) {
@@ -483,7 +487,7 @@ export async function startProxyServer({
483
487
  modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
484
488
  const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
485
489
  const convert = ranges => {
486
- for (const [byteStart, byteEnd] of ranges) converted.push({ ...demand,
490
+ for (const [byteStart, byteEnd] of joinedWithinPieces(ranges, pieceLength)) converted.push({ ...demand,
487
491
  downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
488
492
  };
489
493
  // The bytes one segment needs are a fact of the file and the output, so
@@ -72,7 +72,13 @@ export class PacketIndex {
72
72
  if (track.type !== "audio" || !isMp3Codec(track.codecId) ||
73
73
  (interval?.trackIds && !interval.trackIds.includes(id)) || interval?.modes?.[id] === "copy") continue;
74
74
  const records = track.packets;
75
- for (let position = 0; position < records.length; position++) {
75
+ // As in inputFor: records in time order are searched rather than read
76
+ // in full; one that starts too early to reach the interval is skipped
77
+ // below unless a record its preroll frames ahead does reach it.
78
+ const ordered = interval && records.ptsAscending;
79
+ const scanFrom = ordered ? Math.max(0, records.firstAtOrAfter(interval.from - records.longestDuration) - track.prerollFrames) : 0;
80
+ const scanTo = ordered ? records.firstAtOrAfter(interval.to) : records.length;
81
+ for (let position = scanFrom; position < scanTo; position++) {
76
82
  const packet = records.at(position);
77
83
  if (!packet.decodeDependencyUnknown || (interval &&
78
84
  (packet.pts >= interval.to || (packet.pts < interval.from && packet.pts + packet.duration <= interval.from &&
@@ -174,7 +180,15 @@ export class PacketIndex {
174
180
  const packets = track.packets;
175
181
  const startAt = from - (track.type === "audio" && mode === "transcode" ? track.prerollSeconds : 0);
176
182
  const selected = [];
177
- for (let index = 0; index < packets.length; index++) {
183
+ // Records in time order are searched: only one starting within the
184
+ // longest duration before the interval can reach into it. Read in full,
185
+ // a two-hour soundtrack is 340 thousand records per interval, and the
186
+ // download map asked for two thousand intervals at once: the proxy's main
187
+ // thread stopped for minutes (Home Assistant 2026-10-10, torrent-tv/meta#166).
188
+ const ordered = packets.ptsAscending;
189
+ const scanFrom = ordered ? packets.firstAtOrAfter(startAt - packets.longestDuration) : 0;
190
+ const scanTo = ordered ? packets.firstAtOrAfter(to) : packets.length;
191
+ for (let index = scanFrom; index < scanTo; index++) {
178
192
  const pts = packets.ptsAt(index);
179
193
  if (pts < to && (pts >= startAt ||
180
194
  ((mode === "transcode" || track.type === "audio") && pts + packets.durationAt(index) > startAt))) selected.push(index);
@@ -17,6 +17,12 @@ export class PacketRecords {
17
17
  #onDispose;
18
18
  #disposed = false;
19
19
  #prepaid = 0;
20
+ // Where the records first go back in time (-1 while they never do), the last
21
+ // time pushed, and the longest duration any record has had: what lets a
22
+ // reader find an interval by search instead of reading every record.
23
+ #descentAt = -1;
24
+ #lastPts = -Infinity;
25
+ #longest = 0;
20
26
 
21
27
  constructor(memory = {}) {
22
28
  const allocation = memory.forRecord?.(this) ?? memory;
@@ -77,9 +83,30 @@ export class PacketRecords {
77
83
  this.#bytes = [...this.#blocks, ...this.#addresses].reduce((sum, buffer) => sum + buffer.length, 0);
78
84
  }
79
85
  this.#length = value;
86
+ if (this.#descentAt >= value) this.#descentAt = -1;
87
+ this.#lastPts = value ? this.ptsAt(value - 1) : -Infinity;
88
+ if (!value) this.#longest = 0;
80
89
  if (previousBytes !== this.#bytes) this.#release(previousBytes - this.#bytes);
81
90
  }
82
91
 
92
+ /** Whether every record's time is at least the one before it. */
93
+ get ptsAscending() { return this.#descentAt < 0; }
94
+
95
+ /** No record has lasted longer than this, though a removed one may have: a bound, not a measurement. */
96
+ get longestDuration() { return this.#longest; }
97
+
98
+ /** The first record whose time is at least `seconds`, `length` when there is none; only when the times ascend. */
99
+ firstAtOrAfter(seconds) {
100
+ if (!this.ptsAscending) throw new Error("Records whose times go back cannot be searched by time.");
101
+ let low = 0, high = this.#length;
102
+ while (low < high) {
103
+ const middle = (low + high) >>> 1;
104
+ if (this.ptsAt(middle) < seconds) low = middle + 1;
105
+ else high = middle;
106
+ }
107
+ return low;
108
+ }
109
+
83
110
  push(packet) {
84
111
  if (this.#disposed) throw new Error("Packet records were released.");
85
112
  if (!Number.isFinite(packet.pts) || !Array.isArray(packet.ranges) ||
@@ -137,6 +164,9 @@ export class PacketRecords {
137
164
  if (packet.streamId !== undefined) block.writeUInt32LE(packet.streamId, at + bytes - 4 - (packet.decodeFromIndex !== undefined ? 4 : 0));
138
165
  if (packet.decodeFromIndex !== undefined) block.writeUInt32LE(packet.decodeFromIndex, at + bytes - 4);
139
166
  this.#used += bytes;
167
+ if (this.#descentAt < 0 && packet.pts < this.#lastPts) this.#descentAt = this.#length;
168
+ this.#lastPts = packet.pts;
169
+ if (packet.duration > this.#longest) this.#longest = packet.duration;
140
170
  return ++this.#length;
141
171
  }
142
172
 
@@ -204,7 +234,9 @@ export class PacketRecords {
204
234
  extendLastPresentation(seconds) {
205
235
  if (!this.#length) return;
206
236
  const [block, at] = this.#address(this.#length - 1);
207
- block.writeDoubleLE(block.readDoubleLE(at + 8) + seconds, at + 8);
237
+ const duration = block.readDoubleLE(at + 8) + seconds;
238
+ block.writeDoubleLE(duration, at + 8);
239
+ if (duration > this.#longest) this.#longest = duration;
208
240
  }
209
241
 
210
242
  setDuration(index, seconds, derived = false) {
@@ -213,6 +245,7 @@ export class PacketRecords {
213
245
  }
214
246
  const [block, at] = this.#address(index);
215
247
  block.writeDoubleLE(seconds, at + 8);
248
+ if (seconds > this.#longest) this.#longest = seconds;
216
249
  const flags = block.readUInt32LE(at + 48);
217
250
  block.writeUInt32LE(derived ? flags | 4 : flags & ~4, at + 48);
218
251
  }
@@ -106,3 +106,29 @@ export function nearestFirst({ byteStart, byteEnd, parts }) {
106
106
  }
107
107
  return ranges;
108
108
  }
109
+
110
+ /**
111
+ * Byte ranges joined wherever the gap between two of them is shorter than a
112
+ * piece: the pieces they hold are the same, and there are fewer of them.
113
+ *
114
+ * A gap shorter than a piece cannot hold a whole piece, so every piece it
115
+ * touches is touched by a range beside it. The soundtrack of an AVI read from
116
+ * its own packets is one range per packet, interleaved with the picture's
117
+ * bytes: field 2026-10-10 (torrent-tv/meta#166), a map of 350 191 zones took
118
+ * 20.8 s to reach the torrent thread and grew its heap to 818 MB, for pieces of
119
+ * 4 MiB that the whole soundtrack touches anyway.
120
+ *
121
+ * @param {Array<[number, number]>} ranges - Inclusive byte ranges in ascending order.
122
+ * @param {number} pieceLength
123
+ * @returns {Array<[number, number]>} The same ranges when the piece length is not usable.
124
+ */
125
+ export function joinedWithinPieces(ranges, pieceLength) {
126
+ if (!Number.isFinite(pieceLength) || pieceLength <= 0) return ranges;
127
+ const joined = [];
128
+ for (const [start, end] of ranges) {
129
+ const previous = joined.at(-1);
130
+ if (previous && start >= previous[0] && start - previous[1] - 1 < pieceLength) previous[1] = Math.max(previous[1], end);
131
+ else joined.push([start, end]);
132
+ }
133
+ return joined;
134
+ }