@torrent-tv/proxy 2.88.6 → 2.89.0
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/AGENTS.md +21 -12
- package/CHANGELOG.md +17 -3
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/docs/container-architecture.md +77 -22
- package/docs/playback-start-forecast.md +56 -56
- package/main2.cpuprofile +1 -0
- package/package.json +1 -1
- package/scripts/build-audio-samples.mjs +31 -31
- package/server.js +42 -27
- package/services/encode/OutputOpening.js +5 -4
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- package/services/media/ContainerOrchestrator.js +99 -46
- package/services/media/KeyframeTables.js +61 -21
- package/services/media/SubtitleCues.js +255 -183
- package/services/media/container/AviContainer.js +5 -3
- package/services/media/container/Container.js +66 -43
- package/services/media/container/ContainerFactory.js +8 -26
- package/services/media/container/MatroskaContainer.js +719 -1182
- package/services/media/container/Mp4Container.js +83 -73
- package/services/media/container/ebml-stream.js +174 -0
- package/services/media/container/matroska-clusters.js +1165 -0
- package/services/media/container/unavailable.js +77 -0
- package/services/media/tracks/ContainerTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +9 -1
- package/services/server/playback-readiness.js +516 -516
- package/services/torrent/worker/client.js +6 -2
- package/services/torrent/worker/held-bytes.js +84 -8
- package/services/torrent/worker/worker.js +10 -2
- package/services/transport/data-channel-handler.js +7 -3
- package/services/viewer/PriorityMap.js +31 -0
- package/test/audio-calibration.test.js +23 -23
- package/test/coalesce.test.js +39 -0
- package/test/held-read-ownership.test.js +77 -0
- package/test/helpers/held-file.js +50 -2
- package/test/helpers/matroska-file.js +296 -0
- package/test/keyframe-table-budget.test.js +21 -0
- package/test/matroska-cluster-discovery.test.js +323 -0
- package/test/reads-not-yet-arrived.test.js +218 -0
- package/test/subtitle-cue-walk.test.js +16 -1
- package/test/subtitle-plan-waits.test.js +125 -0
- package/test/subtitle-track-numbering.test.js +8 -3
- package/test/viewer-starts-on-map.test.js +27 -0
- package/utils/coalesce.js +41 -0
|
@@ -20,6 +20,7 @@ import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
|
20
20
|
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
21
21
|
import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
|
|
22
22
|
import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
|
|
23
|
+
import { isUnavailable } from "./unavailable.js";
|
|
23
24
|
|
|
24
25
|
/** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
|
|
25
26
|
const HEADER_BYTES = 8;
|
|
@@ -199,8 +200,9 @@ export class Mp4Container extends Container {
|
|
|
199
200
|
* @param {number} fileSize
|
|
200
201
|
* @returns {Promise<number[]|null>} Null where the container has no index.
|
|
201
202
|
*/
|
|
202
|
-
static readKeyframeTimes(readRange, fileSize) {
|
|
203
|
-
|
|
203
|
+
static async readKeyframeTimes(readRange, fileSize) {
|
|
204
|
+
const index = await new Mp4Container({ readRange, fileSize }).parseKeyframeIndex();
|
|
205
|
+
return index ? index.times : null;
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
/**
|
|
@@ -211,8 +213,9 @@ export class Mp4Container extends Container {
|
|
|
211
213
|
* @param {number} fileSize
|
|
212
214
|
* @returns {Promise<{ tracks: object[] } | null>}
|
|
213
215
|
*/
|
|
214
|
-
static readSubtitlePlan(readRange, fileSize) {
|
|
215
|
-
|
|
216
|
+
static async readSubtitlePlan(readRange, fileSize) {
|
|
217
|
+
const held = await new Mp4Container({ readRange, fileSize }).#moovBuffer();
|
|
218
|
+
return held ? subtitlePlanFromMoov(held.moov) : null;
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
/**
|
|
@@ -224,7 +227,8 @@ export class Mp4Container extends Container {
|
|
|
224
227
|
* @returns {Promise<object|null>}
|
|
225
228
|
*/
|
|
226
229
|
async readSubtitlePlan() {
|
|
227
|
-
const
|
|
230
|
+
const held = await this.#moovBuffer();
|
|
231
|
+
const plan = held ? subtitlePlanFromMoov(held.moov) : null;
|
|
228
232
|
if (!plan) {
|
|
229
233
|
return null;
|
|
230
234
|
}
|
|
@@ -241,6 +245,7 @@ export class Mp4Container extends Container {
|
|
|
241
245
|
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
242
246
|
codecId: track.format,
|
|
243
247
|
language: track.language,
|
|
248
|
+
languageSource: "language",
|
|
244
249
|
name: "",
|
|
245
250
|
isDefault: order === 0,
|
|
246
251
|
codecPrivate: "",
|
|
@@ -262,46 +267,63 @@ export class Mp4Container extends Container {
|
|
|
262
267
|
* reads per sample where Matroska reads per cluster.
|
|
263
268
|
*
|
|
264
269
|
* Nothing is fetched: a sample whose bytes are not downloaded is left for the
|
|
265
|
-
* next call
|
|
270
|
+
* next call, and so is one whose read did not complete — a sample is marked
|
|
271
|
+
* read only once its bytes are in hand.
|
|
266
272
|
*
|
|
267
|
-
* @param {
|
|
268
|
-
* @param {
|
|
269
|
-
* @
|
|
270
|
-
*
|
|
273
|
+
* @param {object} _plan
|
|
274
|
+
* @param {{ trackNumber: number, codecId: string, samples: {offset: number, size: number, startSeconds: number, endSeconds: number}[] }} track
|
|
275
|
+
* @param {object} progress
|
|
276
|
+
* @param {import("./Container.js").HeldReader} held
|
|
277
|
+
* @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number, withdrawn: number[] }>}
|
|
271
278
|
*/
|
|
272
|
-
async readHeldCues(_plan, track, progress) {
|
|
273
|
-
|
|
279
|
+
async readHeldCues(_plan, track, progress, held) {
|
|
280
|
+
if (!progress.mp4) {
|
|
281
|
+
progress.mp4 = { harvested: new Map() };
|
|
282
|
+
}
|
|
283
|
+
let harvested = progress.mp4.harvested.get(track.trackNumber);
|
|
274
284
|
if (!harvested) {
|
|
275
285
|
harvested = new Set();
|
|
276
|
-
progress.harvested.set(track.trackNumber, harvested);
|
|
286
|
+
progress.mp4.harvested.set(track.trackNumber, harvested);
|
|
277
287
|
}
|
|
278
|
-
const cues = await this.readHeldSamples(track, harvested);
|
|
288
|
+
const cues = await this.readHeldSamples(track, harvested, held);
|
|
279
289
|
return {
|
|
280
290
|
found: cues.length > 0 ? new Map([[track.trackNumber, cues]]) : new Map(),
|
|
281
291
|
covered: harvested.size,
|
|
282
|
-
indexed: track?.samples?.length ?? 0
|
|
292
|
+
indexed: track?.samples?.length ?? 0,
|
|
293
|
+
withdrawn: []
|
|
283
294
|
};
|
|
284
295
|
}
|
|
285
296
|
|
|
286
|
-
|
|
297
|
+
/**
|
|
298
|
+
* @param {object} track
|
|
299
|
+
* @param {Set<number>} harvested
|
|
300
|
+
* @param {import("./Container.js").HeldReader} held
|
|
301
|
+
* @returns {Promise<{startSeconds: number, endSeconds: number, text: string, source: number}[]>}
|
|
302
|
+
*/
|
|
303
|
+
async readHeldSamples(track, harvested, held) {
|
|
287
304
|
const found = [];
|
|
288
305
|
for (const sample of track?.samples ?? []) {
|
|
289
306
|
if (harvested.has(sample.offset)) {
|
|
290
307
|
continue;
|
|
291
308
|
}
|
|
292
309
|
const last = Math.min(this.fileSize - 1, sample.offset + sample.size - 1);
|
|
293
|
-
if (!
|
|
310
|
+
if (!held.isHeld(sample.offset, last)) {
|
|
294
311
|
continue;
|
|
295
312
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
313
|
+
let bytes;
|
|
314
|
+
try {
|
|
315
|
+
bytes = await held.read(sample.offset, last);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (isUnavailable(error)) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
299
321
|
}
|
|
300
322
|
harvested.add(sample.offset);
|
|
301
323
|
// The MP4 has framed this cue and is the one that unframes it.
|
|
302
324
|
const text = Mp4Container.cueTextOf(bytes, track.codecId);
|
|
303
325
|
if (text) {
|
|
304
|
-
found.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text });
|
|
326
|
+
found.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text, source: sample.offset });
|
|
305
327
|
}
|
|
306
328
|
}
|
|
307
329
|
return found;
|
|
@@ -309,13 +331,14 @@ export class Mp4Container extends Container {
|
|
|
309
331
|
|
|
310
332
|
async readTracks() {
|
|
311
333
|
const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
|
|
312
|
-
if (!
|
|
334
|
+
if (!isMp4(head)) return [];
|
|
313
335
|
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
const
|
|
336
|
+
// Subtitle tracks from the subtitle reading of the one `moov`, video and
|
|
337
|
+
// audio from a walk of the same buffer — the box is read once per file.
|
|
338
|
+
// A read that has not arrived is not swallowed here: it is not an answer
|
|
339
|
+
// about the file, and the caller keeps nothing for it.
|
|
340
|
+
const held = await this.#moovBuffer();
|
|
341
|
+
const subtitlePlan = held ? subtitlePlanFromMoov(held.moov) : null;
|
|
319
342
|
const subtitleByDecl = new Map();
|
|
320
343
|
if (subtitlePlan?.tracks) {
|
|
321
344
|
for (const t of subtitlePlan.tracks) subtitleByDecl.set(t.declaredIndex, t);
|
|
@@ -336,6 +359,7 @@ export class Mp4Container extends Container {
|
|
|
336
359
|
codecId: s.format,
|
|
337
360
|
language: s.language,
|
|
338
361
|
languageBcp47: "",
|
|
362
|
+
languageSource: "language",
|
|
339
363
|
name: "",
|
|
340
364
|
isEnabled: true,
|
|
341
365
|
isDefault: s.declaredIndex === 0,
|
|
@@ -359,11 +383,18 @@ export class Mp4Container extends Container {
|
|
|
359
383
|
}
|
|
360
384
|
|
|
361
385
|
/**
|
|
362
|
-
* The `moov` box, read whole
|
|
386
|
+
* The `moov` box, read whole — the ONE reading of it every question here is
|
|
387
|
+
* answered from.
|
|
363
388
|
*
|
|
364
389
|
* Held on the instance because every question this class answers is inside
|
|
365
|
-
* it, and the box can be tens of megabytes off a torrent
|
|
366
|
-
*
|
|
390
|
+
* it, and the box can be tens of megabytes off a torrent. Until 2026-10-01 it
|
|
391
|
+
* was read by three functions besides this one, each with its own rule for a
|
|
392
|
+
* read that failed.
|
|
393
|
+
*
|
|
394
|
+
* Three outcomes are kept: the box; null where the file has no `moov`; and
|
|
395
|
+
* null with `moovRefused` set where the box is larger than this program reads
|
|
396
|
+
* whole — a limit of ours, said as one, not an absence. A read whose bytes
|
|
397
|
+
* have not arrived throws `BytesUnavailable` and keeps nothing.
|
|
367
398
|
*
|
|
368
399
|
* @returns {Promise<{ moov: Buffer, header: number } | null>}
|
|
369
400
|
*/
|
|
@@ -371,31 +402,18 @@ export class Mp4Container extends Container {
|
|
|
371
402
|
if (this.moovHeld !== undefined) {
|
|
372
403
|
return this.moovHeld;
|
|
373
404
|
}
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
while (at < this.fileSize) {
|
|
379
|
-
const probe = await this.readRange(at, Math.min(this.fileSize - 1, at + PROBE - 1));
|
|
380
|
-
if (!probe || probe.length < 8) break;
|
|
381
|
-
let size = probe.readUInt32BE(0);
|
|
382
|
-
const type = probe.toString("latin1", 4, 8);
|
|
383
|
-
let header = 8;
|
|
384
|
-
if (size === 1) {
|
|
385
|
-
if (probe.length < 16) break;
|
|
386
|
-
size = Number(probe.readBigUInt64BE(8));
|
|
387
|
-
header = 16;
|
|
388
|
-
}
|
|
389
|
-
if (size <= 0) break;
|
|
390
|
-
if (type === "moov") { moovBox = { offset: at, size, header }; break; }
|
|
391
|
-
at += size;
|
|
405
|
+
const found = await findMoov(this.readRange, this.fileSize);
|
|
406
|
+
if (!found) {
|
|
407
|
+
this.moovHeld = null;
|
|
408
|
+
return null;
|
|
392
409
|
}
|
|
393
|
-
if (
|
|
410
|
+
if (found.size > MAX_MOOV_BYTES) {
|
|
411
|
+
this.moovRefused = { size: found.size, limit: MAX_MOOV_BYTES };
|
|
394
412
|
this.moovHeld = null;
|
|
395
413
|
return null;
|
|
396
414
|
}
|
|
397
|
-
const moov = await this.readRange(
|
|
398
|
-
this.moovHeld =
|
|
415
|
+
const moov = await this.readRange(found.offset, found.offset + found.size - 1);
|
|
416
|
+
this.moovHeld = { moov, header: found.headerBytes };
|
|
399
417
|
return this.moovHeld;
|
|
400
418
|
}
|
|
401
419
|
|
|
@@ -417,8 +435,10 @@ export class Mp4Container extends Container {
|
|
|
417
435
|
}
|
|
418
436
|
/** @type {import("./Container.js").ContainerMediaInfo} */
|
|
419
437
|
const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
|
|
420
|
-
this.mediaInfo = info;
|
|
421
438
|
const held = await this.#moovBuffer();
|
|
439
|
+
// Kept only once the `moov` reading has an outcome: a read that had not
|
|
440
|
+
// arrived used to leave an empty answer here for the life of the container.
|
|
441
|
+
this.mediaInfo = info;
|
|
422
442
|
if (!held) {
|
|
423
443
|
return info;
|
|
424
444
|
}
|
|
@@ -620,7 +640,8 @@ export class Mp4Container extends Container {
|
|
|
620
640
|
}
|
|
621
641
|
|
|
622
642
|
async parseKeyframeIndex() {
|
|
623
|
-
const
|
|
643
|
+
const held = await this.#moovBuffer();
|
|
644
|
+
const r = held ? keyframeTimesFromMoov(held.moov, held.header) : null;
|
|
624
645
|
if (!r) return null;
|
|
625
646
|
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|
|
626
647
|
return r;
|
|
@@ -788,16 +809,10 @@ function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
|
788
809
|
/**
|
|
789
810
|
* The text subtitle tracks of an MP4, with every cue's time and byte range.
|
|
790
811
|
*
|
|
791
|
-
* @param {
|
|
792
|
-
* @
|
|
793
|
-
* @returns {Promise<{ tracks: Mp4SubtitleTrack[] } | null>}
|
|
812
|
+
* @param {Buffer} moov - The whole `moov` box, its header included.
|
|
813
|
+
* @returns {{ tracks: Mp4SubtitleTrack[] } | null}
|
|
794
814
|
*/
|
|
795
|
-
|
|
796
|
-
const found = await findMoov(readRange, fileSize);
|
|
797
|
-
if (!found || found.size > MAX_MOOV_BYTES) {
|
|
798
|
-
return null;
|
|
799
|
-
}
|
|
800
|
-
const moov = await readRange(found.offset, Math.min(fileSize - 1, found.offset + found.size - 1));
|
|
815
|
+
function subtitlePlanFromMoov(moov) {
|
|
801
816
|
if (!moov || moov.length < HEADER_BYTES) {
|
|
802
817
|
return null;
|
|
803
818
|
}
|
|
@@ -1254,20 +1269,15 @@ function isVideoTrack(buffer, mdia) {
|
|
|
1254
1269
|
}
|
|
1255
1270
|
|
|
1256
1271
|
/**
|
|
1257
|
-
* Read the keyframe times of an MP4/MOV file.
|
|
1272
|
+
* Read the keyframe times of an MP4/MOV file from its `moov` box.
|
|
1258
1273
|
*
|
|
1259
|
-
* @param {
|
|
1260
|
-
* @param {number}
|
|
1261
|
-
* @returns {
|
|
1274
|
+
* @param {Buffer} moov - The whole `moov` box, its header included.
|
|
1275
|
+
* @param {number} headerBytes - The box header's length.
|
|
1276
|
+
* @returns {number[] | null} Ascending seconds, or null when the file
|
|
1262
1277
|
* carries no usable index (fragmented MP4, truncated or damaged `moov`).
|
|
1263
1278
|
*/
|
|
1264
|
-
|
|
1265
|
-
const moovBox =
|
|
1266
|
-
if (!moovBox || moovBox.size > MAX_MOOV_BYTES) {
|
|
1267
|
-
return null;
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
const moov = await readRange(moovBox.offset, Math.min(fileSize - 1, moovBox.offset + moovBox.size - 1));
|
|
1279
|
+
function keyframeTimesFromMoov(moov, headerBytes) {
|
|
1280
|
+
const moovBox = { headerBytes };
|
|
1271
1281
|
if (!moov || moov.length < moovBox.headerBytes) {
|
|
1272
1282
|
return null;
|
|
1273
1283
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file EBML elements read where they lie, a portion at a time.
|
|
3
|
+
*
|
|
4
|
+
* `ebml-reader.js` parses a buffer the caller already holds. That is enough for
|
|
5
|
+
* a head window, and it is not enough for the elements that matter here: a Cues
|
|
6
|
+
* table whose size is the file's business, a Tracks element pushed out of the
|
|
7
|
+
* head by a large CodecPrivate, a cluster of a high-bitrate film. Reading any
|
|
8
|
+
* of them "in one go" needs a bound, and a bound is a statement about this
|
|
9
|
+
* program dressed up as a statement about the file — an element over it was
|
|
10
|
+
* reported as absent.
|
|
11
|
+
*
|
|
12
|
+
* This reads element HEADERS one at a time and element DATA only when asked,
|
|
13
|
+
* through a window no larger than one portion. The portion is handed in by
|
|
14
|
+
* whoever knows the medium (a torrent's piece length) and is never chosen here.
|
|
15
|
+
* Skipping an element costs one header read; an element whose data is wanted
|
|
16
|
+
* and is larger than a portion is refused by name, with its size.
|
|
17
|
+
*
|
|
18
|
+
* Grammar only, like `ebml-reader.js`: nothing here knows Matroska's ids.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readVint } from "./ebml-reader.js";
|
|
22
|
+
|
|
23
|
+
/** An element id is at most four bytes and a size at most eight (RFC 8794 §4). */
|
|
24
|
+
export const MAX_HEADER_BYTES = 12;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a size field says "unknown" — every value bit set (RFC 8794 §6.2).
|
|
28
|
+
*
|
|
29
|
+
* @param {{ value: number, length: number }} size
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function sizeIsUnknown(size) {
|
|
33
|
+
return size.value === 2 ** (7 * size.length) - 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} ElementHeader
|
|
38
|
+
* @property {number} id
|
|
39
|
+
* @property {number} at - Where the element begins, its id included.
|
|
40
|
+
* @property {number} dataOffset - Where its data begins.
|
|
41
|
+
* @property {number | null} size - Null when the size is unknown.
|
|
42
|
+
* @property {number | null} end - One past the last data byte, null when unknown.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export class ElementReader {
|
|
46
|
+
/** @type {(start: number, end: number) => Promise<Buffer>} */
|
|
47
|
+
#read;
|
|
48
|
+
|
|
49
|
+
/** @type {number} */
|
|
50
|
+
#fileSize;
|
|
51
|
+
|
|
52
|
+
/** @type {number} */
|
|
53
|
+
#portion;
|
|
54
|
+
|
|
55
|
+
/** @type {boolean} */
|
|
56
|
+
#prefetch;
|
|
57
|
+
|
|
58
|
+
/** @type {(start: number) => number} */
|
|
59
|
+
#readableUntil;
|
|
60
|
+
|
|
61
|
+
/** @type {Buffer | null} */
|
|
62
|
+
#window = null;
|
|
63
|
+
|
|
64
|
+
/** @type {number} */
|
|
65
|
+
#windowAt = 0;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} params
|
|
69
|
+
* @param {(start: number, end: number) => Promise<Buffer>} params.read - Answers
|
|
70
|
+
* with every byte of the range or throws (`strictReader`).
|
|
71
|
+
* @param {number} params.fileSize
|
|
72
|
+
* @param {number} [params.portionBytes] - The largest read this makes. Absent,
|
|
73
|
+
* an element is read in one go, which is right for a file on a local disk.
|
|
74
|
+
* @param {boolean} [params.prefetch] - Read a whole portion on a miss. Right
|
|
75
|
+
* when every byte of a span will be looked at (a cluster being walked);
|
|
76
|
+
* wrong when only a few headers will (the elements of a Segment, where a
|
|
77
|
+
* fetching read of a portion per header would pull megabytes for nothing).
|
|
78
|
+
* @param {(start: number) => number} [params.readableUntil] - One past the
|
|
79
|
+
* last byte that can be read from `start` in one go. A read of what is
|
|
80
|
+
* downloaded must not run a prefetch past the end of the downloaded run,
|
|
81
|
+
* or a cluster that is whole would fail for the bytes after it.
|
|
82
|
+
*/
|
|
83
|
+
constructor({ read, fileSize, portionBytes = Number.POSITIVE_INFINITY, prefetch = false, readableUntil }) {
|
|
84
|
+
this.#read = read;
|
|
85
|
+
this.#fileSize = fileSize;
|
|
86
|
+
this.#readableUntil = typeof readableUntil === "function" ? readableUntil : () => fileSize;
|
|
87
|
+
this.#portion = Number.isFinite(portionBytes) && portionBytes > 0 ? portionBytes : Number.POSITIVE_INFINITY;
|
|
88
|
+
this.#prefetch = prefetch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @returns {number} */
|
|
92
|
+
get portionBytes() {
|
|
93
|
+
return this.#portion;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `length` bytes from `start`, or fewer where the file ends first.
|
|
98
|
+
*
|
|
99
|
+
* @param {number} start
|
|
100
|
+
* @param {number} length
|
|
101
|
+
* @returns {Promise<Buffer>}
|
|
102
|
+
*/
|
|
103
|
+
async bytes(start, length) {
|
|
104
|
+
const end = Math.min(this.#fileSize, start + length);
|
|
105
|
+
if (end <= start) {
|
|
106
|
+
return Buffer.alloc(0);
|
|
107
|
+
}
|
|
108
|
+
const window = this.#window;
|
|
109
|
+
if (window && start >= this.#windowAt && end <= this.#windowAt + window.length) {
|
|
110
|
+
return window.subarray(start - this.#windowAt, end - this.#windowAt);
|
|
111
|
+
}
|
|
112
|
+
const reach = Math.min(this.#fileSize, Math.max(end, this.#readableUntil(start)));
|
|
113
|
+
const span = this.#prefetch ? Math.max(end - start, Math.min(this.#portion, reach - start)) : end - start;
|
|
114
|
+
const got = await this.#read(start, start + span - 1);
|
|
115
|
+
this.#window = got;
|
|
116
|
+
this.#windowAt = start;
|
|
117
|
+
return got.subarray(0, end - start);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The element whose header begins at `at`.
|
|
122
|
+
*
|
|
123
|
+
* @param {number} at
|
|
124
|
+
* @param {number} limit - One past the last byte the element may occupy.
|
|
125
|
+
* @returns {Promise<ElementHeader | null>} Null when the bytes there are not
|
|
126
|
+
* an element header — a malformed vint, or no room for one.
|
|
127
|
+
*/
|
|
128
|
+
async header(at, limit) {
|
|
129
|
+
const room = Math.min(limit, this.#fileSize) - at;
|
|
130
|
+
if (room <= 1) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const bytes = await this.bytes(at, Math.min(MAX_HEADER_BYTES, room));
|
|
134
|
+
const id = readVint(bytes, 0, true);
|
|
135
|
+
if (!id) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const size = readVint(bytes, id.length, false);
|
|
139
|
+
if (!size) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const dataOffset = at + id.length + size.length;
|
|
143
|
+
if (sizeIsUnknown(size)) {
|
|
144
|
+
return { id: id.value, at, dataOffset, size: null, end: null };
|
|
145
|
+
}
|
|
146
|
+
return { id: id.value, at, dataOffset, size: size.value, end: dataOffset + size.value };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* An element's data, copied out of the window so the window can move on.
|
|
151
|
+
*
|
|
152
|
+
* @param {ElementHeader} element
|
|
153
|
+
* @returns {Promise<Buffer | null>} Null when the data is larger than one
|
|
154
|
+
* portion: refused, never assembled.
|
|
155
|
+
*/
|
|
156
|
+
async data(element) {
|
|
157
|
+
if (element.size === null || element.size > this.#portion) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return Buffer.from(await this.bytes(element.dataOffset, element.size));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The first `length` bytes of an element's data, without reading the rest.
|
|
165
|
+
*
|
|
166
|
+
* @param {ElementHeader} element
|
|
167
|
+
* @param {number} length
|
|
168
|
+
* @returns {Promise<Buffer>}
|
|
169
|
+
*/
|
|
170
|
+
async peek(element, length) {
|
|
171
|
+
const available = element.size === null ? length : Math.min(length, element.size);
|
|
172
|
+
return this.bytes(element.dataOffset, available);
|
|
173
|
+
}
|
|
174
|
+
}
|