@torrent-tv/proxy 2.99.12 → 2.99.13

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.99.12",
3
+ "version": "2.99.13",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -44,7 +44,8 @@ import { ContainerTrack } from "../tracks/ContainerTrack.js";
44
44
  import { iterateElements, readFloat, readUint } from "./ebml-reader.js";
45
45
  import { ElementReader } from "./ebml-stream.js";
46
46
  import { firstFrameOffset, readBlockHeader, walkHeldClusters } from "./matroska-clusters.js";
47
- import { readMatroskaPackets } from "./matroska-packets.js";
47
+ import { buildMatroskaIndex, matroskaIntervalCovered, walkMatroskaPackets } from "./matroska-packets.js";
48
+ import { PacketRecordsChain } from "./PacketRecordsChain.js";
48
49
  import { RetainedReads } from "./RetainedReads.js";
49
50
  import { isUnavailable } from "./unavailable.js";
50
51
  import {
@@ -153,7 +154,17 @@ export class MatroskaContainer extends Container {
153
154
  /** @type {Array<import("../tracks/ContainerTrack.js").ContainerTrack> | null} */
154
155
  #tracks = null;
155
156
  #packets = null;
156
- #packetStates = new Map();
157
+ /**
158
+ * Per track, the readings of its packets in file order: each began where a
159
+ * request first needed it, records that track alone, and adjoins the next
160
+ * once it reaches it. One table per track per stretch of the file, and no
161
+ * packet in two of them (torrent-tv/meta#178).
162
+ *
163
+ * @type {Map<number, Array<{ startAt: number, at: number, cluster: object | null, packets: Map<number, import("./PacketRecords.js").PacketRecords>, next?: object, reachedEnd?: boolean }>>}
164
+ */
165
+ #packetReadings = new Map();
166
+ /** Per track, the walk under way: one at a time, so two cannot record one block. */
167
+ #packetWalks = new Map();
157
168
 
158
169
  /**
159
170
  * The Cues reading: undefined until read, null when the file has none.
@@ -175,8 +186,8 @@ export class MatroskaContainer extends Container {
175
186
  }
176
187
 
177
188
  packetIndexBytes() {
178
- return [...this.#packetStates.values()].reduce((sum, state) => sum +
179
- [...(state.packets?.values() ?? [])].reduce((bytes, records) => bytes + records.allocatedBytes, 0), 0);
189
+ return [...this.#packetReadings.values()].flat().reduce((sum, reading) => sum +
190
+ [...(reading.packets?.values() ?? [])].reduce((bytes, records) => bytes + records.allocatedBytes, 0), 0);
180
191
  }
181
192
 
182
193
  static detect(head) {
@@ -653,15 +664,19 @@ export class MatroskaContainer extends Container {
653
664
  * One preceding cluster retains preroll; one following cluster covers demux
654
665
  * lookahead beyond the presentation cut. No packet payload is parsed here.
655
666
  */
656
- async supportsOriginalSourceRanges() {
667
+ async supportsOriginalSourceRanges(interval = null) {
657
668
  const cues = await this.readCues();
658
669
  if (!cues?.points.length) return false;
659
670
  const video = ContainerTrack.firstUsable(await this.readTracks(), "video");
660
- // A soundtrack alone is read from the original file too, picture clusters
661
- // included (38 MB for one piece of 171 KB of sound, r46). Read from its own
662
- // packets (proxy 2.99.6), the soundtrack's packet table was refused memory
663
- // on Home Assistant, its first run was given one piece and never closed
664
- // it, and no Matroska film started (r47, torrent-tv/meta#178).
671
+ // Sound alone is read from its own packets, as an AVI's is
672
+ // (`AviContainer`). From the original file it was given whole clusters,
673
+ // picture included: 260 MB for 23 pieces of a soundtrack, 38 MB for one of
674
+ // 171 KB, on Home Assistant (r44, r46). Proxy 2.99.6 did so and no film
675
+ // started: every stretch the download map asked for kept a table for every
676
+ // track, 38.7 MB in a minute (r47); the soundtrack is now one chain of
677
+ // tables of its own (`#readTrackPackets`, torrent-tv/meta#178). Asked of
678
+ // the file as a whole, the answer is the picture's.
679
+ if (video && interval?.trackIds?.length && !interval.trackIds.includes(video.trackNumber)) return false;
665
680
  return cues.points.some(point => point.positions.some(position => !video || position.track === video.trackNumber));
666
681
  }
667
682
 
@@ -741,27 +756,110 @@ export class MatroskaContainer extends Container {
741
756
  if (!layout) throw new Error("Matroska Segment is absent.");
742
757
  const tracks = await this.readTracks();
743
758
  const info = await this.readMediaInfo();
744
- const start = await this.#packetStart(layout, tracks, interval);
745
- // Reuse a contiguous reading that has already reached this cue. A seek
746
- // across an unindexed gap gets its own reading instead of walking the gap.
747
- let state = [...this.#packetStates.values()]
748
- .filter(one => one.startAt <= start && one.at >= start)
749
- .sort((a, b) => b.startAt - a.startAt)[0];
750
- if (!state) {
751
- state = { startAt: start, at: start };
752
- this.#packetStates.set(start, state);
753
- }
754
- const index = await readMatroskaPackets({ readRange: this.readRange,
755
- fileSize: this.fileSize, portionBytes: this.portionBytes, layout, tracks,
756
- // The last frame ends where the film does, on the packets' own clock.
757
- endSeconds: info.durationSeconds === null ? null : info.durationSeconds + (info.startTimeSeconds ?? 0),
758
- interval, state, packetMemory: this.packetMemory });
759
- if (index.isComplete() && state.startAt === layout.firstClusterAt) {
760
- this.#packets = index;
759
+ const declared = new Map(tracks.filter(track => ["video", "audio", "subtitle"].includes(track.type))
760
+ .map(track => [track.trackNumber, track]));
761
+ // The tracks this request reads: those it names, the picture and sound
762
+ // when it names none, and every track for the whole file.
763
+ const requested = [...declared.values()].filter(track => interval?.trackIds ? interval.trackIds.includes(track.trackNumber)
764
+ : !interval || ["video", "audio"].includes(track.type));
765
+ const start = interval ? await this.#packetStart(layout, tracks, interval) : (layout.firstClusterAt ?? layout.segmentEnd);
766
+ // The last frame ends where the film does, on the packets' own clock.
767
+ const endSeconds = info.durationSeconds === null ? null : info.durationSeconds + (info.startTimeSeconds ?? 0);
768
+ const streams = new Map();
769
+ const complete = new Set();
770
+ let whole = true;
771
+ for (const track of requested) {
772
+ const chain = await this.#readTrackPackets(layout, tracks, track, start, interval);
773
+ streams.set(track.trackNumber, chain.records);
774
+ if (chain.reachesEnd) complete.add(track.trackNumber);
775
+ if (!chain.reachesEnd || chain.startAt !== layout.firstClusterAt) whole = false;
761
776
  }
777
+ const index = buildMatroskaIndex({ declared, streams, endSeconds, complete, covered: interval ?? null,
778
+ packetMemory: this.packetMemory });
779
+ if (!interval && whole) this.#packets = index;
762
780
  return index;
763
781
  }
764
782
 
783
+ /**
784
+ * One track's packets from `start` on, read as far as the interval needs.
785
+ *
786
+ * The reading that holds `start` goes on; with none, one begins there. A
787
+ * reading that reaches the next begins nothing new: the two adjoin and the
788
+ * walk goes on where the next one stopped. Before this, every request the
789
+ * download map made for a stretch not yet read began a reading of its own
790
+ * with a table for every track of the file; a 25-minute film's soundtrack
791
+ * asked 147 of them and held 38.7 MB of mostly empty tables in its first
792
+ * minute, and the rest was refused memory (r47, torrent-tv/meta#178).
793
+ *
794
+ * @returns {Promise<{ records: PacketRecordsChain, startAt: number, reachesEnd: boolean }>}
795
+ */
796
+ async #readTrackPackets(layout, tracks, track, start, interval) {
797
+ const id = track.trackNumber;
798
+ const previous = this.#packetWalks.get(id) ?? Promise.resolve();
799
+ let finished;
800
+ const walk = previous.then(() => new Promise(resolve => { finished = resolve; }));
801
+ this.#packetWalks.set(id, walk);
802
+ await previous;
803
+ try {
804
+ const readings = this.#packetReadings.get(id) ?? [];
805
+ this.#packetReadings.set(id, readings);
806
+ const position = reading => reading.cluster ? reading.cluster.cursor : reading.at;
807
+ let first = readings.find(reading => reading.startAt <= start && position(reading) >= start);
808
+ if (!first) {
809
+ first = { startAt: start, at: start, cluster: null };
810
+ readings.push(first);
811
+ readings.sort((left, right) => left.startAt - right.startAt);
812
+ }
813
+ const chainOf = () => {
814
+ const parts = [first];
815
+ while (parts.at(-1).next) parts.push(parts.at(-1).next);
816
+ return parts;
817
+ };
818
+ const viewOf = parts => new PacketRecordsChain(parts.map(reading => reading.packets?.get(id))
819
+ .filter(Boolean));
820
+ // Asked after every block of a walk: the reading being extended answers
821
+ // alone once it holds the packets the question looks at.
822
+ const declared = new Map([[id, track]]);
823
+ const asked = interval ? { ...interval, trackIds: [id] } : null;
824
+ const depth = track.type === "video" ? track.reorderDepth : 0;
825
+ let current = null;
826
+ const covered = () => {
827
+ if (!asked) return false;
828
+ const own = current?.packets?.get(id);
829
+ if (own && Number.isSafeInteger(depth) && own.length > depth) {
830
+ return matroskaIntervalCovered(declared, new Map([[id, own]]), asked);
831
+ }
832
+ const parts = chainOf();
833
+ if (!parts.every(reading => reading.packets)) return false;
834
+ return matroskaIntervalCovered(declared, new Map([[id, viewOf(parts)]]), asked);
835
+ };
836
+ for (;;) {
837
+ current = chainOf().at(-1);
838
+ if (current.reachedEnd) break;
839
+ const following = readings.find(reading => reading.startAt > current.startAt);
840
+ const outcome = await walkMatroskaPackets({ readRange: this.readRange, fileSize: this.fileSize,
841
+ portionBytes: this.portionBytes, layout, tracks, trackIds: [id], interval, packetMemory: this.packetMemory,
842
+ state: current, until: following?.startAt ?? null, isCovered: covered });
843
+ if (outcome !== "until") break;
844
+ current.next = following;
845
+ }
846
+ // The request reads as far as its interval reaches, not every reading
847
+ // adjoined since: each of its packets is looked at once per request.
848
+ const parts = chainOf();
849
+ if (asked && Number.isSafeInteger(depth)) {
850
+ const reaching = parts.findIndex(reading => {
851
+ const own = reading.packets.get(id);
852
+ return own.length > depth && matroskaIntervalCovered(declared, new Map([[id, own]]), asked);
853
+ });
854
+ if (reaching >= 0) parts.length = reaching + 1;
855
+ }
856
+ return { records: viewOf(parts), startAt: first.startAt, reachesEnd: parts.at(-1).reachedEnd === true };
857
+ } finally {
858
+ finished();
859
+ if (this.#packetWalks.get(id) === walk) this.#packetWalks.delete(id);
860
+ }
861
+ }
862
+
765
863
  async #packetStart(layout, tracks, interval) {
766
864
  const first = layout.firstClusterAt ?? layout.segmentEnd;
767
865
  if (!Number.isFinite(interval?.from) || interval.from <= 0) return first;
@@ -1,4 +1,5 @@
1
1
  import { PacketRecords } from "./PacketRecords.js";
2
+ import { PacketRecordsChain } from "./PacketRecordsChain.js";
2
3
  import { IndexMemoryUnavailable } from "./memory-unavailable.js";
3
4
  import { isMp3Codec, mp3PacketFacts } from "./mp3-packet-dependencies.js";
4
5
 
@@ -95,13 +96,19 @@ export class PacketIndex {
95
96
  }
96
97
  }
97
98
 
98
- /** The progressive parser and its interval views share one packet allocation. */
99
+ /**
100
+ * The progressive parser and its interval views share one packet allocation.
101
+ * A chain of a container's own tables was checked record by record as each
102
+ * was written; read again in full for every request, a two-hour soundtrack
103
+ * would be 340 thousand records per request (torrent-tv/meta#178).
104
+ */
99
105
  sharePackets(id, records) {
100
106
  const track = this.#track(id);
101
- if (track.complete || track.packets.length || !(records instanceof PacketRecords)) {
107
+ const chain = records instanceof PacketRecordsChain;
108
+ if (track.complete || track.packets.length || !(records instanceof PacketRecords || chain)) {
102
109
  throw new TypeError("Only an empty unfinished track may adopt packet records.");
103
110
  }
104
- for (const packet of records) validatePacket(packet);
111
+ if (!chain) for (const packet of records) validatePacket(packet);
105
112
  track.packets.dispose();
106
113
  track.packets = records;
107
114
  }
@@ -3,6 +3,12 @@
3
3
  import { IndexMemoryUnavailable } from "./memory-unavailable.js";
4
4
 
5
5
  const BLOCK_BYTES = 65536;
6
+ // A table's first blocks, of records and of their addresses, and each block
7
+ // after it twice the one before, up to BLOCK_BYTES. Taken whole from the first
8
+ // record, the 128 KiB of a table that held a few hundred records was most of a
9
+ // Matroska soundtrack read in stretches: 144 tables of one 25-minute film held
10
+ // 18.9 MB for 3.7 MB of records (r47 reproduced, torrent-tv/meta#178).
11
+ const FIRST_BLOCK_BYTES = 4096;
6
12
  const ADDRESS_BYTES = 8;
7
13
  const HEADER_BYTES = 56;
8
14
 
@@ -78,6 +84,12 @@ export class PacketRecords {
78
84
  const block = this.#blocks[blockIndex];
79
85
  this.#blocks.length = blockIndex + 1;
80
86
  this.#addresses.length = Math.ceil(value * ADDRESS_BYTES / BLOCK_BYTES);
87
+ // The last address block back to the size its records need, as it grew.
88
+ const lastAddresses = this.#addresses.at(-1);
89
+ const neededAddresses = ((value - 1) % (BLOCK_BYTES / ADDRESS_BYTES) + 1) * ADDRESS_BYTES;
90
+ let addressBytes = FIRST_BLOCK_BYTES;
91
+ while (addressBytes < neededAddresses) addressBytes *= 2;
92
+ if (lastAddresses.length > addressBytes) this.#addresses[this.#addresses.length - 1] = Buffer.from(lastAddresses.subarray(0, addressBytes));
81
93
  const flags = block.readUInt32LE(at + 48);
82
94
  this.#used = at + HEADER_BYTES + block.readUInt32LE(at + 52) * 16 + ((flags & 2) ? 32 : 0) + ((flags & 8) ? 4 : 0) + ((flags & 16) ? 4 : 0);
83
95
  this.#bytes = [...this.#blocks, ...this.#addresses].reduce((sum, buffer) => sum + buffer.length, 0);
@@ -121,17 +133,25 @@ export class PacketRecords {
121
133
  const bytes = HEADER_BYTES + packet.ranges.length * 16 + (packet.expectedHash ? 32 : 0) + (packet.streamId !== undefined ? 4 : 0) + (packet.decodeFromIndex !== undefined ? 4 : 0);
122
134
  let block = this.#blocks.at(-1);
123
135
  const newBlock = !block || block.length - this.#used < bytes;
136
+ const blockBytes = newBlock ? Math.max(bytes, Math.min(BLOCK_BYTES, block ? block.length * 2 : FIRST_BLOCK_BYTES)) : 0;
124
137
  const perAddressBlock = BLOCK_BYTES / ADDRESS_BYTES;
125
138
  const addressBlockIndex = Math.floor(this.#length / perAddressBlock);
126
- const newAddresses = !this.#addresses[addressBlockIndex];
127
- const growth = (newBlock ? Math.max(BLOCK_BYTES, bytes) : 0) + (newAddresses ? BLOCK_BYTES : 0);
139
+ const addressAt = (this.#length % perAddressBlock) * ADDRESS_BYTES;
140
+ const heldAddresses = this.#addresses[addressBlockIndex];
141
+ // An address block is the same span of records at any size; a full one
142
+ // below BLOCK_BYTES is copied into one twice its size.
143
+ const addressBytes = !heldAddresses ? FIRST_BLOCK_BYTES
144
+ : addressAt >= heldAddresses.length ? Math.min(BLOCK_BYTES, heldAddresses.length * 2) : 0;
145
+ const newAddresses = addressBytes > 0;
146
+ const addressGrowth = newAddresses ? addressBytes - (heldAddresses?.length ?? 0) : 0;
147
+ const growth = blockBytes + addressGrowth;
128
148
  const prepaid = Math.min(growth, this.#prepaid);
129
149
  if (growth > prepaid && !this.#reserve(growth - prepaid)) throw new IndexMemoryUnavailable(growth - prepaid);
130
150
  this.#prepaid -= prepaid;
131
151
  let payloadAllocation, addressAllocation;
132
152
  try {
133
- if (newBlock) payloadAllocation = Buffer.alloc(Math.max(BLOCK_BYTES, bytes));
134
- if (newAddresses) addressAllocation = Buffer.alloc(BLOCK_BYTES);
153
+ if (newBlock) payloadAllocation = Buffer.alloc(blockBytes);
154
+ if (newAddresses) addressAllocation = Buffer.alloc(addressBytes);
135
155
  } catch (error) {
136
156
  if (growth) this.#release(growth);
137
157
  throw error;
@@ -143,11 +163,11 @@ export class PacketRecords {
143
163
  this.#used = 0;
144
164
  }
145
165
  if (newAddresses) {
146
- this.#addresses.push(addressAllocation);
147
- this.#bytes += BLOCK_BYTES;
166
+ heldAddresses?.copy(addressAllocation);
167
+ this.#addresses[addressBlockIndex] = addressAllocation;
168
+ this.#bytes += addressGrowth;
148
169
  }
149
170
  const address = this.#addresses[addressBlockIndex];
150
- const addressAt = (this.#length % perAddressBlock) * ADDRESS_BYTES;
151
171
  address.writeUInt32LE(this.#blocks.length - 1, addressAt);
152
172
  address.writeUInt32LE(this.#used, addressAt + 4);
153
173
  const at = this.#used;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @file Packet records of one track read as one list across adjoining tables.
3
+ *
4
+ * A Matroska track's packets are read in stretches: wherever a viewer is, the
5
+ * download map asks first, and a stretch begins at the cue before it. Each
6
+ * stretch keeps its own table, and when one reaches the next they adjoin. A
7
+ * request reads the stretches it spans as one list through this view; nothing
8
+ * is copied, and the tables stay the container's (torrent-tv/meta#178).
9
+ *
10
+ * Read-only apart from what the index derives per request: a duration worked
11
+ * out from the following packet is written to the table holding the packet,
12
+ * and an MP3 decoder dependency to the table holding it when its first packet
13
+ * is in the same table. One that reaches into an earlier table is kept in this
14
+ * view only, since a table states its dependencies by its own positions.
15
+ */
16
+ export class PacketRecordsChain {
17
+ #parts;
18
+ #offsets;
19
+ #fixed;
20
+ #dependencies = new Map();
21
+
22
+ /** @param {import("./PacketRecords.js").PacketRecords[]} parts - In file order; every part but the last adjoins the next and no longer grows. */
23
+ constructor(parts) {
24
+ if (!Array.isArray(parts) || parts.length === 0) throw new TypeError("A packet chain needs at least one table.");
25
+ this.#parts = parts;
26
+ this.#offsets = [];
27
+ let offset = 0;
28
+ for (const part of parts) {
29
+ this.#offsets.push(offset);
30
+ offset += part.length;
31
+ }
32
+ this.#fixed = this.#offsets.at(-1);
33
+ }
34
+
35
+ get length() { return this.#fixed + this.#parts.at(-1).length; }
36
+ get allocatedBytes() { return 0; }
37
+
38
+ get ptsAscending() {
39
+ for (let part = 0; part < this.#parts.length; part++) {
40
+ const records = this.#parts[part];
41
+ if (!records.ptsAscending) return false;
42
+ const next = this.#parts[part + 1];
43
+ if (next?.length && records.length && next.ptsAt(0) < records.ptsAt(records.length - 1)) return false;
44
+ }
45
+ return true;
46
+ }
47
+
48
+ get longestDuration() { return Math.max(...this.#parts.map(part => part.longestDuration)); }
49
+
50
+ /** The table holding `index` and the position within it. */
51
+ #locate(index) {
52
+ if (!Number.isSafeInteger(index) || index < 0 || index >= this.length) throw new RangeError(`No packet record ${index}.`);
53
+ if (index >= this.#fixed) return [this.#parts.length - 1, index - this.#fixed];
54
+ let low = 0, high = this.#parts.length - 1;
55
+ while (low < high) {
56
+ const middle = (low + high + 1) >>> 1;
57
+ if (this.#offsets[middle] <= index) low = middle;
58
+ else high = middle - 1;
59
+ }
60
+ return [low, index - this.#offsets[low]];
61
+ }
62
+
63
+ ptsAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].ptsAt(at); }
64
+ durationAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].durationAt(at); }
65
+ keyframeAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].keyframeAt(at); }
66
+ derivedDurationAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].derivedDurationAt(at); }
67
+
68
+ setDuration(index, seconds, derived = false) {
69
+ const [part, at] = this.#locate(index);
70
+ this.#parts[part].setDuration(at, seconds, derived);
71
+ }
72
+
73
+ setDecodeFromIndex(index, first) {
74
+ if (!Number.isSafeInteger(first) || first < 0 || first > index) throw new TypeError("Invalid decoder dependency address.");
75
+ const [part, at] = this.#locate(index);
76
+ const offset = this.#offsets[part];
77
+ if (first >= offset) {
78
+ this.#parts[part].setDecodeFromIndex(at, first - offset);
79
+ this.#dependencies.delete(index);
80
+ } else {
81
+ this.#dependencies.set(index, first);
82
+ }
83
+ }
84
+
85
+ at(index) {
86
+ if (index < 0) index += this.length;
87
+ if (!Number.isSafeInteger(index) || index < 0 || index >= this.length) return undefined;
88
+ const [part, at] = this.#locate(index);
89
+ const packet = this.#parts[part].at(at);
90
+ if (this.#dependencies.has(index)) {
91
+ packet.decodeFromIndex = this.#dependencies.get(index);
92
+ delete packet.decodeDependencyUnknown;
93
+ } else if (packet.decodeFromIndex !== undefined) {
94
+ packet.decodeFromIndex += this.#offsets[part];
95
+ }
96
+ return packet;
97
+ }
98
+
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
+
110
+ /** The tables are the container's: a request's index lets go of nothing. */
111
+ dispose() {}
112
+ releaseUnusedCapacity() {}
113
+
114
+ *[Symbol.iterator]() { for (let index = 0; index < this.length; index++) yield this.at(index); }
115
+ map(callback) { return Array.from(this, callback); }
116
+ filter(callback) { return Array.from(this).filter(callback); }
117
+ some(callback) { for (const packet of this) if (callback(packet)) return true; return false; }
118
+ every(callback) { for (const packet of this) if (!callback(packet)) return false; return true; }
119
+ reduce(callback, initial) {
120
+ let value = initial;
121
+ for (const packet of this) value = callback(value, packet);
122
+ return value;
123
+ }
124
+ slice(start = 0, end = this.length) {
125
+ const length = this.length;
126
+ if (start < 0) start = Math.max(0, length + start);
127
+ if (end < 0) end = Math.max(0, length + end);
128
+ const records = [];
129
+ for (let index = start; index < Math.min(end, length); index++) records.push(this.at(index));
130
+ return records;
131
+ }
132
+ }
@@ -10,17 +10,51 @@ const CLUSTER = 0x1f43b675;
10
10
  const LEVEL_ONE = new Set([CLUSTER, 0x1c53bb6b, 0x114d9b74, 0x1549a966, 0x1654ae6b, 0x1043a770, 0x1941a469, 0x1254c367]);
11
11
 
12
12
  /** Index frame addresses by reading structural headers, never frame bodies. */
13
- export async function readMatroskaPackets({ readRange, fileSize, portionBytes, layout, tracks, endSeconds, interval, packetMemory, state = {} }) {
13
+ export async function readMatroskaPackets(params) {
14
+ const { tracks, endSeconds, interval, packetMemory, state = {} } = params;
15
+ const outcome = await walkMatroskaPackets({ ...params, state });
16
+ const declared = recordedTracks(tracks, params.trackIds);
17
+ const complete = outcome === "end";
18
+ return buildMatroskaIndex({ declared, streams: state.packets, endSeconds,
19
+ complete: complete ? new Set(declared.keys()) : new Set(), covered: complete ? null : interval, packetMemory });
20
+ }
21
+
22
+ /**
23
+ * The tracks a walk records: every media track, or only those named.
24
+ *
25
+ * @param {object[]} tracks
26
+ * @param {number[]} [trackIds]
27
+ * @returns {Map<number, object>}
28
+ */
29
+ function recordedTracks(tracks, trackIds) {
30
+ return new Map(tracks.filter(track => ["video", "audio", "subtitle"].includes(track.type) &&
31
+ (!trackIds || trackIds.includes(track.trackNumber))).map(track => [track.trackNumber, track]));
32
+ }
33
+
34
+ /**
35
+ * Walk the clusters from where `state` stopped, recording the packets of the
36
+ * tracks it records, until the interval is covered (`isCovered`, by default
37
+ * over this state's own records), the walk reaches `until` — where another
38
+ * reading of the same tracks begins — or the segment ends.
39
+ *
40
+ * Only the named tracks are recorded: a soundtrack read on its own kept the
41
+ * picture's table as well, half of 37.9 MB on r47's order (torrent-tv/meta#178).
42
+ *
43
+ * @returns {Promise<"covered" | "until" | "end">}
44
+ */
45
+ export async function walkMatroskaPackets({ readRange, fileSize, portionBytes, layout, tracks, trackIds, interval, packetMemory, state, until = null, isCovered = null }) {
14
46
  const reader = new ElementReader({ read: readRange, fileSize, portionBytes });
15
- const declared = new Map(tracks.filter(track => ["video", "audio", "subtitle"].includes(track.type)).map(track => [track.trackNumber, track]));
47
+ const declared = recordedTracks(tracks, trackIds);
16
48
  if (!state.packets) {
17
49
  state.packets = new Map([...declared.keys()].map(id => [id, new PacketRecords(packetMemory)]));
18
50
  state.at ??= layout.firstClusterAt ?? layout.segmentEnd;
19
51
  }
20
52
  const packets = state.packets;
21
- if (intervalCovered(declared, packets, interval)) return buildIndex(declared, packets, endSeconds, false, interval, packetMemory);
53
+ const covered = isCovered ?? (() => intervalCovered(declared, packets, interval));
54
+ const limit = Math.min(until ?? layout.segmentEnd, layout.segmentEnd);
55
+ if (covered()) return "covered";
22
56
  let at = state.at;
23
- while (at < layout.segmentEnd) {
57
+ while (at < limit) {
24
58
  const element = state.cluster?.element ?? await validHeader(reader, at, layout.segmentEnd);
25
59
  if (element.id !== CLUSTER) {
26
60
  if (element.end === null) throw new Error("An unknown-sized Matroska element is not a Cluster.");
@@ -80,14 +114,16 @@ export async function readMatroskaPackets({ readRange, fileSize, portionBytes, l
80
114
  cursor = child.end;
81
115
  state.cluster.cursor = cursor;
82
116
  state.cluster.clusterTicks = clusterTicks;
83
- if (intervalCovered(declared, packets, interval)) return buildIndex(declared, packets, endSeconds, false, interval, packetMemory);
117
+ if (covered()) return "covered";
84
118
  }
85
119
  if (cursor <= at) throw new Error("Matroska packet walk did not advance.");
86
120
  at = element.end ?? cursor;
87
121
  state.at = at;
88
122
  state.cluster = null;
89
123
  }
90
- return buildIndex(declared, packets, endSeconds, true, undefined, packetMemory);
124
+ if (at < layout.segmentEnd) return "until";
125
+ state.reachedEnd = true;
126
+ return "end";
91
127
  }
92
128
 
93
129
  function paddingForFrame(seconds, duration, count, frame) {
@@ -110,17 +146,28 @@ function intervalCovered(declared, packets, interval) {
110
146
  });
111
147
  }
112
148
 
113
- function buildIndex(declared, packets, endSeconds, complete, interval, packetMemory) {
149
+ /** Whether the interval's requested tracks reach past its end; exported for a container's own chains. */
150
+ export { intervalCovered as matroskaIntervalCovered };
151
+
152
+ /**
153
+ * A packet index over the given streams of the declared tracks.
154
+ *
155
+ * `complete` names the tracks no packet follows; `covered` is the interval
156
+ * the other requested tracks are known to reach past. A declared track with no
157
+ * stream is declared empty.
158
+ */
159
+ export function buildMatroskaIndex({ declared, streams, endSeconds, complete, covered, packetMemory }) {
114
160
  const index = new PacketIndex();
115
161
  for (const [id, track] of declared) {
116
162
  index.declareTrack(id, { type: track.type, codecId: track.codecId, codecRanges: track.codecRanges ?? [], prerollSeconds: track.seekPrerollSeconds ?? 0, reorderDepth: track.reorderDepth ?? 0 });
117
- const stream = packets.get(id);
163
+ const stream = streams.get(id);
164
+ if (!stream) continue;
118
165
  let needsFollowing = false;
119
166
  for (let position = 0; position < stream.length; position++) {
120
167
  if (stream.durationAt(position) === 0 || stream.derivedDurationAt(position)) { needsFollowing = true; break; }
121
168
  }
122
169
  if (needsFollowing) {
123
- const endFor = followingPacketTime(stream, complete ? endSeconds : null, packetMemory);
170
+ const endFor = followingPacketTime(stream, complete.has(id) ? endSeconds : null, packetMemory);
124
171
  try { for (let position = 0; position < stream.length; position++) {
125
172
  if (stream.durationAt(position) !== 0 && !stream.derivedDurationAt(position)) continue;
126
173
  const pts = stream.ptsAt(position), end = endFor(pts);
@@ -128,9 +175,9 @@ function buildIndex(declared, packets, endSeconds, complete, interval, packetMem
128
175
  } } finally { endFor.dispose(); }
129
176
  }
130
177
  index.sharePackets(id, stream);
131
- if (complete) index.complete(id);
132
- else if (interval.trackIds ? interval.trackIds.includes(id) : ["video", "audio"].includes(track.type)) {
133
- index.coverThrough(id, interval.to);
178
+ if (complete.has(id)) index.complete(id);
179
+ else if (covered && (covered.trackIds ? covered.trackIds.includes(id) : ["video", "audio"].includes(track.type))) {
180
+ index.coverThrough(id, covered.to);
134
181
  }
135
182
  }
136
183
  return index;