@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
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * @file Container orchestrator — application layer over Container domain.
3
3
  *
4
- * Holds a per-file cache of Container instances (key sourceKey:fileIndex) so
5
- * Tracks and keyframe index are read once per file, not per request.
6
- * Delegates format detection to ContainerFactory. Transport-agnostic — takes
7
- * readRange, knows nothing about torrents or HTTP.
4
+ * Holds ONE Container per file (key sourceKey:fileIndex), and every reader of a
5
+ * file's statements — the track table, the media info, the keyframe table, the
6
+ * subtitle plan and the cue walk — goes through that one. The subtitle walk used
7
+ * to build a second container over the same file with readers of its own, which
8
+ * read the head and the Cues table a second time under a different rule for a
9
+ * read that had not arrived (`research/subtitles-never-appear-2026-10-01.md`).
10
+ *
11
+ * What is kept, and what is not: a container is kept once the bytes have said
12
+ * what format they are (including "none this proxy knows"); a head that has not
13
+ * arrived throws `BytesUnavailable` and leaves nothing behind, so the next ask
14
+ * reads again. Delegates format detection to ContainerFactory. Transport-
15
+ * agnostic — takes readRange, knows nothing about torrents or HTTP.
8
16
  */
9
17
 
10
18
  import { ContainerFactory } from "./container/ContainerFactory.js";
19
+ import { isUnavailable } from "./container/unavailable.js";
20
+ import { logger } from "../../utils/logger.js";
11
21
 
12
22
  /**
13
23
  * How much of a file's head its track table lives in.
@@ -17,7 +27,6 @@ import { ContainerFactory } from "./container/ContainerFactory.js";
17
27
  * fetches bytes before asking is told this figure rather than choosing one.
18
28
  */
19
29
  export const CONTAINER_HEAD_BYTES = 256 * 1024;
20
- import { logger } from "../../utils/logger.js";
21
30
 
22
31
  export class ContainerOrchestrator {
23
32
  constructor() {
@@ -26,14 +35,11 @@ export class ContainerOrchestrator {
26
35
  /** @type {Map<string, Promise<import("./container/Container.js").Container|null>>} */
27
36
  this.pending = new Map();
28
37
  /**
29
- * What each file's container declares, once it has said anything.
38
+ * What each file's container declares, once it has said it.
30
39
  *
31
- * The container parses on every ask, so without this the header is read
32
- * again for each question — and the track table is asked for the audio
33
- * menu, the subtitle defaults and the video facts of one file. It used to
34
- * be kept in the torrent thread (`torrent-worker/container-tracks.js`), which is where the
35
- * parse used to happen; it is the same one cache in its new place, not a
36
- * second one.
40
+ * The track table is asked for the audio menu, the subtitle defaults and
41
+ * the video facts of one file; the container keeps its own reading, and
42
+ * this keeps the answer so the question costs nothing the second time.
37
43
  *
38
44
  * @type {Map<string, import("./tracks/index.js").ContainerTrack[]>}
39
45
  */
@@ -41,36 +47,78 @@ export class ContainerOrchestrator {
41
47
  }
42
48
 
43
49
  /**
50
+ * The container already built for a file, if any — without reading anything.
51
+ *
52
+ * `undefined` means none has been built yet; `null` means the bytes were read
53
+ * and are no format this proxy knows.
54
+ *
55
+ * @param {string} sourceKey
56
+ * @param {number} fileIndex
57
+ * @returns {import("./container/Container.js").Container | null | undefined}
58
+ */
59
+ known(sourceKey, fileIndex) {
60
+ return this.cache.get(`${sourceKey}:${fileIndex}`);
61
+ }
62
+
63
+ /**
64
+ * The file's one container, built on first ask.
65
+ *
44
66
  * @param {object} params
45
67
  * @param {string} params.sourceKey
46
68
  * @param {number} params.fileIndex
47
69
  * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
48
70
  * @param {number} params.fileSize
49
71
  * @param {string} [params.label]
72
+ * @param {number} [params.portionBytes]
50
73
  * @returns {Promise<import("./container/Container.js").Container|null>}
74
+ * @throws {import("./container/unavailable.js").BytesUnavailable} While the
75
+ * head has not arrived. Nothing is kept for it.
51
76
  */
52
- async getContainer({ sourceKey, fileIndex, readRange, fileSize, label = "" }) {
77
+ async containerFor({ sourceKey, fileIndex, readRange, fileSize, label = "", portionBytes }) {
53
78
  const key = `${sourceKey}:${fileIndex}`;
54
79
  if (this.cache.has(key)) return this.cache.get(key);
55
80
  if (this.pending.has(key)) return this.pending.get(key);
56
- const p = ContainerFactory.create({ readRange, fileSize, label }).then((c) => {
57
- this.cache.set(key, c);
58
- this.pending.delete(key);
59
- if (c) logger.info(`container: ${c.formatName} for "${label}"`);
60
- else logger.info(`container: unknown for "${label}"`);
61
- return c;
62
- }).catch((e) => {
63
- this.pending.delete(key);
64
- logger.warn(`container: failed for "${label}": ${e?.message ?? e}`);
65
- return null;
66
- });
81
+ const p = ContainerFactory.create({ readRange, fileSize, label, portionBytes })
82
+ .then((c) => {
83
+ this.cache.set(key, c);
84
+ if (c) logger.info(`container: ${c.formatName} for "${label}"`);
85
+ else logger.info(`container: unknown for "${label}"`);
86
+ return c;
87
+ })
88
+ .catch((e) => {
89
+ if (!isUnavailable(e)) {
90
+ logger.warn(`container: failed for "${label}": ${e?.message ?? e}`);
91
+ }
92
+ throw e;
93
+ })
94
+ .finally(() => {
95
+ this.pending.delete(key);
96
+ });
67
97
  this.pending.set(key, p);
68
98
  return p;
69
99
  }
70
100
 
71
101
  /**
72
- * @param {object} params - same as getContainer
73
- * @returns {Promise<import("./tracks/index.js").ContainerTrack[]>}
102
+ * The same, answering null rather than throwing while the head is not here —
103
+ * for the callers whose own answer to "not yet" is "nothing yet".
104
+ *
105
+ * @param {object} params - same as containerFor
106
+ * @returns {Promise<import("./container/Container.js").Container|null>}
107
+ */
108
+ async getContainer(params) {
109
+ try {
110
+ return await this.containerFor(params);
111
+ } catch {
112
+ // `containerFor` has already said what failed, where it was not a
113
+ // shortage of bytes; either way nothing was kept.
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @param {object} params - same as containerFor
120
+ * @returns {Promise<import("./tracks/index.js").ContainerTrack[]>} Empty while
121
+ * the bytes have not arrived; that empty answer is not kept.
74
122
  */
75
123
  async getTracks(params) {
76
124
  const key = `${params.sourceKey}:${params.fileIndex}`;
@@ -78,21 +126,19 @@ export class ContainerOrchestrator {
78
126
  if (known) {
79
127
  return known;
80
128
  }
81
- const container = await this.getContainer(params);
82
- if (!container) return [];
83
129
  try {
130
+ const container = await this.containerFor(params);
131
+ if (!container) return [];
84
132
  const tracks = await container.readTracks();
85
- // ONLY A NON-EMPTY READING IS KEPT. An empty one usually means the header
86
- // has not arrived off the swarm yet, and caching that would answer "this
87
- // file declares nothing" for the life of the process — which is what
88
- // decides whether the viewer is offered a soundtrack at all.
89
- if (Array.isArray(tracks) && tracks.length > 0) {
133
+ if (Array.isArray(tracks)) {
90
134
  this.tracks.set(key, tracks);
91
135
  return tracks;
92
136
  }
93
- return Array.isArray(tracks) ? tracks : [];
137
+ return [];
94
138
  } catch (e) {
95
- logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
139
+ if (!isUnavailable(e)) {
140
+ logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
141
+ }
96
142
  return [];
97
143
  }
98
144
  }
@@ -101,34 +147,41 @@ export class ContainerOrchestrator {
101
147
  * What the file declares about itself as a whole — format, duration, and
102
148
  * where its own timeline begins.
103
149
  *
104
- * Read once per file, like the track table beside it, and from the same 64 KB
105
- * of header. A `null` field means the container does not declare it, which is
106
- * a final answer about the container.
150
+ * A `null` field means the container does not declare it, which is a final
151
+ * answer about the container. A null RESULT means the bytes have not arrived,
152
+ * or the format is unknown; neither is kept here.
107
153
  *
108
- * @param {object} params - same as getContainer
154
+ * @param {object} params - same as containerFor
109
155
  * @returns {Promise<import("./container/Container.js").ContainerMediaInfo|null>}
110
156
  */
111
157
  async getMediaInfo(params) {
112
- const container = await this.getContainer(params);
113
- if (!container) return null;
114
158
  try {
159
+ const container = await this.containerFor(params);
160
+ if (!container) return null;
115
161
  return await container.readMediaInfo();
116
162
  } catch (e) {
117
- logger.warn(`container: readMediaInfo failed for "${params.label}": ${e?.message ?? e}`);
163
+ if (!isUnavailable(e)) {
164
+ logger.warn(`container: readMediaInfo failed for "${params.label}": ${e?.message ?? e}`);
165
+ }
118
166
  return null;
119
167
  }
120
168
  }
121
169
 
122
170
  /**
123
- * @param {object} params - same as getContainer
124
- * @returns {Promise<{times:number[],tolerance:number}|null>}
171
+ * @param {object} params - same as containerFor
172
+ * @returns {Promise<{times:number[],tolerance:number}|null>} Null where the
173
+ * file has no usable index, or is of no known format.
174
+ * @throws {import("./container/unavailable.js").BytesUnavailable} While the
175
+ * bytes the index needs have not arrived — so that the keyframe table does
176
+ * not record "no keyframes" for a file whose index is still downloading.
125
177
  */
126
178
  async getKeyframeIndex(params) {
127
- const container = await this.getContainer(params);
179
+ const container = await this.containerFor(params);
128
180
  if (!container) return null;
129
181
  try {
130
182
  return await container.readKeyframeIndex();
131
- } catch {
183
+ } catch (e) {
184
+ if (isUnavailable(e)) throw e;
132
185
  return null;
133
186
  }
134
187
  }
@@ -16,7 +16,12 @@
16
16
  * it. Cancelling it would make every later viewer pay the whole wait again;
17
17
  * 3. **a read that THREW is not an answer.** "The head is not downloaded" says
18
18
  * nothing about the file; turning it into "this file has no keyframes" makes
19
- * every picture of it a re-encode for as long as the process lives.
19
+ * every picture of it a re-encode for as long as the process lives. Bytes
20
+ * that have not arrived reach here as `BytesUnavailable` — until 2026-10-01
21
+ * they came back as a reading of no times, which `KeyframeTable.learn`
22
+ * records as answered, so point 3 held only for reads that crashed. When
23
+ * pieces of such a file arrive the read is made again
24
+ * (`readAgainIfUnanswered`), and its answer lands in the same table.
20
25
  *
21
26
  * **What it does NOT know.** Where the bytes come from. The reader is one
22
27
  * function handed in at construction — on this proxy it crosses to the torrent
@@ -27,20 +32,16 @@
27
32
  * transport inside a layer that must not have one, and a second answer to a
28
33
  * question with one owner.
29
34
  *
30
- * **Why this is not `ContainerOrchestrator`, which is the same shape and sits
31
- * in this directory: the THREAD, not the responsibility.** That one holds a
32
- * container per file and needs byte ranges to build it, so it can only live
33
- * where the torrent is — the worker. This lives on the main thread, where the
34
- * sessions are, and reaches the other across the channel. The table is
35
- * therefore held twice, once per thread, and the two are not equals: the
36
- * worker's is the parse, memoized on the container it parsed, and the main
37
- * thread's is the answer, which nothing in the worker ever reads back. Three
38
- * directories of the storage layer are kept apart for the same reason, and it
39
- * is stated here for the same reason too — an unexplained second home reads as
40
- * a second owner.
35
+ * **Why this is not `ContainerOrchestrator`.** Both live on the main thread
36
+ * (the parse moved there on 2026-09-15). That one holds the file's one
37
+ * container, which keeps its own reading of the Cues table; this keeps the
38
+ * ANSWER the sessions hold — the keyframe times taken from that reading, and
39
+ * the wait around it, which a container cannot have because it does not know
40
+ * there is a viewer.
41
41
  */
42
42
 
43
43
  import { KeyframeTable } from "./container/KeyframeTable.js";
44
+ import { isUnavailable } from "./container/unavailable.js";
44
45
  import { logger as defaultLogger } from "../../utils/logger.js";
45
46
 
46
47
  /**
@@ -187,10 +188,17 @@ export class KeyframeTables {
187
188
  return table;
188
189
  })
189
190
  .catch((error) => {
190
- this.#logger.warn(
191
- `keyframe index "${logName}": the read failed after ${Date.now() - startedMs}ms — ` +
192
- `${error?.message ?? error}`
193
- );
191
+ if (isUnavailable(error)) {
192
+ this.#logger.info(
193
+ `keyframe index "${logName}": not downloaded yet after ${Date.now() - startedMs}ms — ` +
194
+ "read again when pieces of the file arrive"
195
+ );
196
+ } else {
197
+ this.#logger.warn(
198
+ `keyframe index "${logName}": the read failed after ${Date.now() - startedMs}ms — ` +
199
+ `${error?.message ?? error}`
200
+ );
201
+ }
194
202
  throw error;
195
203
  })
196
204
  .finally(() => {
@@ -220,6 +228,25 @@ export class KeyframeTables {
220
228
  }
221
229
  }
222
230
 
231
+ /**
232
+ * Read again a table somebody asked for and nobody has answered.
233
+ *
234
+ * Called when pieces of the file arrive. A table nobody asked for is left
235
+ * alone — this is not a reason to read every file of a torrent — and one
236
+ * already read, or being read, is not touched.
237
+ *
238
+ * @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
239
+ * @returns {void}
240
+ */
241
+ readAgainIfUnanswered(params) {
242
+ const key = KeyframeTables.keyFor(params.sourceKey, params.fileIndex);
243
+ const table = this.#byFile.get(key);
244
+ if (!table || table.answered || this.#reading.has(key)) {
245
+ return;
246
+ }
247
+ void this.warm(params);
248
+ }
249
+
223
250
  /**
224
251
  * The same read, with a bound on how long THIS caller waits for it.
225
252
  *
@@ -232,7 +259,8 @@ export class KeyframeTables {
232
259
  * @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
233
260
  * @returns {Promise<{ table: KeyframeTable, arrived: boolean }>} `arrived` is
234
261
  * about THIS wait. False with an unanswered table means the read is still
235
- * running, which is not the same as a file with no keyframes.
262
+ * running, or found the bytes not downloaded yet and will be made again
263
+ * when they arrive — neither is the same as a file with no keyframes.
236
264
  */
237
265
  async within(params) {
238
266
  const read = this.read(params);
@@ -242,11 +270,23 @@ export class KeyframeTables {
242
270
  // A caller must not be held open by this timer alone.
243
271
  timer?.unref?.();
244
272
  });
245
- const answer = await Promise.race([read.then((table) => ({ table, arrived: true })), budget]);
273
+ // Bytes not downloaded yet are "not arrived" to this caller: the table
274
+ // stays unanswered and is read again when pieces of the file arrive.
275
+ // Thrown out of here, a shortage of bytes would fail the opening of an
276
+ // output. Any other failure is still the caller's to see.
277
+ const answer = await Promise.race([
278
+ read.then(
279
+ (table) => ({ table, arrived: true }),
280
+ (error) => {
281
+ if (isUnavailable(error)) {
282
+ return { table: this.of(params), arrived: false };
283
+ }
284
+ throw error;
285
+ }
286
+ ),
287
+ budget
288
+ ]);
246
289
  clearTimeout(timer);
247
- // Nothing is added here to swallow a late rejection: the race is holding a
248
- // handler on that promise already, and a second one would only look like it
249
- // was doing something.
250
290
  return answer ?? { table: this.of(params), arrived: false };
251
291
  }
252
292