@torrent-tv/proxy 2.9.64 → 2.9.66

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.9.66
2
+
3
+ - **New**: The container keyframe index now covers **MP4/MOV and AVI** as well as Matroska. MP4 reads the sync-sample and time-to-sample tables from `moov` — found by stepping over top-level box headers, so it works whether `moov` sits at the file start or the end, without scanning the gigabytes of `mdat` between them (verified on a 2 GB field file: **1145 keyframes in 625 ms**). AVI reads the trailing `idx1` table, still worth having because older releases are largely XviD-in-AVI and are exactly the files served by copying rather than re-encoding. Formats left out are documented in the module with the reason: MPEG-TS/M2TS carry no index anywhere by design, fragmented MP4 spreads timing across fragments instead of one table, and FLV/ASF have tables but effectively never appear in these releases.
4
+
5
+ ## 2.9.65
6
+
7
+ - **Fix**: Segment boundaries on the video-COPY path are now the source's **real** keyframe positions, read from the container's own index (`services/container-index/`), instead of an invented 4 s grid. ffmpeg can only cut a copied stream at existing keyframes, so the declared grid was simply false — measured on a field file, the true keyframe spacing is **10.43 s**, meaning roughly two of every three declared boundaries could not exist. Players punish this in two ways, both seen in the field 2026-08-02: on a long file the player stops trusting the playlist and walks it from segment #1 to locate a seek (a 1:30 seek produced requests #1, #2, #45, #86 … #1187 and never arrived), and on a short one it presents **audio with no picture**, because a segment beginning without a keyframe has nothing to decode from.
8
+ - **New**: `services/container-index/` — reads a file's keyframe table directly from the container (Matroska Cues today; MP4/AVI to follow) via two point reads: the head, to learn where the table lives, then the table itself. Measured against a 5.5 GB torrent-backed file: **570 keyframes in 0.8 s from 16 KB**, versus a full packet scan that found 77 in 45 s and never finished. Transport-agnostic by construction — it takes a byte-range function and knows nothing of torrents, HTTP or sessions — and cached per (source, file), so re-opens and seeks reuse the first read. Files with no readable index (live captures, interrupted writes, damaged uploads, MPEG-TS) return nothing and keep the previous fallback.
9
+
1
10
  ## 2.9.64
2
11
 
3
12
  - **Fix**: The 2.9.63 pull-to-lowest-awaited-segment dragged the encoder to the start of the file. A seek to #1354 restarted at **#123** — the position of the *previous* watch — because requests outstanding from before the seek still counted toward `lowestAwaitedIndex`. Two fixes: the awaited floor is cleared the moment a new seek arrives (earlier requests describe where the player used to be, not where it is going), and the pull is bounded by `SEEK_PULL_LIMIT_SEGMENTS` (120) below the target — anything deeper is a leftover, not the preceding keyframe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.64",
3
+ "version": "2.9.66",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @file Keyframe index for AVI, read without downloading the file.
3
+ *
4
+ * AVI ends with an `idx1` chunk: one fixed-size entry per stream chunk, each
5
+ * carrying a flags word whose keyframe bit says whether that chunk starts a
6
+ * keyframe. Frame number times the video stream's frame duration gives the
7
+ * time, so the index alone is enough — no media has to be read.
8
+ *
9
+ * `idx1` lives at the end of the file and the top-level chunk headers state
10
+ * their sizes, so it is reached by stepping over headers (typically two hops:
11
+ * `LIST hdrl`, `LIST movi`), not by scanning.
12
+ *
13
+ * Still relevant despite the format's age: older releases are largely XviD in
14
+ * AVI, and those are exactly the files that get copied rather than re-encoded.
15
+ */
16
+
17
+ const HEADER_BYTES = 8;
18
+ const PROBE_BYTES = 4096;
19
+ // Keyframe flag in an idx1 entry's flags word (AVIIF_KEYFRAME).
20
+ const KEYFRAME_FLAG = 0x10;
21
+ const IDX1_ENTRY_BYTES = 16;
22
+ // Cap on the idx1 read. One entry per chunk, 16 bytes each — a long film runs
23
+ // to a few MB; beyond this is not a normal index.
24
+ const MAX_IDX1_BYTES = 64 * 1024 * 1024;
25
+
26
+ /**
27
+ * Whether this looks like AVI: a RIFF container whose form type is `AVI `.
28
+ *
29
+ * @param {Buffer} head
30
+ * @returns {boolean}
31
+ */
32
+ export function isAvi(head) {
33
+ return (
34
+ head.length >= 12 &&
35
+ head.toString("latin1", 0, 4) === "RIFF" &&
36
+ head.toString("latin1", 8, 12) === "AVI "
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Microseconds per frame and the video stream's chunk id prefix, from the main
42
+ * header. Both live in the `hdrl` list near the file start.
43
+ *
44
+ * @param {Buffer} head
45
+ * @returns {{ microsecondsPerFrame: number } | null}
46
+ */
47
+ function readMainHeader(head) {
48
+ // Top-level: "RIFF" size "AVI " then chunks. `avih` sits inside `LIST hdrl`.
49
+ let offset = 12;
50
+ while (offset + HEADER_BYTES <= head.length) {
51
+ const id = head.toString("latin1", offset, offset + 4);
52
+ const size = head.readUInt32LE(offset + 4);
53
+ if (size <= 0) {
54
+ return null;
55
+ }
56
+ if (id === "LIST") {
57
+ // Descend: list type follows the header, then its own chunks.
58
+ const listType = head.toString("latin1", offset + 8, offset + 12);
59
+ if (listType === "hdrl") {
60
+ let inner = offset + 12;
61
+ while (inner + HEADER_BYTES <= Math.min(head.length, offset + 8 + size)) {
62
+ const innerId = head.toString("latin1", inner, inner + 4);
63
+ const innerSize = head.readUInt32LE(inner + 4);
64
+ if (innerSize <= 0) {
65
+ return null;
66
+ }
67
+ if (innerId === "avih" && inner + 8 + 4 <= head.length) {
68
+ return { microsecondsPerFrame: head.readUInt32LE(inner + 8) };
69
+ }
70
+ inner += HEADER_BYTES + innerSize + (innerSize % 2);
71
+ }
72
+ }
73
+ offset += HEADER_BYTES + 4 + (size - 4) + ((size - 4) % 2);
74
+ continue;
75
+ }
76
+ offset += HEADER_BYTES + size + (size % 2);
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Step over top-level chunks to find `idx1`.
83
+ *
84
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
85
+ * @param {number} fileSize
86
+ * @returns {Promise<{ offset: number, size: number } | null>}
87
+ */
88
+ async function findIdx1(readRange, fileSize) {
89
+ let offset = 12; // Past "RIFF" size "AVI ".
90
+ while (offset + HEADER_BYTES < fileSize) {
91
+ const probe = await readRange(offset, Math.min(fileSize - 1, offset + HEADER_BYTES - 1));
92
+ if (!probe || probe.length < HEADER_BYTES) {
93
+ return null;
94
+ }
95
+ const id = probe.toString("latin1", 0, 4);
96
+ const size = probe.readUInt32LE(4);
97
+ if (size <= 0) {
98
+ return null;
99
+ }
100
+ if (id === "idx1") {
101
+ return { offset: offset + HEADER_BYTES, size };
102
+ }
103
+ // Chunks are word-aligned; a LIST carries its type inside the payload, so
104
+ // the same size arithmetic covers both cases.
105
+ offset += HEADER_BYTES + size + (size % 2);
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Read the keyframe times of an AVI file.
112
+ *
113
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
114
+ * @param {number} fileSize
115
+ * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
116
+ * has no `idx1` (OpenDML-only index, interrupted write, damaged upload).
117
+ */
118
+ export async function readAviKeyframeTimes(readRange, fileSize) {
119
+ const head = await readRange(0, Math.min(PROBE_BYTES - 1, fileSize - 1));
120
+ if (!head || !isAvi(head)) {
121
+ return null;
122
+ }
123
+ const mainHeader = readMainHeader(head);
124
+ if (!mainHeader || !mainHeader.microsecondsPerFrame) {
125
+ return null;
126
+ }
127
+
128
+ const idx1 = await findIdx1(readRange, fileSize);
129
+ if (!idx1 || idx1.size > MAX_IDX1_BYTES) {
130
+ return null;
131
+ }
132
+
133
+ const table = await readRange(idx1.offset, Math.min(fileSize - 1, idx1.offset + idx1.size - 1));
134
+ if (!table || table.length < IDX1_ENTRY_BYTES) {
135
+ return null;
136
+ }
137
+
138
+ const secondsPerFrame = mainHeader.microsecondsPerFrame / 1e6;
139
+ const times = [];
140
+ let videoFrame = 0;
141
+ for (let at = 0; at + IDX1_ENTRY_BYTES <= table.length; at += IDX1_ENTRY_BYTES) {
142
+ const chunkId = table.toString("latin1", at, at + 4);
143
+ // Video chunks are "##db" (uncompressed) or "##dc" (compressed); audio is
144
+ // "##wb" and must not advance the frame counter.
145
+ const isVideo = chunkId.endsWith("db") || chunkId.endsWith("dc");
146
+ if (!isVideo) {
147
+ continue;
148
+ }
149
+ const flags = table.readUInt32LE(at + 4);
150
+ if ((flags & KEYFRAME_FLAG) !== 0) {
151
+ times.push(videoFrame * secondsPerFrame);
152
+ }
153
+ videoFrame += 1;
154
+ }
155
+ return times.length > 0 ? times : null;
156
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @file Minimal EBML reader — the encoding Matroska/WebM are built from.
3
+ *
4
+ * Only what a keyframe index needs: walk elements, read unsigned integers,
5
+ * descend into containers. Deliberately not a general EBML implementation.
6
+ *
7
+ * Why not the `ebml` npm package (MIT, otherwise fine): its decoder is a
8
+ * *stream* decoder — it must be fed the file from byte zero. We read two small
9
+ * ranges out of a multi-gigabyte torrent-backed file and never the whole thing,
10
+ * so a parser that starts mid-file is exactly what we need and exactly what it
11
+ * cannot do (verified 2026-08-02: feeding it the Cues range throws
12
+ * "Unrepresentable length"). The element encoding itself is small enough to
13
+ * implement directly, so we do — the shape follows the Matroska specification
14
+ * and the same logic those libraries implement.
15
+ */
16
+
17
+ /**
18
+ * A variable-length integer, as EBML encodes both element ids and sizes.
19
+ *
20
+ * The first set bit of the leading byte marks the width: `1xxxxxxx` is one
21
+ * byte, `01xxxxxx` two, and so on up to eight. For a SIZE the marker bit is
22
+ * removed and the remainder is the value; for an ID the bytes are kept intact,
23
+ * because the id *is* those bytes (that is how `0x1C53BB6B` identifies Cues).
24
+ *
25
+ * @param {Buffer} buffer
26
+ * @param {number} offset
27
+ * @param {boolean} keepMarker - True for ids, false for sizes.
28
+ * @returns {{ value: number, length: number } | null} Null when truncated or malformed.
29
+ */
30
+ export function readVint(buffer, offset, keepMarker) {
31
+ if (offset >= buffer.length) {
32
+ return null;
33
+ }
34
+ const first = buffer[offset];
35
+ if (first === 0) {
36
+ return null; // No marker bit in the first byte: not a valid vint start.
37
+ }
38
+ let length = 1;
39
+ let mask = 0x80;
40
+ while (length <= 8 && (first & mask) === 0) {
41
+ length += 1;
42
+ mask >>= 1;
43
+ }
44
+ if (length > 8 || offset + length > buffer.length) {
45
+ return null;
46
+ }
47
+ let value = keepMarker ? first : first & (mask - 1);
48
+ for (let index = 1; index < length; index += 1) {
49
+ // Values beyond 2^53 cannot be represented exactly; sizes and positions in
50
+ // real files stay far below that, so plain arithmetic is safe here.
51
+ value = value * 256 + buffer[offset + index];
52
+ }
53
+ return { value, length };
54
+ }
55
+
56
+ /**
57
+ * Iterate the elements directly inside `buffer`, without descending.
58
+ *
59
+ * Yields each element's id, the offset of its payload and its size. An element
60
+ * whose payload runs past the end of the buffer is still yielded (its header is
61
+ * intact and the caller may only need the id), but iteration stops after it.
62
+ *
63
+ * @param {Buffer} buffer
64
+ * @param {number} [start=0]
65
+ * @param {number} [end=buffer.length]
66
+ * @yields {{ id: number, dataOffset: number, size: number }}
67
+ */
68
+ export function* iterateElements(buffer, start = 0, end = buffer.length) {
69
+ let offset = start;
70
+ while (offset < end) {
71
+ const id = readVint(buffer, offset, true);
72
+ if (!id) {
73
+ return;
74
+ }
75
+ const size = readVint(buffer, offset + id.length, false);
76
+ if (!size) {
77
+ return;
78
+ }
79
+ const dataOffset = offset + id.length + size.length;
80
+ yield { id: id.value, dataOffset, size: size.value };
81
+ if (dataOffset + size.value > end) {
82
+ return; // Truncated payload — nothing dependable follows it.
83
+ }
84
+ offset = dataOffset + size.value;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Read an EBML unsigned integer payload (big-endian, variable width).
90
+ *
91
+ * @param {Buffer} buffer
92
+ * @param {number} offset
93
+ * @param {number} size
94
+ * @returns {number}
95
+ */
96
+ export function readUint(buffer, offset, size) {
97
+ let value = 0;
98
+ for (let index = 0; index < size; index += 1) {
99
+ value = value * 256 + buffer[offset + index];
100
+ }
101
+ return value;
102
+ }
103
+
104
+ /**
105
+ * Depth-first search for the first element with `id`, descending only into the
106
+ * container ids listed in `descendInto`.
107
+ *
108
+ * @param {Buffer} buffer
109
+ * @param {number} id - Element id to find.
110
+ * @param {number[]} descendInto - Container ids worth entering.
111
+ * @param {number} [start=0]
112
+ * @param {number} [end=buffer.length]
113
+ * @returns {{ dataOffset: number, size: number } | null}
114
+ */
115
+ export function findElement(buffer, id, descendInto, start = 0, end = buffer.length) {
116
+ for (const element of iterateElements(buffer, start, end)) {
117
+ if (element.id === id) {
118
+ return { dataOffset: element.dataOffset, size: element.size };
119
+ }
120
+ if (descendInto.includes(element.id)) {
121
+ const limit = Math.min(end, element.dataOffset + element.size);
122
+ const found = findElement(buffer, id, descendInto, element.dataOffset, limit);
123
+ if (found) {
124
+ return found;
125
+ }
126
+ }
127
+ }
128
+ return null;
129
+ }
@@ -0,0 +1,102 @@
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<number[] | null>} Ascending seconds, or null when this file
66
+ * has no readable index — the caller must then not claim to know the grid.
67
+ */
68
+ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
69
+ if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
70
+ return null;
71
+ }
72
+
73
+ const startedAt = Date.now();
74
+ let times = null;
75
+ let format = "unrecognised";
76
+ try {
77
+ const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
78
+ if (!sniff) {
79
+ return null;
80
+ }
81
+ const reader = READERS.find((candidate) => candidate.matches(sniff));
82
+ if (reader) {
83
+ format = reader.name;
84
+ times = await reader.read(readRange, fileSize);
85
+ }
86
+ } catch (error) {
87
+ // A malformed or partially-downloaded index must never take playback down —
88
+ // it only means the grid is unknown, which the caller already handles.
89
+ logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
90
+ return null;
91
+ }
92
+
93
+ const elapsedMs = Date.now() - startedAt;
94
+ if (times) {
95
+ logger.info(
96
+ `container-index: ${times.length} keyframes from the ${format} index in ${elapsedMs}ms for "${label}"`
97
+ );
98
+ } else {
99
+ logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
100
+ }
101
+ return times;
102
+ }
@@ -0,0 +1,200 @@
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
+
32
+ // How much of the file start to read. Must cover the EBML header, the SeekHead
33
+ // and Info; 64 KB is generous for every real muxer (the file measured needed
34
+ // under 4 KB) while still trivial to fetch.
35
+ const HEAD_BYTES = 64 * 1024;
36
+ // Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
37
+ // this is not a normal index and not worth pulling over a torrent.
38
+ const MAX_CUES_BYTES = 8 * 1024 * 1024;
39
+ // Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
40
+ const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
41
+
42
+ /**
43
+ * Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
44
+ *
45
+ * @param {Buffer} head
46
+ * @returns {boolean}
47
+ */
48
+ export function isMatroska(head) {
49
+ return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
50
+ }
51
+
52
+ /**
53
+ * Locate the Segment element and the SeekHead entries inside it.
54
+ *
55
+ * Seek positions are relative to the start of Segment's payload, not to the
56
+ * file, so that base has to come back with them.
57
+ *
58
+ * @param {Buffer} head
59
+ * @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
60
+ */
61
+ function readSeekHead(head) {
62
+ let segmentDataOffset = -1;
63
+ for (const element of iterateElements(head)) {
64
+ if (element.id === ID_SEGMENT) {
65
+ segmentDataOffset = element.dataOffset;
66
+ break;
67
+ }
68
+ }
69
+ if (segmentDataOffset < 0) {
70
+ return null;
71
+ }
72
+
73
+ const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
74
+ if (!seekHead) {
75
+ return null;
76
+ }
77
+
78
+ const entries = new Map();
79
+ const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
80
+ for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
81
+ if (seek.id !== ID_SEEK) {
82
+ continue;
83
+ }
84
+ const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
85
+ let targetId = null;
86
+ let position = null;
87
+ for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
88
+ if (field.id === ID_SEEK_ID) {
89
+ targetId = readUint(head, field.dataOffset, field.size);
90
+ } else if (field.id === ID_SEEK_POSITION) {
91
+ position = readUint(head, field.dataOffset, field.size);
92
+ }
93
+ }
94
+ if (targetId !== null && position !== null) {
95
+ entries.set(targetId, position);
96
+ }
97
+ }
98
+ return { segmentDataOffset, entries };
99
+ }
100
+
101
+ /**
102
+ * Timestamp scale (nanoseconds per tick) declared in Info, or the default.
103
+ *
104
+ * @param {Buffer} head
105
+ * @param {number} segmentDataOffset
106
+ * @returns {number}
107
+ */
108
+ function readTimestampScale(head, segmentDataOffset) {
109
+ const info = findElement(head, ID_INFO, [], segmentDataOffset);
110
+ if (!info) {
111
+ return DEFAULT_TIMESTAMP_SCALE;
112
+ }
113
+ const infoEnd = Math.min(head.length, info.dataOffset + info.size);
114
+ for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
115
+ if (field.id === ID_TIMESTAMP_SCALE) {
116
+ const scale = readUint(head, field.dataOffset, field.size);
117
+ return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
118
+ }
119
+ }
120
+ return DEFAULT_TIMESTAMP_SCALE;
121
+ }
122
+
123
+ /**
124
+ * Cue times (seconds, ascending) from a Cues payload.
125
+ *
126
+ * @param {Buffer} cues
127
+ * @param {number} timestampScale - Nanoseconds per tick.
128
+ * @returns {number[]}
129
+ */
130
+ function readCueTimes(cues, timestampScale) {
131
+ const times = [];
132
+ const secondsPerTick = timestampScale / 1e9;
133
+ for (const point of iterateElements(cues)) {
134
+ if (point.id !== ID_CUE_POINT) {
135
+ continue;
136
+ }
137
+ const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
138
+ for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
139
+ if (field.id === ID_CUE_TIME) {
140
+ times.push(readUint(cues, field.dataOffset, field.size) * secondsPerTick);
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ times.sort((left, right) => left - right);
146
+ return times;
147
+ }
148
+
149
+ /**
150
+ * Read the keyframe times of a Matroska file using only two point reads.
151
+ *
152
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
153
+ * Inclusive byte range reader; returns null when the range is unavailable.
154
+ * @param {number} fileSize
155
+ * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
156
+ * carries no usable index (see the module doc for when that happens).
157
+ */
158
+ export async function readMatroskaKeyframeTimes(readRange, fileSize) {
159
+ const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
160
+ if (!head || !isMatroska(head)) {
161
+ return null;
162
+ }
163
+
164
+ const seekHead = readSeekHead(head);
165
+ if (!seekHead) {
166
+ return null; // No SeekHead — a streamed or truncated mux.
167
+ }
168
+
169
+ const cuesRelative = seekHead.entries.get(ID_CUES);
170
+ if (cuesRelative === undefined) {
171
+ return null; // Indexless file: live capture, interrupted write, damaged upload.
172
+ }
173
+
174
+ // SeekHead positions are relative to Segment's payload.
175
+ const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
176
+ if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
177
+ return null;
178
+ }
179
+
180
+ // The element header states the payload size, but reading it costs a round
181
+ // trip; fetch a bounded window instead and let the parser stop at the end of
182
+ // what it got. Cues sits near the file end, so the window is clamped there.
183
+ const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
184
+ const cuesChunk = await readRange(cuesOffset, cuesEnd);
185
+ if (!cuesChunk || cuesChunk.length === 0) {
186
+ return null;
187
+ }
188
+
189
+ // The window starts exactly at the Cues element, so its own header comes
190
+ // first; step over it to reach the CuePoints.
191
+ const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
192
+ if (!cuesElement || cuesElement.id !== ID_CUES) {
193
+ return null;
194
+ }
195
+ const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
196
+ const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
197
+
198
+ const times = readCueTimes(payload, readTimestampScale(head, seekHead.segmentDataOffset));
199
+ return times.length > 0 ? times : null;
200
+ }
@@ -0,0 +1,246 @@
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) {
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
+ times.push(ticks / timescale);
164
+ }
165
+ ticks += delta;
166
+ sampleNumber += 1;
167
+ }
168
+ cursor += 8;
169
+ }
170
+ return times;
171
+ }
172
+
173
+ /**
174
+ * Read the keyframe times of an MP4/MOV file.
175
+ *
176
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
177
+ * @param {number} fileSize
178
+ * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
179
+ * carries no usable index (fragmented MP4, truncated or damaged `moov`).
180
+ */
181
+ export async function readMp4KeyframeTimes(readRange, fileSize) {
182
+ const moovBox = await findMoov(readRange, fileSize);
183
+ if (!moovBox || moovBox.size > MAX_MOOV_BYTES) {
184
+ return null;
185
+ }
186
+
187
+ const moov = await readRange(moovBox.offset, Math.min(fileSize - 1, moovBox.offset + moovBox.size - 1));
188
+ if (!moov || moov.length < moovBox.headerBytes) {
189
+ return null;
190
+ }
191
+
192
+ // Examine every track; the video one is whichever carries sync samples. A
193
+ // track with no `stss` has every sample a keyframe, so it constrains nothing
194
+ // and is skipped.
195
+ for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
196
+ const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
197
+ if (!mdia) {
198
+ continue;
199
+ }
200
+ const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
201
+ if (!mdhd) {
202
+ continue;
203
+ }
204
+ // mdhd layout: version(1) + flags(3), then creation/modification times —
205
+ // 32-bit each in version 0, 64-bit in version 1 — then the timescale.
206
+ const version = moov[mdhd.dataOffset];
207
+ const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
208
+ if (timescaleOffset + 4 > mdhd.end) {
209
+ continue;
210
+ }
211
+ const timescale = moov.readUInt32BE(timescaleOffset);
212
+ if (!timescale) {
213
+ continue;
214
+ }
215
+
216
+ const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
217
+ const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
218
+ if (!stbl) {
219
+ continue;
220
+ }
221
+ const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
222
+ const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
223
+ if (!stss || !stts) {
224
+ continue;
225
+ }
226
+
227
+ const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
228
+ const wanted = new Set();
229
+ for (let index = 0; index < syncCount; index += 1) {
230
+ const at = stss.dataOffset + 8 + index * 4;
231
+ if (at + 4 > stss.end) {
232
+ break;
233
+ }
234
+ wanted.add(moov.readUInt32BE(at));
235
+ }
236
+ if (wanted.size === 0) {
237
+ continue;
238
+ }
239
+
240
+ const times = resolveSampleTimes(moov, stts, timescale, wanted);
241
+ if (times.length > 0) {
242
+ return times;
243
+ }
244
+ }
245
+ return null;
246
+ }
@@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto";
16
16
  import { spawn } from "node:child_process";
17
17
  import { createRequire } from "node:module";
18
18
  import { logger } from "../utils/logger.js";
19
+ import { readKeyframeIndex } from "./container-index/index.js";
19
20
 
20
21
  /** Own package version, stamped onto session-start log lines. */
21
22
  const PROXY_VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -492,6 +493,17 @@ async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000)
492
493
  ffprobeBinFor(ffmpegBin),
493
494
  [
494
495
  "-v", "error",
496
+ // `-skip_frame nokey` makes the decoder discard non-keyframes, so the
497
+ // probe reads only what it needs. Without it a full packet scan of a
498
+ // ~5 GB MKV cannot finish inside any sane budget over a torrent-backed
499
+ // input, the probe returns nothing, and the playlist falls back to a
500
+ // uniform grid — which on the COPY path is a lie: cuts land on the
501
+ // source's real keyframes, not on a 4 s ruler. The player then finds
502
+ // the declared times do not match the media, stops trusting the
503
+ // playlist and walks the file from segment #1 to locate the seek
504
+ // position by hand (field 2026-08-02: a seek to 1:30 produced requests
505
+ // #1, #2, #45, #86, #123 … #1187, taking minutes and never arriving).
506
+ "-skip_frame", "nokey",
495
507
  "-select_streams", "v:0",
496
508
  "-show_entries", "packet=pts_time,flags",
497
509
  "-of", "csv=p=0",
@@ -735,6 +747,11 @@ export class HlsSessionManager {
735
747
  this.startupWaitMs = startupWaitMs;
736
748
  this.localBaseUrl = buildHttpBaseUrl(localBindHost, localPort);
737
749
  this.sessionsById = new Map();
750
+ // Container keyframe index per (source, file). Immutable per file, so one
751
+ // read serves every session, re-open and seek. Null means "this file has no
752
+ // readable index" and is cached too — no point retrying a scan that cannot
753
+ // succeed.
754
+ this.keyframeIndexCache = new Map();
738
755
  this.sessionIdBySource = new Map();
739
756
  this.cleanupTimer = setInterval(() => {
740
757
  void this.cleanupExpired();
@@ -913,12 +930,22 @@ export class HlsSessionManager {
913
930
  // packet scan time out and fall back to a uniform grid, so this never adds
914
931
  // more than ~6 s to session start.
915
932
  const keyframeStartMs = Date.now();
916
- keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
933
+ // Read the container's OWN keyframe table (Cues/stss) rather than
934
+ // scanning the media. On the copy path ffmpeg can only cut at the
935
+ // source's existing keyframes, so these times ARE the segment
936
+ // boundaries — declaring an even grid instead is a falsehood the player
937
+ // punishes: it walks the whole file to rebuild the timeline, or presents
938
+ // audio with no picture because a segment starts with nothing decodable
939
+ // (both field-observed 2026-08-02). Scanning cannot supply them here —
940
+ // the file comes off a torrent, and a full packet scan of 5.5 GB found 77
941
+ // keyframes in 45 s without finishing, while the container index yields
942
+ // all 570 in 0.8 s from two point reads (16 KB).
943
+ keyframeTimes = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
917
944
  keyframeMs = Date.now() - keyframeStartMs;
918
945
  if (!keyframeTimes) {
919
946
  logger.warn(
920
- `transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
921
- `for "${logName}" (seek precision may be reduced)`
947
+ `transcode ${sessionId}: no container keyframe index for "${logName}"; ` +
948
+ `falling back to a uniform grid — segment boundaries will not match the media`
922
949
  );
923
950
  }
924
951
  } else if (hasDuration && transcodeVideo) {
@@ -1157,6 +1184,56 @@ export class HlsSessionManager {
1157
1184
  * spans `[boundaries[i], boundaries[i+1])`.
1158
1185
  * @returns {string}
1159
1186
  */
1187
+ /**
1188
+ * Keyframe times for a source file, from the container's own index.
1189
+ *
1190
+ * Cached per (source, file) because the answer never changes for a given
1191
+ * file: a second session, a re-open or a seek all reuse the first read
1192
+ * instead of repeating it.
1193
+ *
1194
+ * Reads byte ranges through the proxy's own /stream route, so it goes through
1195
+ * the same torrent piece prioritisation as everything else and needs no
1196
+ * separate access path.
1197
+ *
1198
+ * @param {{ sourceKey: string, fileIndex: number, inputUrl: URL, logName: string }} params
1199
+ * @returns {Promise<number[] | null>} Ascending seconds, or null when this
1200
+ * file carries no readable index.
1201
+ */
1202
+ async #readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName }) {
1203
+ const cacheKey = `${sourceKey}:${fileIndex}`;
1204
+ if (this.keyframeIndexCache.has(cacheKey)) {
1205
+ return this.keyframeIndexCache.get(cacheKey);
1206
+ }
1207
+
1208
+ const url = inputUrl.toString();
1209
+ let fileSize = 0;
1210
+ try {
1211
+ const head = await fetch(url, { method: "HEAD" });
1212
+ fileSize = Number(head.headers.get("content-length")) || 0;
1213
+ } catch {
1214
+ return null;
1215
+ }
1216
+ if (fileSize <= 0) {
1217
+ return null;
1218
+ }
1219
+
1220
+ const readRange = async (start, end) => {
1221
+ try {
1222
+ const response = await fetch(url, { headers: { Range: `bytes=${start}-${end}` } });
1223
+ if (!response.ok && response.status !== 206) {
1224
+ return null;
1225
+ }
1226
+ return Buffer.from(await response.arrayBuffer());
1227
+ } catch {
1228
+ return null;
1229
+ }
1230
+ };
1231
+
1232
+ const times = await readKeyframeIndex({ readRange, fileSize, label: logName });
1233
+ this.keyframeIndexCache.set(cacheKey, times);
1234
+ return times;
1235
+ }
1236
+
1160
1237
  #buildVodPlaylist(boundaries) {
1161
1238
  const count = Math.max(0, boundaries.length - 1);
1162
1239
  let maxDuration = 0;