@torrent-tv/proxy 2.74.1 → 2.76.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 (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -1,358 +0,0 @@
1
- /**
2
- * @file Keyframe index for MP4/MOV, read without downloading the file.
3
- *
4
- * MP4 keeps its tables in a `moov` box: `stss` lists which samples are sync
5
- * samples (keyframes) by number, and `stts` gives each sample's duration, so
6
- * the two together turn "sample #N" into "second T". `moov` sits either at the
7
- * start (files written for streaming) or at the end (the common case for a
8
- * plain mux); box headers state their own size, so it is found by stepping over
9
- * top-level boxes rather than scanning bytes — a couple of 64-byte reads even
10
- * when `mdat` is gigabytes.
11
- *
12
- * Same purpose as the Matroska reader: on the video-COPY path the segment
13
- * boundaries ARE the source's keyframes, and inventing an even grid instead
14
- * makes players walk the whole file or present audio with no picture.
15
- */
16
-
17
- // A 64-bit box size is signalled by a 32-bit size of 1, the real size following
18
- // in the next 8 bytes.
19
- const LARGE_SIZE_MARKER = 1;
20
- const HEADER_BYTES = 8;
21
- const LARGE_HEADER_BYTES = 16;
22
- // Enough to read any box header while walking the top level.
23
- const PROBE_BYTES = 64;
24
- // Cap on the moov read. A feature-length file indexes to a few hundred KB;
25
- // beyond this is not a normal index and not worth pulling over a torrent.
26
- const MAX_MOOV_BYTES = 32 * 1024 * 1024;
27
-
28
- /**
29
- * Whether this looks like MP4/MOV — every real file opens with an `ftyp` box.
30
- *
31
- * @param {Buffer} head
32
- * @returns {boolean}
33
- */
34
- export function isMp4(head) {
35
- return head.length >= 12 && head.toString("latin1", 4, 8) === "ftyp";
36
- }
37
-
38
- /**
39
- * Read a box header at `offset`.
40
- *
41
- * @param {Buffer} buffer
42
- * @param {number} offset
43
- * @returns {{ type: string, size: number, headerBytes: number } | null}
44
- */
45
- function readBoxHeader(buffer, offset) {
46
- if (offset + HEADER_BYTES > buffer.length) {
47
- return null;
48
- }
49
- const size32 = buffer.readUInt32BE(offset);
50
- const type = buffer.toString("latin1", offset + 4, offset + 8);
51
- if (size32 === LARGE_SIZE_MARKER) {
52
- if (offset + LARGE_HEADER_BYTES > buffer.length) {
53
- return null;
54
- }
55
- // High word is zero for any file we can practically handle.
56
- const high = buffer.readUInt32BE(offset + 8);
57
- const low = buffer.readUInt32BE(offset + 12);
58
- return { type, size: high * 4294967296 + low, headerBytes: LARGE_HEADER_BYTES };
59
- }
60
- return { type, size: size32, headerBytes: HEADER_BYTES };
61
- }
62
-
63
- /**
64
- * Walk the top-level boxes to find `moov`, reading only each box header.
65
- *
66
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
67
- * @param {number} fileSize
68
- * @returns {Promise<{ offset: number, size: number, headerBytes: number } | null>}
69
- */
70
- async function findMoov(readRange, fileSize) {
71
- let offset = 0;
72
- while (offset < fileSize) {
73
- const probe = await readRange(offset, Math.min(fileSize - 1, offset + PROBE_BYTES - 1));
74
- if (!probe || probe.length < HEADER_BYTES) {
75
- return null;
76
- }
77
- const header = readBoxHeader(probe, 0);
78
- // Size 0 means "extends to end of file" — legal only for the last box, and
79
- // never for one we would step over.
80
- if (!header || header.size <= 0) {
81
- return null;
82
- }
83
- if (header.type === "moov") {
84
- return { offset, size: header.size, headerBytes: header.headerBytes };
85
- }
86
- offset += header.size;
87
- }
88
- return null;
89
- }
90
-
91
- /**
92
- * Find the first box of `type` directly inside a range of an already-read buffer.
93
- *
94
- * @param {Buffer} buffer
95
- * @param {number} start
96
- * @param {number} end
97
- * @param {string} type
98
- * @returns {{ dataOffset: number, end: number } | null}
99
- */
100
- function findBox(buffer, start, end, type) {
101
- let offset = start;
102
- while (offset + HEADER_BYTES <= end) {
103
- const header = readBoxHeader(buffer, offset);
104
- if (!header || header.size <= 0) {
105
- return null;
106
- }
107
- if (header.type === type) {
108
- return { dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) };
109
- }
110
- offset += header.size;
111
- }
112
- return null;
113
- }
114
-
115
- /**
116
- * All boxes of `type` directly inside a range.
117
- *
118
- * @param {Buffer} buffer
119
- * @param {number} start
120
- * @param {number} end
121
- * @param {string} type
122
- * @returns {{ dataOffset: number, end: number }[]}
123
- */
124
- function findAllBoxes(buffer, start, end, type) {
125
- const found = [];
126
- let offset = start;
127
- while (offset + HEADER_BYTES <= end) {
128
- const header = readBoxHeader(buffer, offset);
129
- if (!header || header.size <= 0) {
130
- break;
131
- }
132
- if (header.type === type) {
133
- found.push({ dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) });
134
- }
135
- offset += header.size;
136
- }
137
- return found;
138
- }
139
-
140
- /**
141
- * Turn sample numbers into seconds using the time-to-sample table.
142
- *
143
- * `stts` is run-length encoded — pairs of (sample count, per-sample duration) —
144
- * so one walk yields every sample's start time without expanding the table.
145
- *
146
- * @param {Buffer} buffer
147
- * @param {{ dataOffset: number, end: number }} stts
148
- * @param {number} timescale - Ticks per second.
149
- * @param {Set<number>} wanted - Sample numbers (1-based).
150
- * @returns {number[]} Seconds, ascending.
151
- */
152
- function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
153
- const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
154
- const times = [];
155
- let sampleNumber = 1;
156
- let ticks = 0;
157
- let cursor = stts.dataOffset + 8;
158
- for (let entry = 0; entry < entryCount && cursor + 8 <= stts.end; entry += 1) {
159
- const count = buffer.readUInt32BE(cursor);
160
- const delta = buffer.readUInt32BE(cursor + 4);
161
- for (let index = 0; index < count; index += 1) {
162
- if (wanted.has(sampleNumber)) {
163
- // `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
164
- // what turns decode order into the order frames are shown in, and it is
165
- // the timeline ffmpeg cuts on.
166
- times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
167
- }
168
- ticks += delta;
169
- sampleNumber += 1;
170
- }
171
- cursor += 8;
172
- }
173
- return times;
174
- }
175
-
176
- /**
177
- * Composition offsets for the sample numbers asked for.
178
- *
179
- * `ctts` is run-length encoded like `stts`, and version 1 carries SIGNED
180
- * offsets — which is what the version exists for: a frame may be shown before
181
- * it is decoded. Reading them as unsigned turns a small negative offset into
182
- * roughly four billion ticks.
183
- *
184
- * @param {Buffer} buffer
185
- * @param {{ dataOffset: number, end: number }} ctts
186
- * @param {Set<number>} wanted - Sample numbers (1-based).
187
- * @returns {Map<number, number>} Sample number to offset in media ticks.
188
- */
189
- function readCompositionOffsets(buffer, ctts, wanted) {
190
- const version = buffer[ctts.dataOffset];
191
- const entryCount = buffer.readUInt32BE(ctts.dataOffset + 4);
192
- const offsets = new Map();
193
- let sampleNumber = 1;
194
- let cursor = ctts.dataOffset + 8;
195
- for (let entry = 0; entry < entryCount && cursor + 8 <= ctts.end; entry += 1) {
196
- const count = buffer.readUInt32BE(cursor);
197
- const offset = version === 1 ? buffer.readInt32BE(cursor + 4) : buffer.readUInt32BE(cursor + 4);
198
- for (let index = 0; index < count; index += 1) {
199
- if (wanted.has(sampleNumber)) {
200
- offsets.set(sampleNumber, offset);
201
- }
202
- sampleNumber += 1;
203
- }
204
- cursor += 8;
205
- }
206
- return offsets;
207
- }
208
-
209
- /**
210
- * How far the edit list shifts this track's composition timeline, in media
211
- * ticks.
212
- *
213
- * ISO/IEC 14496-12 §8.6.6.3: `media_time` is the start of the edit within the
214
- * media, in the MEDIA timescale and in composition time, while
215
- * `segment_duration` is in the MOVIE timescale — two different units in one
216
- * structure, which is why only the first is read here. `media_time = -1` is an
217
- * empty edit: it inserts blank presentation time and starts no media, so the
218
- * first real edit is the one that matters.
219
- *
220
- * Measured 2026-08-19: every LostFilm MP4 that carries a composition offset
221
- * also carries an edit list cancelling it exactly, which is why decode times
222
- * have been right on those files. `Firefly.S01E03` has the offset and NO edit
223
- * list, and its times were 62.1 ms early on all 34 keyframes checked.
224
- *
225
- * @param {Buffer} buffer
226
- * @param {{ dataOffset: number, end: number }} elst
227
- * @returns {number} Ticks to subtract; zero when nothing is shifted.
228
- */
229
- function readEditShift(buffer, elst) {
230
- const version = buffer[elst.dataOffset];
231
- const entryCount = buffer.readUInt32BE(elst.dataOffset + 4);
232
- const wide = version === 1;
233
- const entryBytes = wide ? 20 : 12;
234
- let cursor = elst.dataOffset + 8;
235
- for (let entry = 0; entry < entryCount && cursor + entryBytes <= elst.end; entry += 1) {
236
- const mediaTime = wide
237
- ? Number(buffer.readBigInt64BE(cursor + 8))
238
- : buffer.readInt32BE(cursor + 4);
239
- if (mediaTime >= 0) {
240
- return mediaTime;
241
- }
242
- cursor += entryBytes;
243
- }
244
- return 0;
245
- }
246
-
247
- /**
248
- * Whether this track's handler says it carries video.
249
- *
250
- * The standard identifies a track by its `hdlr`, and nothing else does. Picking
251
- * "the first track that happens to carry sync samples" worked only because the
252
- * seven releases measured all put video first; a file whose audio track carries
253
- * them, or one that leads with a cover-art video track, would be read from the
254
- * wrong place. That is the same defect that was fixed in the Matroska reader on
255
- * 2026-08-18, arrived at from the other side.
256
- *
257
- * @param {Buffer} buffer
258
- * @param {{ dataOffset: number, end: number }} mdia
259
- * @returns {boolean}
260
- */
261
- function isVideoTrack(buffer, mdia) {
262
- const hdlr = findBox(buffer, mdia.dataOffset, mdia.end, "hdlr");
263
- if (!hdlr || hdlr.dataOffset + 12 > hdlr.end) {
264
- return false;
265
- }
266
- // FullBox header (4) then a reserved pre_defined (4), then the handler type.
267
- return buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) === "vide";
268
- }
269
-
270
- /**
271
- * Read the keyframe times of an MP4/MOV file.
272
- *
273
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
274
- * @param {number} fileSize
275
- * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
276
- * carries no usable index (fragmented MP4, truncated or damaged `moov`).
277
- */
278
- export async function readMp4KeyframeTimes(readRange, fileSize) {
279
- const moovBox = await findMoov(readRange, fileSize);
280
- if (!moovBox || moovBox.size > MAX_MOOV_BYTES) {
281
- return null;
282
- }
283
-
284
- const moov = await readRange(moovBox.offset, Math.min(fileSize - 1, moovBox.offset + moovBox.size - 1));
285
- if (!moov || moov.length < moovBox.headerBytes) {
286
- return null;
287
- }
288
-
289
- // Examine every track, and take the one whose HANDLER says it is video. A
290
- // track with no `stss` has every sample a keyframe, so it constrains nothing
291
- // and is skipped even when it is the video one.
292
- for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
293
- const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
294
- if (!mdia || !isVideoTrack(moov, mdia)) {
295
- continue;
296
- }
297
- const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
298
- if (!mdhd) {
299
- continue;
300
- }
301
- // mdhd layout: version(1) + flags(3), then creation/modification times —
302
- // 32-bit each in version 0, 64-bit in version 1 — then the timescale.
303
- const version = moov[mdhd.dataOffset];
304
- const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
305
- if (timescaleOffset + 4 > mdhd.end) {
306
- continue;
307
- }
308
- const timescale = moov.readUInt32BE(timescaleOffset);
309
- if (!timescale) {
310
- continue;
311
- }
312
-
313
- const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
314
- const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
315
- if (!stbl) {
316
- continue;
317
- }
318
- const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
319
- const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
320
- if (!stss || !stts) {
321
- continue;
322
- }
323
-
324
- const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
325
- const wanted = new Set();
326
- for (let index = 0; index < syncCount; index += 1) {
327
- const at = stss.dataOffset + 8 + index * 4;
328
- if (at + 4 > stss.end) {
329
- break;
330
- }
331
- wanted.add(moov.readUInt32BE(at));
332
- }
333
- if (wanted.size === 0) {
334
- continue;
335
- }
336
-
337
- // The two terms that turn decode times into the timeline ffmpeg cuts on.
338
- // Both are optional: a file without them is one whose decode and
339
- // composition orders already agree, and then nothing is added or taken.
340
- const ctts = findBox(moov, stbl.dataOffset, stbl.end, "ctts");
341
- const offsets = ctts ? readCompositionOffsets(moov, ctts, wanted) : null;
342
- const edts = findBox(moov, trak.dataOffset, trak.end, "edts");
343
- const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
344
- const editShift = elst ? readEditShift(moov, elst) : 0;
345
-
346
- const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
347
- if (times.length > 0) {
348
- // A shift applied after the division would be in the wrong units: the
349
- // edit's `media_time` is in MEDIA ticks, like everything else here.
350
- const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
351
- // A negative time is not a position in the file. It happens when an edit
352
- // starts later than a keyframe the table lists, and those frames are not
353
- // presented at all.
354
- return shifted.filter((time) => time >= 0);
355
- }
356
- }
357
- return null;
358
- }
@@ -1,2 +0,0 @@
1
- export { PlaybackController } from "./PlaybackController.js";
2
- export { SubtitleController } from "./SubtitleController.js";
@@ -1,8 +0,0 @@
1
- export { SwarmSelection } from "./SwarmSelection.js";
2
- export {
3
- anythingUrgentIsMissing,
4
- demandFor,
5
- forgetTorrent,
6
- liveDemand,
7
- reconcileAll
8
- } from "./registry.js";
@@ -1,2 +0,0 @@
1
- export { ContainerOrchestrator, containerOrchestrator } from "./ContainerOrchestrator.js";
2
- export { SubtitleOrchestrator, subtitleOrchestrator } from "./SubtitleOrchestrator.js";