@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
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @file Bytes that are not here yet, said as such.
3
+ *
4
+ * A container reads a file it does not hold: the bytes arrive off a swarm, in
5
+ * whatever order the swarm delivers them. So every read has three outcomes —
6
+ * the bytes, a statement the bytes make ("there is no Cues element"), and "the
7
+ * bytes are not here yet" — and only the first two are facts about the file.
8
+ *
9
+ * Until 2026-10-01 the third came back as `null`, the same value the readers
10
+ * return for "the element is absent". Every container then read a read that
11
+ * had not arrived as an element that does not exist, and everything that
12
+ * remembered a container's answer remembered that as a fact for the life of the
13
+ * process: an embedded subtitle track planned with no clusters while its Cues
14
+ * table was still downloading showed nothing for a whole session
15
+ * (`research/subtitles-never-appear-2026-10-01.md`). The same shape was in the
16
+ * container choice, the track table, the keyframe table, MP4's `moov` and AVI's
17
+ * `idx1`.
18
+ *
19
+ * So a read that cannot be answered now THROWS this, and the rule for anything
20
+ * that keeps an answer is one line: a value or a proven absence may be kept;
21
+ * this may not.
22
+ */
23
+
24
+ /** Thrown where the bytes a reading needs have not arrived. */
25
+ export class BytesUnavailable extends Error {
26
+ /**
27
+ * @param {number} start
28
+ * @param {number} end - Inclusive.
29
+ * @param {number} received - How many bytes the read did return.
30
+ */
31
+ constructor(start, end, received) {
32
+ super(`bytes ${start}-${end} of the file are not available yet (${received} of ${end - start + 1} received)`);
33
+ this.name = "BytesUnavailable";
34
+ this.start = start;
35
+ this.end = end;
36
+ this.received = received;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Whether an error says "not here yet" rather than "something is wrong".
42
+ *
43
+ * @param {unknown} error
44
+ * @returns {boolean}
45
+ */
46
+ export function isUnavailable(error) {
47
+ return error instanceof BytesUnavailable || /** @type {any} */ (error)?.name === "BytesUnavailable";
48
+ }
49
+
50
+ /**
51
+ * A reader that answers with every byte asked for, or throws.
52
+ *
53
+ * The range is clamped to the file first, so a reading that asks past the end
54
+ * of a short file — a head window over a file smaller than the window — is
55
+ * answered with what the file has, and that is complete. Anything shorter than
56
+ * the clamped range is a read that did not finish: a stream that ended early,
57
+ * a piece that is not downloaded, a store that could not serve it.
58
+ *
59
+ * @param {(start: number, end: number) => Promise<Buffer | null>} read
60
+ * @param {number} fileSize
61
+ * @returns {(start: number, end: number) => Promise<Buffer>}
62
+ */
63
+ export function strictReader(read, fileSize) {
64
+ return async (start, end) => {
65
+ const last = Math.min(end, fileSize - 1);
66
+ if (!(start >= 0) || last < start) {
67
+ return Buffer.alloc(0);
68
+ }
69
+ const bytes = await read(start, last);
70
+ const wanted = last - start + 1;
71
+ const received = bytes ? bytes.length : 0;
72
+ if (received < wanted) {
73
+ throw new BytesUnavailable(start, last, received);
74
+ }
75
+ return received === wanted ? bytes : bytes.subarray(0, wanted);
76
+ };
77
+ }
@@ -41,6 +41,8 @@ export class ContainerTrack {
41
41
  language,
42
42
  languageBcp47,
43
43
  declaresLanguage,
44
+ languageSource = "",
45
+ languageCode = "",
44
46
  name,
45
47
  isEnabled,
46
48
  isDefault,
@@ -55,6 +57,13 @@ export class ContainerTrack {
55
57
  this.language = language ?? "";
56
58
  this.languageBcp47 = languageBcp47 ?? "";
57
59
  this.declaresLanguage = declaresLanguage === true;
60
+ // Where `language` came from: "bcp47", "language", "default" (the format's
61
+ // own default, RFC 8794 §11.1.19), or "" where the container has no say.
62
+ this.languageSource = typeof languageSource === "string" ? languageSource : "";
63
+ // The three-letter form the file states, or its default — what ffmpeg's
64
+ // banner prints, and so what a list lined up against it has to carry even
65
+ // where an RFC 5646 tag is the track's language.
66
+ this.languageCode = typeof languageCode === "string" ? languageCode : "";
58
67
  this.name = name ?? "";
59
68
  this.isEnabled = isEnabled !== false;
60
69
  this.isDefault = isDefault === true;
@@ -164,7 +164,9 @@ export class TextSubtitleTrack extends SubtitleTrack {
164
164
  if (!text) {
165
165
  return;
166
166
  }
167
- result.push({ startSeconds: cue.startSeconds, endSeconds, text });
167
+ // The found-order number travels with the cue where it has one: it is
168
+ // how a browser names a cue it may be asked to take back.
169
+ result.push(Number.isInteger(cue.seq) ? { startSeconds: cue.startSeconds, endSeconds, text, seq: cue.seq } : { startSeconds: cue.startSeconds, endSeconds, text });
168
170
  });
169
171
  return result;
170
172
  }
@@ -181,6 +183,12 @@ export class TextSubtitleTrack extends SubtitleTrack {
181
183
  static cuesToVtt(cues, codecId) {
182
184
  const lines = ["WEBVTT", ""];
183
185
  for (const cue of TextSubtitleTrack.finalizeCues(cues, codecId)) {
186
+ // WebVTT's cue identifier (W3C WebVTT §4.1): the found-order number,
187
+ // where the cue has one, so a cue delivered in a document can be named
188
+ // later exactly as a pushed one is.
189
+ if (Number.isInteger(cue.seq)) {
190
+ lines.push(String(cue.seq));
191
+ }
184
192
  lines.push(`${TextSubtitleTrack.vttTime(cue.startSeconds)} --> ${TextSubtitleTrack.vttTime(cue.endSeconds)}`);
185
193
  lines.push(cue.text);
186
194
  lines.push("");
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { logger } from "../../utils/logger.js";
11
- import { isOutputName } from "../encode/output/index.js";
11
+ import { isOutputName, SEGMENT_CUT_TIME_DELTA_SECONDS } from "../encode/output/index.js";
12
12
  import { predictPlaybackReadiness, RateTrend, forecastRate as forecastRateOf } from "./playback-readiness.js";
13
13
 
14
14
  function isWarmupTimeoutError(error) {
@@ -569,7 +569,8 @@ export class ViewerRequests {
569
569
  const nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
570
570
  this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
571
571
  startSeconds: timeline.publishedStartOf(index),
572
- endSeconds: index < timeline.segmentCount - 1 ? timeline.publishedStartOf(index + 1) : undefined
572
+ endSeconds: index < timeline.segmentCount - 1 ?
573
+ timeline.publishedStartOf(index + 1) - SEGMENT_CUT_TIME_DELTA_SECONDS : undefined
573
574
  }));
574
575
  const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
575
576
  const ranges = nativeRanges[index];