@torrent-tv/proxy 2.88.5 → 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.
Files changed (50) hide show
  1. package/AGENTS.md +21 -12
  2. package/CHANGELOG.md +18 -0
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/docs/container-architecture.md +77 -22
  6. package/docs/playback-start-forecast.md +56 -56
  7. package/main2.cpuprofile +1 -0
  8. package/package.json +1 -1
  9. package/scripts/build-audio-samples.mjs +31 -31
  10. package/server.js +42 -27
  11. package/services/encode/EncodeRuns.js +3 -1
  12. package/services/encode/OutputOpening.js +5 -4
  13. package/services/encode/audio-calibration.js +67 -67
  14. package/services/encode/audio-work-rate.js +15 -15
  15. package/services/encode/output/index.js +4 -0
  16. package/services/encode/run-command.js +2 -1
  17. package/services/media/ContainerOrchestrator.js +99 -46
  18. package/services/media/KeyframeTables.js +61 -21
  19. package/services/media/SubtitleCues.js +255 -183
  20. package/services/media/container/AviContainer.js +5 -3
  21. package/services/media/container/Container.js +66 -43
  22. package/services/media/container/ContainerFactory.js +8 -26
  23. package/services/media/container/MatroskaContainer.js +719 -1182
  24. package/services/media/container/Mp4Container.js +83 -73
  25. package/services/media/container/ebml-stream.js +174 -0
  26. package/services/media/container/matroska-clusters.js +1165 -0
  27. package/services/media/container/unavailable.js +77 -0
  28. package/services/media/tracks/ContainerTrack.js +9 -0
  29. package/services/media/tracks/TextSubtitleTrack.js +9 -1
  30. package/services/server/ViewerRequests.js +3 -2
  31. package/services/server/playback-readiness.js +516 -516
  32. package/services/torrent/worker/client.js +6 -2
  33. package/services/torrent/worker/held-bytes.js +84 -8
  34. package/services/torrent/worker/worker.js +10 -2
  35. package/services/transport/data-channel-handler.js +7 -3
  36. package/services/viewer/PriorityMap.js +31 -0
  37. package/test/audio-calibration.test.js +23 -23
  38. package/test/coalesce.test.js +39 -0
  39. package/test/held-read-ownership.test.js +77 -0
  40. package/test/helpers/held-file.js +50 -2
  41. package/test/helpers/matroska-file.js +296 -0
  42. package/test/keyframe-table-budget.test.js +21 -0
  43. package/test/matroska-cluster-discovery.test.js +323 -0
  44. package/test/reads-not-yet-arrived.test.js +218 -0
  45. package/test/segment-store.test.js +14 -0
  46. package/test/subtitle-cue-walk.test.js +16 -1
  47. package/test/subtitle-plan-waits.test.js +125 -0
  48. package/test/subtitle-track-numbering.test.js +8 -3
  49. package/test/viewer-starts-on-map.test.js +27 -0
  50. 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 (!head || !isAvi(head)) return [];
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
- if (!head || !isAvi(head)) {
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
- * Two ways of reading, because two readers of one file want different things
31
- * and the file is opened once.
32
- *
33
- * `readRange` fetches what is missing: the head and the track table are
34
- * kilobytes, they are needed before anything can be offered, and the codec
35
- * probe has already pulled the head of every file that plays. `readHeld`
36
- * reads only what is already downloaded and asks the swarm for nothing, which
37
- * is what the cue walk needs — turning subtitles on must not pull bytes the
38
- * viewer is not waiting for. `isHeld` says whether a range can be read that
39
- * way at all, so the walk can leave a cluster for next time instead of
40
- * blocking on it.
41
- *
42
- * They are given per container rather than per call because a container is
43
- * cached per file and both readers want the same parsed head. Where the
44
- * caller supplies only `readRange`, the held reader is that one and every
45
- * range counts as held — which is the right answer for a local file, and for
46
- * a torrent it is the caller's job to say otherwise.
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 {(start:number,end:number)=>Promise<Buffer|null>} [params.readHeld]
57
- * @param {(start:number,end:number)=>boolean} [params.isHeld]
54
+ * @param {number} [params.portionBytes]
58
55
  */
59
- constructor({ readRange, fileSize, label = "", readHeld, isHeld }) {
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.readHeld = typeof readHeld === "function" ? readHeld : readRange;
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
- // Matroska applies `eng` when Language is absent, and ffmpeg reports
276
- // that default as if the file had stated it. Preserve only an explicit
277
- // container value; otherwise let cue-text detection decide later.
278
- language: track.declaresLanguage === true
279
- ? (track.languageBcp47 || track.language || "")
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: `walked` holds cluster
409
- * positions, `harvested` holds sample offsets per track. Each container adds
410
- * to the one it uses.
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 {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
415
- * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
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
- * `params` is passed through whole, so a caller with a torrent's two readers
20
- * gets a container that has both — see {@link Container}'s constructor.
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, readHeld?: Function, isHeld?: Function }} params
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 - 1, fileSize - 1));
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
  }