@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
|
@@ -34,7 +34,7 @@ export class AviContainer extends Container {
|
|
|
34
34
|
|
|
35
35
|
async readTracks() {
|
|
36
36
|
const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
|
|
37
|
-
if (!
|
|
37
|
+
if (!isAvi(head)) return [];
|
|
38
38
|
// AVI track table is minimal — expose one video track for uniformity.
|
|
39
39
|
return [new VideoTrack({
|
|
40
40
|
trackNumber: 1,
|
|
@@ -65,9 +65,11 @@ export class AviContainer extends Container {
|
|
|
65
65
|
}
|
|
66
66
|
/** @type {import("./Container.js").ContainerMediaInfo} */
|
|
67
67
|
const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: 0 };
|
|
68
|
-
this.mediaInfo = info;
|
|
69
68
|
const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
|
|
70
|
-
|
|
69
|
+
// Kept only once the head has been read: one that had not arrived used to
|
|
70
|
+
// leave an empty answer here for the life of the container.
|
|
71
|
+
this.mediaInfo = info;
|
|
72
|
+
if (!isAvi(head)) {
|
|
71
73
|
return info;
|
|
72
74
|
}
|
|
73
75
|
// RIFF("AVI ") -> LIST("hdrl") -> avih. The avih chunk's payload begins with
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* - AVI RIFF §: LIST hdrl, idx1
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { strictReader } from "./unavailable.js";
|
|
16
|
+
|
|
15
17
|
/**
|
|
16
18
|
* What one file declares about itself. Every field is either a value the
|
|
17
19
|
* container states or `null`, which means the container does not state it —
|
|
@@ -27,41 +29,35 @@
|
|
|
27
29
|
|
|
28
30
|
export class Container {
|
|
29
31
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `readRange`
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* a torrent
|
|
47
|
-
*
|
|
48
|
-
* The torrent is NOT passed in and must not be: `readHeld` and `isHeld` are
|
|
49
|
-
* the only two facts about it this layer needs, and reducing it to two
|
|
50
|
-
* functions is what keeps the container ignorant of where its bytes live.
|
|
32
|
+
* One reader, the one that fetches what is missing, and one container per
|
|
33
|
+
* file — the one `ContainerOrchestrator` keeps.
|
|
34
|
+
*
|
|
35
|
+
* `readRange` is made strict here: it answers with every byte asked for or
|
|
36
|
+
* throws `BytesUnavailable`, so no reading in any subclass can mistake a read
|
|
37
|
+
* that has not arrived for an element that is not there. What a subclass keeps
|
|
38
|
+
* it keeps only after a reading succeeded (see `unavailable.js`).
|
|
39
|
+
*
|
|
40
|
+
* The cue walk reads only what is already downloaded and asks the swarm for
|
|
41
|
+
* nothing. That reader is NOT given here: it is handed to
|
|
42
|
+
* {@link Container#readHeldCues} on every pass, because what is downloaded
|
|
43
|
+
* changes between passes and the container is kept for the life of the file.
|
|
44
|
+
* It used to be given here, which is why the walk had to build a second
|
|
45
|
+
* container over the same file and read its head and Cues a second time.
|
|
46
|
+
*
|
|
47
|
+
* `portionBytes` is the largest read a reading makes, stated by whoever knows
|
|
48
|
+
* the medium — a torrent's piece length. Absent, an element is read in one go.
|
|
51
49
|
*
|
|
52
50
|
* @param {object} params
|
|
53
|
-
* @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
|
|
54
|
-
* @param {number} params.fileSize
|
|
51
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} [params.readRange]
|
|
52
|
+
* @param {number} [params.fileSize]
|
|
55
53
|
* @param {string} [params.label]
|
|
56
|
-
* @param {
|
|
57
|
-
* @param {(start:number,end:number)=>boolean} [params.isHeld]
|
|
54
|
+
* @param {number} [params.portionBytes]
|
|
58
55
|
*/
|
|
59
|
-
constructor({ readRange, fileSize, label = "",
|
|
60
|
-
this.readRange = readRange;
|
|
56
|
+
constructor({ readRange, fileSize, label = "", portionBytes } = {}) {
|
|
61
57
|
this.fileSize = fileSize;
|
|
58
|
+
this.readRange = typeof readRange === "function" ? strictReader(readRange, fileSize) : readRange;
|
|
62
59
|
this.label = label;
|
|
63
|
-
this.
|
|
64
|
-
this.isHeld = typeof isHeld === "function" ? isHeld : () => true;
|
|
60
|
+
this.portionBytes = Number.isFinite(portionBytes) && portionBytes > 0 ? portionBytes : Number.POSITIVE_INFINITY;
|
|
65
61
|
}
|
|
66
62
|
|
|
67
63
|
|
|
@@ -272,13 +268,17 @@ export class Container {
|
|
|
272
268
|
declared,
|
|
273
269
|
"subtitle",
|
|
274
270
|
(track) => ({
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
271
|
+
// The language the container states, its default included. RFC 9559
|
|
272
|
+
// §5.1.4.1.19 gives `Language` the default `eng`, and RFC 8794
|
|
273
|
+
// §11.1.19 says an element equal to its default need not be written
|
|
274
|
+
// and that a reader MUST then read the default — so a track with no
|
|
275
|
+
// Language element is English by the file's own statement. Until
|
|
276
|
+
// 2026-10-01 that was treated as "says nothing", and an English track
|
|
277
|
+
// was offered as Unknown. Where the language came from travels beside
|
|
278
|
+
// it, so a reading of the text that disagrees can be reported.
|
|
279
|
+
language: track.languageBcp47 || track.language || "",
|
|
281
280
|
declaresLanguage: track.declaresLanguage === true,
|
|
281
|
+
languageSource: typeof track.languageSource === "string" ? track.languageSource : "",
|
|
282
282
|
isDefault: track.isDefault === true,
|
|
283
283
|
declaresDefault: track.declaresDefault === true,
|
|
284
284
|
// Read from the file rather than guessed from the track's name. Both
|
|
@@ -405,19 +405,27 @@ export class Container {
|
|
|
405
405
|
* different evidence are a disagreement waiting to happen.
|
|
406
406
|
*
|
|
407
407
|
* `progress` is what has already been read, kept by the caller because it
|
|
408
|
-
* belongs to the file rather than to one pass
|
|
409
|
-
*
|
|
410
|
-
*
|
|
408
|
+
* belongs to the file rather than to one pass. Each container keeps its own
|
|
409
|
+
* part of it under its own name; nothing else reads inside.
|
|
410
|
+
*
|
|
411
|
+
* `held` is how to read what is downloaded NOW, handed in per pass: which
|
|
412
|
+
* ranges are whole, a read of one that never fetches (strict: every byte or
|
|
413
|
+
* `BytesUnavailable`), the portion a read may span, and where the viewers
|
|
414
|
+
* stand, in seconds, so the work nearest them is taken first.
|
|
411
415
|
*
|
|
412
416
|
* @param {object} _plan - This file's subtitle plan.
|
|
413
417
|
* @param {object} _track - The track asked about.
|
|
414
|
-
* @param {
|
|
415
|
-
* @
|
|
418
|
+
* @param {object} _progress
|
|
419
|
+
* @param {HeldReader} _held
|
|
420
|
+
* @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number, withdrawn: number[] }>}
|
|
416
421
|
* `found` is track number to the cues found in THIS pass — Matroska fills
|
|
417
|
-
* every track from one walk, so it is a map and not a list.
|
|
422
|
+
* every track from one walk, so it is a map and not a list. Each cue may
|
|
423
|
+
* carry `source`, the position it was read from; `withdrawn` lists sources
|
|
424
|
+
* found in an earlier pass that turned out not to be what they were taken
|
|
425
|
+
* for, whose cues the caller must take back.
|
|
418
426
|
*/
|
|
419
|
-
async readHeldCues(_plan, _track, _progress) {
|
|
420
|
-
return { found: new Map(), covered: 0, indexed: 0 };
|
|
427
|
+
async readHeldCues(_plan, _track, _progress, _held) {
|
|
428
|
+
return { found: new Map(), covered: 0, indexed: 0, withdrawn: [] };
|
|
421
429
|
}
|
|
422
430
|
|
|
423
431
|
/**
|
|
@@ -524,6 +532,21 @@ export class Container {
|
|
|
524
532
|
}
|
|
525
533
|
}
|
|
526
534
|
|
|
535
|
+
/**
|
|
536
|
+
* What the cue walk may read on one pass.
|
|
537
|
+
*
|
|
538
|
+
* @typedef {object} HeldReader
|
|
539
|
+
* @property {(start: number, end: number) => boolean} isHeld - Whether the whole
|
|
540
|
+
* inclusive range is downloaded.
|
|
541
|
+
* @property {Array<[number, number]>} ranges - The downloaded ranges, ascending.
|
|
542
|
+
* @property {(start: number, end: number) => Promise<Buffer>} read - Every byte
|
|
543
|
+
* of a downloaded range, or `BytesUnavailable`; never fetches.
|
|
544
|
+
* @property {number} portionBytes - The largest read one step may make.
|
|
545
|
+
* @property {(() => number[]) | number[]} wantedSeconds - Where viewers stand,
|
|
546
|
+
* in film seconds — a function, read again between clusters, so a pass can
|
|
547
|
+
* stop when a viewer has moved.
|
|
548
|
+
*/
|
|
549
|
+
|
|
527
550
|
// ---------------------------------------------------------------------------
|
|
528
551
|
// Lining ffmpeg's banner up with what a container declares. Here because the
|
|
529
552
|
// correction is about what a CONTAINER states and the banner cannot.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { MatroskaContainer } from "./MatroskaContainer.js";
|
|
10
10
|
import { Mp4Container } from "./Mp4Container.js";
|
|
11
11
|
import { AviContainer } from "./AviContainer.js";
|
|
12
|
+
import { strictReader } from "./unavailable.js";
|
|
12
13
|
|
|
13
14
|
const SNIFF_BYTES = 16;
|
|
14
15
|
|
|
@@ -16,41 +17,22 @@ export class ContainerFactory {
|
|
|
16
17
|
/**
|
|
17
18
|
* The container these bytes are, built over them.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* Null means the bytes were read and are none of the formats this knows — a
|
|
21
|
+
* statement about the file. Bytes that have not arrived throw
|
|
22
|
+
* `BytesUnavailable`, which is not one: until 2026-10-01 they came back as
|
|
23
|
+
* null too, and a file whose head was still downloading was remembered as
|
|
24
|
+
* being of no known format.
|
|
21
25
|
*
|
|
22
|
-
* @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string,
|
|
26
|
+
* @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string, portionBytes?: number }} params
|
|
23
27
|
* @returns {Promise<import("./Container.js").Container|null>}
|
|
24
28
|
*/
|
|
25
29
|
static async create(params) {
|
|
26
30
|
const { readRange, fileSize } = params;
|
|
27
31
|
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) return null;
|
|
28
|
-
const head = await readRange(0, Math.min(SNIFF_BYTES
|
|
29
|
-
if (!head) return null;
|
|
32
|
+
const head = await strictReader(readRange, fileSize)(0, Math.min(SNIFF_BYTES, fileSize) - 1);
|
|
30
33
|
if (MatroskaContainer.detect(head)) return new MatroskaContainer(params);
|
|
31
34
|
if (Mp4Container.detect(head)) return new Mp4Container(params);
|
|
32
35
|
if (AviContainer.detect(head)) return new AviContainer(params);
|
|
33
36
|
return null;
|
|
34
37
|
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The container a file NAME suggests, for the moment the bytes cannot be
|
|
38
|
-
* sniffed.
|
|
39
|
-
*
|
|
40
|
-
* The head of a file nobody has opened is not downloaded, and the cue walk
|
|
41
|
-
* asks the swarm for nothing — so on that one path the name is all there is.
|
|
42
|
-
* It is a fallback and never a preference: the bytes decide wherever they can
|
|
43
|
-
* be read, because a name is what somebody typed and a header is what the
|
|
44
|
-
* muxer wrote.
|
|
45
|
-
*
|
|
46
|
-
* @param {string} name
|
|
47
|
-
* @returns {typeof MatroskaContainer | typeof Mp4Container | null}
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
static byName(name) {
|
|
51
|
-
const text = String(name ?? "");
|
|
52
|
-
if (/\.(mp4|m4v|m4a)$/i.test(text)) return Mp4Container;
|
|
53
|
-
if (/\.(mkv|mka|webm)$/i.test(text)) return MatroskaContainer;
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
38
|
}
|