@torrent-tv/proxy 2.89.6 → 2.89.8

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 (92) hide show
  1. package/AGENTS.md +32 -12
  2. package/CHANGELOG.md +2340 -2330
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/biome.json +412 -412
  6. package/docs/browser-proxy-contract.md +155 -155
  7. package/docs/container-architecture.md +273 -273
  8. package/docs/encode-run-state.md +1 -1
  9. package/docs/playback-start-forecast.md +113 -98
  10. package/package.json +51 -50
  11. package/routes/api/sources/files/get.js +106 -106
  12. package/scripts/build-audio-samples.mjs +31 -31
  13. package/services/encode/EncodeRun.js +67 -15
  14. package/services/encode/EncodeRuns.js +19 -7
  15. package/services/encode/OutputOpening.js +784 -784
  16. package/services/encode/audio-calibration.js +67 -67
  17. package/services/encode/audio-work-rate.js +15 -15
  18. package/services/encode/encode-exit.js +2 -0
  19. package/services/encode/hwaccel.js +1894 -1894
  20. package/services/encode/output/index.js +24 -24
  21. package/services/encode/piece-completeness.js +60 -2
  22. package/services/encode/run-command.js +757 -755
  23. package/services/encode/segment-formats/index.js +1 -0
  24. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  25. package/services/media/ContainerOrchestrator.js +202 -202
  26. package/services/media/KeyframeTables.js +356 -356
  27. package/services/media/SubtitleCues.js +592 -592
  28. package/services/media/audio-inventory.js +299 -299
  29. package/services/media/container/AviContainer.js +268 -268
  30. package/services/media/container/Container.js +632 -632
  31. package/services/media/container/ContainerFactory.js +38 -38
  32. package/services/media/container/MatroskaContainer.js +1031 -1031
  33. package/services/media/container/Mp4Container.js +1354 -1354
  34. package/services/media/container/SubtitleFileContainer.js +339 -339
  35. package/services/media/container/ebml-stream.js +174 -174
  36. package/services/media/container/matroska-clusters.js +1165 -1165
  37. package/services/media/container/unavailable.js +77 -77
  38. package/services/media/tracks/ContainerTrack.js +84 -84
  39. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  40. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  41. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  42. package/services/torrent/Contents.js +382 -382
  43. package/services/torrent/episode-naming.js +182 -182
  44. package/services/torrent/torrent-pool.js +2688 -2688
  45. package/services/torrent/worker/client.js +836 -836
  46. package/services/torrent/worker/held-bytes.js +231 -231
  47. package/services/torrent/worker/worker.js +1137 -1137
  48. package/services/transport/data-channel-handler.js +1373 -1373
  49. package/services/viewer/PriorityMap.js +421 -421
  50. package/test/audio-calibration.test.js +23 -23
  51. package/test/auto-quality-step.test.js +762 -762
  52. package/test/coalesce.test.js +39 -39
  53. package/test/coverage-follows-the-disk.test.js +5 -0
  54. package/test/decode-cost.test.js +475 -475
  55. package/test/encode-run.test.js +58 -0
  56. package/test/episode-naming.test.js +131 -131
  57. package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
  58. package/test/fixtures/audio-frame-boundary.json +1 -1
  59. package/test/fixtures/refused-audio-20261002.json +10 -0
  60. package/test/fmp4-presentation-clock.test.js +4 -2
  61. package/test/format-keyed-outputs.test.js +78 -78
  62. package/test/health-metrics.test.js +11 -13
  63. package/test/held-read-ownership.test.js +77 -77
  64. package/test/helpers/encode-run.js +14 -6
  65. package/test/helpers/held-file.js +77 -77
  66. package/test/helpers/manager.js +62 -62
  67. package/test/helpers/matroska-file.js +296 -296
  68. package/test/keyframe-table-budget.test.js +123 -123
  69. package/test/matroska-cluster-discovery.test.js +323 -323
  70. package/test/one-output-one-session.test.js +273 -273
  71. package/test/output-speed.test.js +6 -1
  72. package/test/piece-completeness.test.js +37 -1
  73. package/test/quality-variants.test.js +1274 -1270
  74. package/test/rate-control-in-the-output.test.js +4 -3
  75. package/test/reads-not-yet-arrived.test.js +218 -218
  76. package/test/seek-frees-the-viewers-requests.test.js +1 -1
  77. package/test/segment-closed-on-both-branches.test.js +114 -114
  78. package/test/segment-judged-by-its-own-header.test.js +222 -222
  79. package/test/segment-serve-wiring.test.js +481 -481
  80. package/test/serving-existing-output.test.js +111 -111
  81. package/test/source-files-route.test.js +113 -113
  82. package/test/subtitle-cue-walk.test.js +385 -385
  83. package/test/subtitle-metadata.test.js +15 -15
  84. package/test/subtitle-plan-waits.test.js +125 -125
  85. package/test/subtitle-track-numbering.test.js +391 -391
  86. package/test/two-viewers-one-picture.test.js +508 -508
  87. package/test/viewer-requests-admission.test.js +92 -92
  88. package/test/viewer-starts-on-map.test.js +27 -27
  89. package/test/viewer-states.test.js +5 -2
  90. package/test/viewing-generations.test.js +390 -390
  91. package/test/whole-file-held-read.test.js +30 -30
  92. package/utils/coalesce.js +41 -41
@@ -68,6 +68,7 @@ import { mpegtsFormat } from "./mpegts.js";
68
68
  * data; whether a browser joins a gap is decided by the viewer component.
69
69
  *
70
70
  * @typedef {{ tracks: Array<{ id: number, kind?: string, timescale: bigint,
71
+ * firstSampleStart?: bigint, positionErrorTicks?: bigint,
71
72
  * ranges: Array<{ start: bigint, end: bigint, frame: bigint }> }> }} MediaCoverage
72
73
  */
73
74
 
@@ -154,6 +154,11 @@ export function readTrackEdits(initSegment) {
154
154
  }
155
155
  }
156
156
  edit.offset = roundedQuotient(emptyTicks * edit.timescale, movieTimescale) - mediaTime;
157
+ // movenc writes a positive empty edit with AV_ROUND_DOWN. Keep the
158
+ // uncertainty of that position separate from the served timestamps.
159
+ if (emptyTicks > 0n) {
160
+ edit.positionErrorTicks = (edit.timescale + movieTimescale - 1n) / movieTimescale;
161
+ }
157
162
  }
158
163
  });
159
164
  return edits;
@@ -329,7 +334,9 @@ export function readPresentationRanges(raw) {
329
334
  ranges.push({ start: sample.start, end, frame });
330
335
  }
331
336
  }
332
- tracks.push({ id, kind: kinds.get(id), timescale: edits.get(id).timescale, ranges });
337
+ const edit = edits.get(id);
338
+ tracks.push({ id, kind: kinds.get(id), timescale: edit.timescale, ranges,
339
+ firstSampleStart: samples[0].start, positionErrorTicks: edit.positionErrorTicks ?? 0n });
333
340
  }
334
341
  return { tracks };
335
342
  }
@@ -1,202 +1,202 @@
1
- /**
2
- * @file Container orchestrator — application layer over Container domain.
3
- *
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.
16
- */
17
-
18
- import { ContainerFactory } from "./container/ContainerFactory.js";
19
- import { isUnavailable } from "./container/unavailable.js";
20
- import { logger } from "../../utils/logger.js";
21
-
22
- /**
23
- * How much of a file's head its track table lives in.
24
- *
25
- * A fact of the FORMATS, not of the torrent: Matroska puts its Tracks element
26
- * in the head and MP4's `moov` is there or pointed to from there. Whoever
27
- * fetches bytes before asking is told this figure rather than choosing one.
28
- */
29
- export const CONTAINER_HEAD_BYTES = 256 * 1024;
30
-
31
- export class ContainerOrchestrator {
32
- constructor() {
33
- /** @type {Map<string, import("./container/Container.js").Container|null>} */
34
- this.cache = new Map();
35
- /** @type {Map<string, Promise<import("./container/Container.js").Container|null>>} */
36
- this.pending = new Map();
37
- /**
38
- * What each file's container declares, once it has said it.
39
- *
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.
43
- *
44
- * @type {Map<string, import("./tracks/index.js").ContainerTrack[]>}
45
- */
46
- this.tracks = new Map();
47
- }
48
-
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
- *
66
- * @param {object} params
67
- * @param {string} params.sourceKey
68
- * @param {number} params.fileIndex
69
- * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
70
- * @param {number} params.fileSize
71
- * @param {string} [params.label]
72
- * @param {number} [params.portionBytes]
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.
76
- */
77
- async containerFor({ sourceKey, fileIndex, readRange, fileSize, label = "", portionBytes }) {
78
- const key = `${sourceKey}:${fileIndex}`;
79
- if (this.cache.has(key)) return this.cache.get(key);
80
- if (this.pending.has(key)) return this.pending.get(key);
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
- });
97
- this.pending.set(key, p);
98
- return p;
99
- }
100
-
101
- /**
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.
122
- */
123
- async getTracks(params) {
124
- const key = `${params.sourceKey}:${params.fileIndex}`;
125
- const known = this.tracks.get(key);
126
- if (known) {
127
- return known;
128
- }
129
- try {
130
- const container = await this.containerFor(params);
131
- if (!container) return [];
132
- const tracks = await container.readTracks();
133
- if (Array.isArray(tracks)) {
134
- this.tracks.set(key, tracks);
135
- return tracks;
136
- }
137
- return [];
138
- } catch (e) {
139
- if (!isUnavailable(e)) {
140
- logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
141
- }
142
- return [];
143
- }
144
- }
145
-
146
- /**
147
- * What the file declares about itself as a whole — format, duration, and
148
- * where its own timeline begins.
149
- *
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.
153
- *
154
- * @param {object} params - same as containerFor
155
- * @returns {Promise<import("./container/Container.js").ContainerMediaInfo|null>}
156
- */
157
- async getMediaInfo(params) {
158
- try {
159
- const container = await this.containerFor(params);
160
- if (!container) return null;
161
- return await container.readMediaInfo();
162
- } catch (e) {
163
- if (!isUnavailable(e)) {
164
- logger.warn(`container: readMediaInfo failed for "${params.label}": ${e?.message ?? e}`);
165
- }
166
- return null;
167
- }
168
- }
169
-
170
- /**
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.
177
- */
178
- async getKeyframeIndex(params) {
179
- const container = await this.containerFor(params);
180
- if (!container) return null;
181
- try {
182
- return await container.readKeyframeIndex();
183
- } catch (e) {
184
- if (isUnavailable(e)) throw e;
185
- return null;
186
- }
187
- }
188
-
189
- forget(sourceKey, fileIndex) {
190
- if (fileIndex === undefined) {
191
- for (const k of [...this.cache.keys()]) if (k.startsWith(`${sourceKey}:`)) this.cache.delete(k);
192
- for (const k of [...this.pending.keys()]) if (k.startsWith(`${sourceKey}:`)) this.pending.delete(k);
193
- for (const k of [...this.tracks.keys()]) if (k.startsWith(`${sourceKey}:`)) this.tracks.delete(k);
194
- return;
195
- }
196
- this.cache.delete(`${sourceKey}:${fileIndex}`);
197
- this.pending.delete(`${sourceKey}:${fileIndex}`);
198
- this.tracks.delete(`${sourceKey}:${fileIndex}`);
199
- }
200
- }
201
-
202
- export const containerOrchestrator = new ContainerOrchestrator();
1
+ /**
2
+ * @file Container orchestrator — application layer over Container domain.
3
+ *
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.
16
+ */
17
+
18
+ import { ContainerFactory } from "./container/ContainerFactory.js";
19
+ import { isUnavailable } from "./container/unavailable.js";
20
+ import { logger } from "../../utils/logger.js";
21
+
22
+ /**
23
+ * How much of a file's head its track table lives in.
24
+ *
25
+ * A fact of the FORMATS, not of the torrent: Matroska puts its Tracks element
26
+ * in the head and MP4's `moov` is there or pointed to from there. Whoever
27
+ * fetches bytes before asking is told this figure rather than choosing one.
28
+ */
29
+ export const CONTAINER_HEAD_BYTES = 256 * 1024;
30
+
31
+ export class ContainerOrchestrator {
32
+ constructor() {
33
+ /** @type {Map<string, import("./container/Container.js").Container|null>} */
34
+ this.cache = new Map();
35
+ /** @type {Map<string, Promise<import("./container/Container.js").Container|null>>} */
36
+ this.pending = new Map();
37
+ /**
38
+ * What each file's container declares, once it has said it.
39
+ *
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.
43
+ *
44
+ * @type {Map<string, import("./tracks/index.js").ContainerTrack[]>}
45
+ */
46
+ this.tracks = new Map();
47
+ }
48
+
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
+ *
66
+ * @param {object} params
67
+ * @param {string} params.sourceKey
68
+ * @param {number} params.fileIndex
69
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
70
+ * @param {number} params.fileSize
71
+ * @param {string} [params.label]
72
+ * @param {number} [params.portionBytes]
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.
76
+ */
77
+ async containerFor({ sourceKey, fileIndex, readRange, fileSize, label = "", portionBytes }) {
78
+ const key = `${sourceKey}:${fileIndex}`;
79
+ if (this.cache.has(key)) return this.cache.get(key);
80
+ if (this.pending.has(key)) return this.pending.get(key);
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
+ });
97
+ this.pending.set(key, p);
98
+ return p;
99
+ }
100
+
101
+ /**
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.
122
+ */
123
+ async getTracks(params) {
124
+ const key = `${params.sourceKey}:${params.fileIndex}`;
125
+ const known = this.tracks.get(key);
126
+ if (known) {
127
+ return known;
128
+ }
129
+ try {
130
+ const container = await this.containerFor(params);
131
+ if (!container) return [];
132
+ const tracks = await container.readTracks();
133
+ if (Array.isArray(tracks)) {
134
+ this.tracks.set(key, tracks);
135
+ return tracks;
136
+ }
137
+ return [];
138
+ } catch (e) {
139
+ if (!isUnavailable(e)) {
140
+ logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
141
+ }
142
+ return [];
143
+ }
144
+ }
145
+
146
+ /**
147
+ * What the file declares about itself as a whole — format, duration, and
148
+ * where its own timeline begins.
149
+ *
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.
153
+ *
154
+ * @param {object} params - same as containerFor
155
+ * @returns {Promise<import("./container/Container.js").ContainerMediaInfo|null>}
156
+ */
157
+ async getMediaInfo(params) {
158
+ try {
159
+ const container = await this.containerFor(params);
160
+ if (!container) return null;
161
+ return await container.readMediaInfo();
162
+ } catch (e) {
163
+ if (!isUnavailable(e)) {
164
+ logger.warn(`container: readMediaInfo failed for "${params.label}": ${e?.message ?? e}`);
165
+ }
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
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.
177
+ */
178
+ async getKeyframeIndex(params) {
179
+ const container = await this.containerFor(params);
180
+ if (!container) return null;
181
+ try {
182
+ return await container.readKeyframeIndex();
183
+ } catch (e) {
184
+ if (isUnavailable(e)) throw e;
185
+ return null;
186
+ }
187
+ }
188
+
189
+ forget(sourceKey, fileIndex) {
190
+ if (fileIndex === undefined) {
191
+ for (const k of [...this.cache.keys()]) if (k.startsWith(`${sourceKey}:`)) this.cache.delete(k);
192
+ for (const k of [...this.pending.keys()]) if (k.startsWith(`${sourceKey}:`)) this.pending.delete(k);
193
+ for (const k of [...this.tracks.keys()]) if (k.startsWith(`${sourceKey}:`)) this.tracks.delete(k);
194
+ return;
195
+ }
196
+ this.cache.delete(`${sourceKey}:${fileIndex}`);
197
+ this.pending.delete(`${sourceKey}:${fileIndex}`);
198
+ this.tracks.delete(`${sourceKey}:${fileIndex}`);
199
+ }
200
+ }
201
+
202
+ export const containerOrchestrator = new ContainerOrchestrator();