@torrent-tv/proxy 2.74.1 → 2.75.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.
@@ -1,118 +0,0 @@
1
- /**
2
- * @file Container keyframe index — where a file's real keyframes are, read
3
- * from the container's own tables rather than by scanning the media.
4
- *
5
- * The problem it solves: on the video-COPY path ffmpeg can only cut segments at
6
- * the source's existing keyframes. A playlist declaring an even grid instead is
7
- * then false, and players punish it — either walking the whole file to rebuild
8
- * the timeline, or presenting audio with no picture because a segment begins
9
- * with nothing decodable (both seen in the field 2026-08-02; the file measured
10
- * had 10.43 s keyframe spacing against our declared 4 s).
11
- *
12
- * Scanning for the answer is not an option here: the file is served from a
13
- * torrent, and a full packet scan of 5.5 GB found 77 keyframes in 45 s without
14
- * finishing. Containers already store the table — this reads it with a couple
15
- * of point reads (16 KB, 0.8 s for 570 keyframes on that same file).
16
- *
17
- * Transport-agnostic by construction: it takes a byte-range function and knows
18
- * nothing about torrents, HTTP or our session model, which is what let it be
19
- * verified standalone before being wired in.
20
- */
21
-
22
- import { logger } from "../../utils/logger.js";
23
- import { isMatroska, readMatroskaKeyframeTimes } from "./matroska.js";
24
- import { isMp4, readMp4KeyframeTimes } from "./mp4.js";
25
- import { isAvi, readAviKeyframeTimes } from "./avi.js";
26
-
27
- /**
28
- * @callback ReadRange
29
- * @param {number} start - First byte, inclusive.
30
- * @param {number} end - Last byte, inclusive.
31
- * @returns {Promise<Buffer | null>} The bytes, or null when unavailable.
32
- */
33
-
34
- // Enough to identify every supported container from its opening bytes.
35
- const SNIFF_BYTES = 16;
36
-
37
- /**
38
- * Container readers, in detection order. Each pairs a cheap magic-byte test
39
- * with the reader for that format.
40
- *
41
- * Formats deliberately absent, and why:
42
- * - **MPEG-TS / M2TS** carry no index at all — the format is a continuous
43
- * broadcast stream with no table of contents anywhere. Nothing to read.
44
- * - **FLV / ASF-WMV** do have keyframe tables, but effectively never appear in
45
- * the releases this serves; adding them is mechanical if that changes.
46
- * - **Fragmented MP4** spreads its timing across fragments instead of a single
47
- * `moov` table; `readMp4KeyframeTimes` returns null for it rather than
48
- * guessing.
49
- *
50
- * @type {{ name: string, matches: (head: Buffer) => boolean, read: ReadRange extends never ? never : (readRange: ReadRange, fileSize: number) => Promise<number[] | null> }[]}
51
- */
52
- const READERS = [
53
- { name: "matroska", matches: isMatroska, read: readMatroskaKeyframeTimes },
54
- { name: "mp4", matches: isMp4, read: readMp4KeyframeTimes },
55
- { name: "avi", matches: isAvi, read: readAviKeyframeTimes }
56
- ];
57
-
58
- /**
59
- * Read a file's keyframe times from its container index.
60
- *
61
- * @param {object} params
62
- * @param {ReadRange} params.readRange
63
- * @param {number} params.fileSize
64
- * @param {string} [params.label] - For logging only.
65
- * @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>} Ascending
66
- * seconds, or null times when this file has no readable index — the caller
67
- * must then not claim to know the grid. The format is which reader matched,
68
- * reported whether or not it produced anything: how often an index disagrees
69
- * with its own file is a question about the CONTAINER, and it cannot be
70
- * answered by a measurement that does not say which one it came from.
71
- */
72
- export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
73
- if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
74
- return { times: null, format: "unknown", tolerance: 0 };
75
- }
76
-
77
- const startedAt = Date.now();
78
- let times = null;
79
- let format = "unrecognised";
80
- // How far a time in `times` may be from the instant it names. Zero wherever
81
- // the container states instants outright, which Matroska and MP4 both do.
82
- let tolerance = 0;
83
- try {
84
- const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
85
- if (!sniff) {
86
- return { times: null, format: "unread", tolerance: 0 };
87
- }
88
- const reader = READERS.find((candidate) => candidate.matches(sniff));
89
- if (reader) {
90
- format = reader.name;
91
- const read = await reader.read(readRange, fileSize);
92
- // A reader may answer with the times alone, or with how far those times
93
- // may sit from the instants they name — which only AVI has to say,
94
- // because only AVI computes them from frame numbers.
95
- if (Array.isArray(read)) {
96
- times = read;
97
- } else if (read && Array.isArray(read.times)) {
98
- times = read.times;
99
- tolerance = Number.isFinite(read.tolerance) ? read.tolerance : 0;
100
- }
101
- }
102
- } catch (error) {
103
- // A malformed or partially-downloaded index must never take playback down —
104
- // it only means the grid is unknown, which the caller already handles.
105
- logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
106
- return { times: null, format, tolerance: 0 };
107
- }
108
-
109
- const elapsedMs = Date.now() - startedAt;
110
- if (times) {
111
- logger.info(
112
- `container-index: ${times.length} keyframes from the ${format} index in ${elapsedMs}ms for "${label}"`
113
- );
114
- } else {
115
- logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
116
- }
117
- return { times, format, tolerance };
118
- }
@@ -1,336 +0,0 @@
1
- /**
2
- * @file Keyframe index for Matroska (MKV/WebM), read without downloading the file.
3
- *
4
- * Matroska stores a Cues element — a table of "at time T, a keyframe starts at
5
- * byte P" — and a SeekHead near the start listing where each top-level element
6
- * lives. So two point reads suffice: the head, to learn where Cues is, then
7
- * Cues itself. Measured on a 5.5 GB torrent-backed file: 4 KB + 12 KB, both
8
- * effectively instant, versus a full packet scan that found 77 keyframes in
9
- * 45 s and never finished.
10
- *
11
- * This matters because on the video-COPY path the encoder can only cut on the
12
- * source's own keyframes. A playlist declaring an even grid is then a lie, and
13
- * players react badly to it — either walking the whole file to rebuild the
14
- * timeline, or presenting audio with no picture because a segment begins with
15
- * no keyframe to decode from (both observed in the field 2026-08-02).
16
- */
17
-
18
- import { findElement, iterateElements, readUint } from "./ebml-reader.js";
19
-
20
- // Element ids, from the Matroska specification.
21
- const ID_SEGMENT = 0x18538067;
22
- const ID_SEEK_HEAD = 0x114d9b74;
23
- const ID_SEEK = 0x4dbb;
24
- const ID_SEEK_ID = 0x53ab;
25
- const ID_SEEK_POSITION = 0x53ac;
26
- const ID_INFO = 0x1549a966;
27
- const ID_TIMESTAMP_SCALE = 0x2ad7b1;
28
- const ID_CUES = 0x1c53bb6b;
29
- const ID_CUE_POINT = 0xbb;
30
- const ID_CUE_TIME = 0xb3;
31
- const ID_CUE_TRACK_POSITIONS = 0xb7;
32
- const ID_CUE_TRACK = 0xf7;
33
- const ID_TRACKS = 0x1654ae6b;
34
- const ID_TRACK_ENTRY = 0xae;
35
- const ID_TRACK_NUMBER = 0xd7;
36
- const ID_TRACK_TYPE = 0x83;
37
- // TrackType 1 is video; 2 is audio, 17 subtitles, and the rest are rarer still.
38
- const TRACK_TYPE_VIDEO = 1;
39
-
40
- // How much of the file start to read. Must cover the EBML header, the SeekHead
41
- // and Info; 64 KB is generous for every real muxer (the file measured needed
42
- // under 4 KB) while still trivial to fetch.
43
- const HEAD_BYTES = 64 * 1024;
44
- // Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
45
- // this is not a normal index and not worth pulling over a torrent.
46
- const MAX_CUES_BYTES = 8 * 1024 * 1024;
47
- // Cap on a Tracks read, for the rare file whose Tracks element sits outside the
48
- // head window. Track entries are small, so a file with dozens of them still
49
- // fits well inside this.
50
- const MAX_TRACKS_BYTES = 1024 * 1024;
51
- // Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
52
- const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
53
-
54
- /**
55
- * Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
56
- *
57
- * @param {Buffer} head
58
- * @returns {boolean}
59
- */
60
- export function isMatroska(head) {
61
- return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
62
- }
63
-
64
- /**
65
- * Locate the Segment element and the SeekHead entries inside it.
66
- *
67
- * Seek positions are relative to the start of Segment's payload, not to the
68
- * file, so that base has to come back with them.
69
- *
70
- * @param {Buffer} head
71
- * @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
72
- */
73
- function readSeekHead(head) {
74
- let segmentDataOffset = -1;
75
- for (const element of iterateElements(head)) {
76
- if (element.id === ID_SEGMENT) {
77
- segmentDataOffset = element.dataOffset;
78
- break;
79
- }
80
- }
81
- if (segmentDataOffset < 0) {
82
- return null;
83
- }
84
-
85
- const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
86
- if (!seekHead) {
87
- return null;
88
- }
89
-
90
- const entries = new Map();
91
- const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
92
- for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
93
- if (seek.id !== ID_SEEK) {
94
- continue;
95
- }
96
- const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
97
- let targetId = null;
98
- let position = null;
99
- for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
100
- if (field.id === ID_SEEK_ID) {
101
- targetId = readUint(head, field.dataOffset, field.size);
102
- } else if (field.id === ID_SEEK_POSITION) {
103
- position = readUint(head, field.dataOffset, field.size);
104
- }
105
- }
106
- if (targetId !== null && position !== null) {
107
- entries.set(targetId, position);
108
- }
109
- }
110
- return { segmentDataOffset, entries };
111
- }
112
-
113
- /**
114
- * Timestamp scale (nanoseconds per tick) declared in Info, or the default.
115
- *
116
- * @param {Buffer} head
117
- * @param {number} segmentDataOffset
118
- * @returns {number}
119
- */
120
- function readTimestampScale(head, segmentDataOffset) {
121
- const info = findElement(head, ID_INFO, [], segmentDataOffset);
122
- if (!info) {
123
- return DEFAULT_TIMESTAMP_SCALE;
124
- }
125
- const infoEnd = Math.min(head.length, info.dataOffset + info.size);
126
- for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
127
- if (field.id === ID_TIMESTAMP_SCALE) {
128
- const scale = readUint(head, field.dataOffset, field.size);
129
- return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
130
- }
131
- }
132
- return DEFAULT_TIMESTAMP_SCALE;
133
- }
134
-
135
- /**
136
- * The number of the first video track, from a Tracks payload.
137
- *
138
- * The FIRST one, because that is the track ffmpeg is told to copy (`0:v:0`).
139
- *
140
- * @param {Buffer} buffer
141
- * @param {{ dataOffset: number, size: number }} tracks
142
- * @returns {number | null}
143
- */
144
- function readVideoTrackNumber(buffer, tracks) {
145
- const tracksEnd = Math.min(buffer.length, tracks.dataOffset + tracks.size);
146
- for (const entry of iterateElements(buffer, tracks.dataOffset, tracksEnd)) {
147
- if (entry.id !== ID_TRACK_ENTRY) {
148
- continue;
149
- }
150
- const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
151
- let number = null;
152
- let type = null;
153
- for (const field of iterateElements(buffer, entry.dataOffset, entryEnd)) {
154
- if (field.id === ID_TRACK_NUMBER) {
155
- number = readUint(buffer, field.dataOffset, field.size);
156
- } else if (field.id === ID_TRACK_TYPE) {
157
- type = readUint(buffer, field.dataOffset, field.size);
158
- }
159
- }
160
- if (number !== null && type === TRACK_TYPE_VIDEO) {
161
- return number;
162
- }
163
- }
164
- return null;
165
- }
166
-
167
- /**
168
- * Cue times (seconds, ascending) of ONE track, from a Cues payload.
169
- *
170
- * The track is the whole point, and leaving it out is what this reader got
171
- * wrong until 2026-08-18. A CuePoint belongs to the track named inside its
172
- * CueTrackPositions, and a muxer indexes whatever tracks it likes: RFC 9559
173
- * says each keyframe of a video track SHOULD be referenced, and that the Cues
174
- * Element "can be used to index every single timestamp of every Block or they
175
- * can be indexed selectively". Both field files index their SUBTITLE tracks as
176
- * well — `Minions.and.Monsters.1080p.mkv` has 2778 video entries every 2.002 s
177
- * plus 4669 across four subtitle tracks; `Moana.2 … MegaPeer.mkv` has 1055
178
- * video entries plus 5007 across five.
179
- *
180
- * Read without the track, those extra times enter the cut list as though they
181
- * were keyframes. ffmpeg can only cut a COPIED picture at a real keyframe at or
182
- * after the time it is given, so every cut asked for at one of them lands late
183
- * — which is exactly what the field measured: on the first file every deviation
184
- * was 2.002 s, that file's own keyframe spacing, and on the second the median
185
- * was 6.3 s with a worst case of 21 s. Never once negative.
186
- *
187
- * @param {Buffer} cues
188
- * @param {number} timestampScale - Nanoseconds per tick.
189
- * @param {number | null} trackNumber - Null keeps every entry, which is right
190
- * only for a file that indexes one track.
191
- * @returns {number[]}
192
- */
193
- function readCueTimes(cues, timestampScale, trackNumber) {
194
- const times = [];
195
- const secondsPerTick = timestampScale / 1e9;
196
- for (const point of iterateElements(cues)) {
197
- if (point.id !== ID_CUE_POINT) {
198
- continue;
199
- }
200
- const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
201
- let time = null;
202
- let belongsToTrack = trackNumber === null;
203
- for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
204
- if (field.id === ID_CUE_TIME) {
205
- time = readUint(cues, field.dataOffset, field.size) * secondsPerTick;
206
- continue;
207
- }
208
- if (field.id !== ID_CUE_TRACK_POSITIONS || belongsToTrack) {
209
- continue;
210
- }
211
- const positionsEnd = Math.min(pointEnd, field.dataOffset + field.size);
212
- for (const inner of iterateElements(cues, field.dataOffset, positionsEnd)) {
213
- if (inner.id === ID_CUE_TRACK && readUint(cues, inner.dataOffset, inner.size) === trackNumber) {
214
- belongsToTrack = true;
215
- break;
216
- }
217
- }
218
- }
219
- if (time !== null && belongsToTrack) {
220
- times.push(time);
221
- }
222
- }
223
- times.sort((left, right) => left - right);
224
- return times;
225
- }
226
-
227
- /**
228
- * Read the keyframe times of a Matroska file using only two point reads.
229
- *
230
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
231
- * Inclusive byte range reader; returns null when the range is unavailable.
232
- * @param {number} fileSize
233
- * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
234
- * carries no usable index (see the module doc for when that happens).
235
- */
236
- export async function readMatroskaKeyframeTimes(readRange, fileSize) {
237
- const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
238
- if (!head || !isMatroska(head)) {
239
- return null;
240
- }
241
-
242
- const seekHead = readSeekHead(head);
243
- if (!seekHead) {
244
- return null; // No SeekHead — a streamed or truncated mux.
245
- }
246
-
247
- const cuesRelative = seekHead.entries.get(ID_CUES);
248
- if (cuesRelative === undefined) {
249
- return null; // Indexless file: live capture, interrupted write, damaged upload.
250
- }
251
-
252
- // SeekHead positions are relative to Segment's payload.
253
- const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
254
- if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
255
- return null;
256
- }
257
-
258
- // The element header states the payload size, but reading it costs a round
259
- // trip; fetch a bounded window instead and let the parser stop at the end of
260
- // what it got. Cues sits near the file end, so the window is clamped there.
261
- const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
262
- const cuesChunk = await readRange(cuesOffset, cuesEnd);
263
- if (!cuesChunk || cuesChunk.length === 0) {
264
- return null;
265
- }
266
-
267
- // The window starts exactly at the Cues element, so its own header comes
268
- // first; step over it to reach the CuePoints.
269
- const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
270
- if (!cuesElement || cuesElement.id !== ID_CUES) {
271
- return null;
272
- }
273
- const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
274
- const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
275
-
276
- // Whose entries to keep. Tracks sits near the head and is normally inside the
277
- // bytes already fetched; when it is not, SeekHead says where it is and one
278
- // more short read gets it. Nothing is fetched twice and nothing is scanned.
279
- const videoTrack = await readVideoTrack(readRange, head, seekHead, fileSize);
280
- const timestampScale = readTimestampScale(head, seekHead.segmentDataOffset);
281
- const times = readCueTimes(payload, timestampScale, videoTrack);
282
- if (times.length > 0) {
283
- return times;
284
- }
285
- if (videoTrack === null) {
286
- return null;
287
- }
288
- // The filter left nothing, and that is not an answer about the file: a table
289
- // exists, and this reader simply failed to recognise which of its entries
290
- // belong to the picture — a track numbered one way in Tracks and another in
291
- // the cue points, or an entry with no CueTrack at all. Returning null here
292
- // would put an EVEN grid on a copied picture, which is the failure this
293
- // module exists to prevent, so the unfiltered table is used instead: less
294
- // exact than the picture's own keyframes, better than a grid that has nothing
295
- // to do with the file.
296
- const unfiltered = readCueTimes(payload, timestampScale, null);
297
- return unfiltered.length > 0 ? unfiltered : null;
298
- }
299
-
300
- /**
301
- * The video track's number — from the head when it is there, and from one extra
302
- * short read when it is not.
303
- *
304
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
305
- * @param {Buffer} head
306
- * @param {{ segmentDataOffset: number, entries: Map<number, number> }} seekHead
307
- * @param {number} fileSize
308
- * @returns {Promise<number | null>} Null when Tracks cannot be read at all, and
309
- * then every cue entry is kept — right for a file that indexes only its
310
- * picture, wrong for one that does not, and nothing here can tell them apart.
311
- * Refusing the index instead would put an even grid on a copied picture,
312
- * which is the failure this reader exists to prevent.
313
- */
314
- async function readVideoTrack(readRange, head, seekHead, fileSize) {
315
- const inHead = findElement(head, ID_TRACKS, [], seekHead.segmentDataOffset);
316
- if (inHead && inHead.dataOffset + inHead.size <= head.length) {
317
- return readVideoTrackNumber(head, inHead);
318
- }
319
- const relative = seekHead.entries.get(ID_TRACKS);
320
- if (relative === undefined) {
321
- return null;
322
- }
323
- const offset = seekHead.segmentDataOffset + relative;
324
- if (!Number.isFinite(offset) || offset <= 0 || offset >= fileSize) {
325
- return null;
326
- }
327
- const chunk = await readRange(offset, Math.min(fileSize - 1, offset + MAX_TRACKS_BYTES));
328
- if (!chunk || chunk.length === 0) {
329
- return null;
330
- }
331
- const element = [...iterateElements(chunk, 0, chunk.length)][0];
332
- if (!element || element.id !== ID_TRACKS) {
333
- return null;
334
- }
335
- return readVideoTrackNumber(chunk, { dataOffset: element.dataOffset, size: element.size });
336
- }