@torrent-tv/proxy 2.9.53 → 2.9.54

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @file fMP4 (CMAF) segment format — `.m4s` media segments plus one shared
3
+ * `init.mp4` referenced by `#EXT-X-MAP`.
4
+ *
5
+ * Codec configuration (SPS/PPS) lives once in the init segment instead of being
6
+ * repeated per segment. That is what makes hardware encoders which do not
7
+ * repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce
8
+ * independently usable segments, and it lowers container overhead.
9
+ *
10
+ * See {@link SegmentFormat} in `./index.js` for the interface contract.
11
+ */
12
+
13
+ import { readTrackTimescales, stampSegmentStartTime } from "./mp4-boxes.js";
14
+
15
+ const INIT_FILE_NAME = "init.mp4";
16
+ const SEGMENT_PATTERN = /^segment-(\d{5})\.m4s$/;
17
+
18
+ /**
19
+ * @type {import("./index.js").SegmentFormat}
20
+ */
21
+ export const fmp4Format = {
22
+ id: "fmp4",
23
+ initFileName: INIT_FILE_NAME,
24
+ initContentType: "video/mp4",
25
+ segmentContentType: "video/mp4",
26
+ // Version 7 is the minimum that allows fMP4 media segments + `#EXT-X-MAP`.
27
+ playlistVersion: 7,
28
+
29
+ muxerArgs() {
30
+ return [
31
+ "-hls_segment_type",
32
+ "fmp4",
33
+ "-hls_fmp4_init_filename",
34
+ INIT_FILE_NAME,
35
+ "-hls_segment_filename",
36
+ "segment-%05d.m4s"
37
+ ];
38
+ },
39
+
40
+ playlistHeaderLines() {
41
+ return [
42
+ // The init segment (codec config). Fetched once; applies to every media
43
+ // segment in the playlist.
44
+ `#EXT-X-MAP:URI="${INIT_FILE_NAME}"`
45
+ ];
46
+ },
47
+
48
+ segmentFileName(index) {
49
+ return `segment-${String(index).padStart(5, "0")}.m4s`;
50
+ },
51
+
52
+ isSegmentFileName(fileName) {
53
+ return SEGMENT_PATTERN.test(fileName);
54
+ },
55
+
56
+ segmentIndexFromName(fileName) {
57
+ const match = SEGMENT_PATTERN.exec(fileName);
58
+ return match ? Number(match[1]) : -1;
59
+ },
60
+
61
+ /**
62
+ * fMP4 segments must be read into memory and corrected before being served —
63
+ * see {@link stampSegmentStartTime} for the full reasoning. Without this a
64
+ * seek is permanently broken: ffmpeg leaves every segment claiming to start
65
+ * at 0 and puts the real offset in the per-run init, which we do not serve
66
+ * (the player fetches the session's first init once and keeps it).
67
+ *
68
+ * Segments are a few hundred KB, so reading one whole is cheap next to the
69
+ * transcode itself; the box walk never descends into `mdat`.
70
+ */
71
+ needsSegmentRewrite: true,
72
+
73
+ prepareSegmentBytes(bytes, { startSeconds, initBytes }) {
74
+ if (!initBytes || initBytes.length === 0) {
75
+ // No init cached yet — nothing to read timescales from. The player always
76
+ // fetches `#EXT-X-MAP` before any segment, so this is not reachable in
77
+ // practice; serve unmodified rather than guess a timescale.
78
+ return bytes;
79
+ }
80
+ return stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
81
+ }
82
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @file Selectable HLS segment container formats.
3
+ *
4
+ * Everything that differs between fMP4/CMAF and MPEG-TS output lives behind one
5
+ * interface, so `hls-session-manager` never branches on the container: it holds
6
+ * a format object and asks it. Adding a container means adding a module here,
7
+ * not editing the session manager.
8
+ *
9
+ * The choice is per-proxy (CLI `--segment-format`), mirroring how Jellyfin lets
10
+ * the operator pick the transcoding container. fMP4 is the default; MPEG-TS is
11
+ * the fallback for hosts or players where fMP4 misbehaves.
12
+ */
13
+
14
+ import { fmp4Format } from "./fmp4.js";
15
+ import { mpegtsFormat } from "./mpegts.js";
16
+
17
+ /**
18
+ * @typedef {Object} PrepareSegmentContext
19
+ * @property {number} startSeconds - Position of this segment on the 0-based
20
+ * output timeline, from the session's segment-boundary table.
21
+ * @property {Buffer | null} initBytes - The init segment being served for this
22
+ * session, when the format has one (fMP4 needs it to read track timescales).
23
+ */
24
+
25
+ /**
26
+ * One container format's complete behaviour.
27
+ *
28
+ * @typedef {Object} SegmentFormat
29
+ * @property {string} id - Stable identifier, also the CLI value ("fmp4" | "mpegts").
30
+ * @property {string | null} initFileName - Init segment name, or null when the
31
+ * format has none (MPEG-TS).
32
+ * @property {string | null} initContentType - MIME type for the init segment.
33
+ * @property {string} segmentContentType - MIME type for a media segment.
34
+ * @property {number} playlistVersion - `#EXT-X-VERSION` the playlist must declare.
35
+ * @property {() => string[]} muxerArgs - ffmpeg arguments selecting this
36
+ * container (including `-hls_segment_filename`).
37
+ * @property {() => string[]} playlistHeaderLines - Extra playlist header lines
38
+ * (e.g. `#EXT-X-MAP` for fMP4); empty for formats that need none.
39
+ * @property {(index: number) => string} segmentFileName - File name for a
40
+ * zero-based segment index.
41
+ * @property {(fileName: string) => boolean} isSegmentFileName - Whether a name
42
+ * is one of this format's media segments.
43
+ * @property {(fileName: string) => number} segmentIndexFromName - Index encoded
44
+ * in a segment file name, or -1.
45
+ * @property {boolean} needsSegmentRewrite - Whether a segment must be read into
46
+ * memory and passed through `prepareSegmentBytes` before being served. False
47
+ * lets the caller stream straight from disk.
48
+ * @property {(bytes: Buffer, context: PrepareSegmentContext) => Buffer}
49
+ * prepareSegmentBytes - Correct a segment before serving. Identity for
50
+ * formats that need nothing.
51
+ */
52
+
53
+ /** @type {Readonly<Record<string, SegmentFormat>>} */
54
+ const FORMATS = Object.freeze({
55
+ [fmp4Format.id]: fmp4Format,
56
+ [mpegtsFormat.id]: mpegtsFormat
57
+ });
58
+
59
+ /** The container used unless the operator selects otherwise. */
60
+ export const DEFAULT_SEGMENT_FORMAT_ID = fmp4Format.id;
61
+
62
+ /** Identifiers accepted by {@link resolveSegmentFormat}, for CLI help/validation. */
63
+ export const SEGMENT_FORMAT_IDS = Object.freeze(Object.keys(FORMATS));
64
+
65
+ /**
66
+ * Resolve a format by id, falling back to the default for an unknown or absent
67
+ * value (a bad CLI value must not stop the proxy from starting).
68
+ *
69
+ * @param {string | undefined | null} id
70
+ * @returns {SegmentFormat}
71
+ */
72
+ export function resolveSegmentFormat(id) {
73
+ if (typeof id === "string" && Object.hasOwn(FORMATS, id)) {
74
+ return FORMATS[id];
75
+ }
76
+ return FORMATS[DEFAULT_SEGMENT_FORMAT_ID];
77
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @file Minimal ISO Base Media File Format (ISO/IEC 14496-12) box reader/writer.
3
+ *
4
+ * Only what the fMP4 segment format needs: read the per-track media timescale
5
+ * out of an init segment, and rewrite each fragment's
6
+ * `tfdt` (TrackFragmentBaseMediaDecodeTime) so a segment states where it sits
7
+ * on the media timeline. Deliberately tiny and dependency-free — it never
8
+ * descends into `mdat` (the payload), so cost is proportional to the header,
9
+ * not to the segment size.
10
+ */
11
+
12
+ /**
13
+ * Container boxes whose payload is a sequence of child boxes. Anything else is
14
+ * treated as a leaf, so `mdat` (the media payload) is never walked into.
15
+ *
16
+ * @type {ReadonlySet<string>}
17
+ */
18
+ const CONTAINER_BOXES = new Set(["moov", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
19
+
20
+ /**
21
+ * Walk the box tree, invoking `visit` for every box encountered.
22
+ *
23
+ * @param {Buffer} buffer
24
+ * @param {(type: string, bodyStart: number, bodyEnd: number) => void} visit
25
+ * `bodyStart`/`bodyEnd` delimit the box payload (header excluded).
26
+ * @param {number} [start=0]
27
+ * @param {number} [end=buffer.length]
28
+ * @returns {void}
29
+ */
30
+ export function walkBoxes(buffer, visit, start = 0, end = buffer.length) {
31
+ let offset = start;
32
+ while (offset + 8 <= end) {
33
+ let size = buffer.readUInt32BE(offset);
34
+ const type = buffer.toString("latin1", offset + 4, offset + 8);
35
+ let headerSize = 8;
36
+ if (size === 1) {
37
+ // 64-bit `largesize` follows the type.
38
+ if (offset + 16 > end) {
39
+ return;
40
+ }
41
+ size = Number(buffer.readBigUInt64BE(offset + 8));
42
+ headerSize = 16;
43
+ } else if (size === 0) {
44
+ // "to end of file"
45
+ size = end - offset;
46
+ }
47
+ if (size < headerSize || offset + size > end) {
48
+ return; // truncated or malformed — stop rather than read out of bounds
49
+ }
50
+ visit(type, offset + headerSize, offset + size);
51
+ if (CONTAINER_BOXES.has(type)) {
52
+ walkBoxes(buffer, visit, offset + headerSize, offset + size);
53
+ }
54
+ offset += size;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Media timescale (ticks per second) of every track in an init segment, keyed
60
+ * by track id. `tfdt` values are expressed in this unit, so it is required to
61
+ * convert a wall-clock position into a `baseMediaDecodeTime`.
62
+ *
63
+ * Read from each track's `tkhd` (track id) + `mdia/mdhd` (timescale) pair;
64
+ * within a `trak` the `tkhd` always precedes the `mdhd`, so a single ordered
65
+ * pass pairs them correctly.
66
+ *
67
+ * @param {Buffer} initSegment
68
+ * @returns {Map<number, number>} trackId → timescale
69
+ */
70
+ export function readTrackTimescales(initSegment) {
71
+ const timescales = new Map();
72
+ let currentTrackId = null;
73
+ walkBoxes(initSegment, (type, bodyStart) => {
74
+ if (type === "tkhd") {
75
+ const version = initSegment[bodyStart];
76
+ // v1 widens creation/modification time to 64 bit, moving track_id by 8.
77
+ const trackIdOffset = version === 1 ? bodyStart + 20 : bodyStart + 12;
78
+ if (trackIdOffset + 4 <= initSegment.length) {
79
+ currentTrackId = initSegment.readUInt32BE(trackIdOffset);
80
+ }
81
+ } else if (type === "mdhd" && currentTrackId !== null) {
82
+ const version = initSegment[bodyStart];
83
+ const timescaleOffset = version === 1 ? bodyStart + 20 : bodyStart + 12;
84
+ if (timescaleOffset + 4 <= initSegment.length) {
85
+ timescales.set(currentTrackId, initSegment.readUInt32BE(timescaleOffset));
86
+ }
87
+ currentTrackId = null;
88
+ }
89
+ });
90
+ return timescales;
91
+ }
92
+
93
+ /**
94
+ * Rewrite every fragment's `tfdt` so the segment declares that it starts at
95
+ * `startSeconds` on the media timeline.
96
+ *
97
+ * WHY THIS IS NEEDED — ffmpeg's HLS/fMP4 output writes `tfdt = 0` in every
98
+ * seek-restart run and records the run's start offset in an `elst` (edit list)
99
+ * inside that run's init segment instead. That is self-consistent only while
100
+ * the init and the segments come from the SAME run. We serve one init for the
101
+ * whole session (the player fetches `#EXT-X-MAP` once and never re-fetches it),
102
+ * so a post-seek segment read against the cached init loses its offset entirely
103
+ * and appears to start at ~0 — the player finds nothing at the position it
104
+ * seeked to, discards the segment and re-requests it, forever. Verified in the
105
+ * field 2026-08-01: segments 402/403 re-fetched in a loop for over two minutes
106
+ * at full link speed with the buffer stuck at 0 s, while the transcode itself
107
+ * was healthy. No ffmpeg muxer/flag combination avoids this — HLS and DASH
108
+ * muxers, `-copyts`, `-output_ts_offset`, `-itsoffset`, `-avoid_negative_ts`,
109
+ * `-movflags -use_edts/+dash/+frag_discont/+global_sidx` were all measured and
110
+ * all produce `tfdt = 0`.
111
+ *
112
+ * Stamping the true value restores what CMAF (ISO/IEC 23000-19) requires of an
113
+ * independently-addressable segment anyway: it carries its own position, so it
114
+ * is valid against any init for the same tracks.
115
+ *
116
+ * Mutates a copy; the caller's buffer is untouched.
117
+ *
118
+ * @param {Buffer} segment
119
+ * @param {number} startSeconds - Position of this segment on the 0-based output timeline.
120
+ * @param {Map<number, number>} trackTimescales - From {@link readTrackTimescales}.
121
+ * @returns {Buffer} The segment with corrected `tfdt` values.
122
+ */
123
+ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
124
+ if (!Number.isFinite(startSeconds) || startSeconds < 0 || trackTimescales.size === 0) {
125
+ return segment;
126
+ }
127
+ const stamped = Buffer.from(segment);
128
+ // `tfhd` carries the track id and always precedes the `tfdt` inside the same
129
+ // `traf`, so an ordered pass pairs each `tfdt` with its track's timescale.
130
+ let currentTrackId = null;
131
+ walkBoxes(stamped, (type, bodyStart, bodyEnd) => {
132
+ if (type === "tfhd") {
133
+ if (bodyStart + 8 <= bodyEnd) {
134
+ currentTrackId = stamped.readUInt32BE(bodyStart + 4);
135
+ }
136
+ return;
137
+ }
138
+ if (type !== "tfdt" || currentTrackId === null) {
139
+ return;
140
+ }
141
+ const timescale = trackTimescales.get(currentTrackId);
142
+ if (!timescale) {
143
+ return;
144
+ }
145
+ const version = stamped[bodyStart];
146
+ const value = Math.round(startSeconds * timescale);
147
+ if (version === 1) {
148
+ if (bodyStart + 12 <= bodyEnd) {
149
+ stamped.writeBigUInt64BE(BigInt(value), bodyStart + 4);
150
+ }
151
+ } else if (bodyStart + 8 <= bodyEnd) {
152
+ // A 32-bit field cannot express beyond ~2^32 ticks; leave it rather than
153
+ // write a wrapped value (the player would land somewhere arbitrary).
154
+ if (value <= 0xffffffff) {
155
+ stamped.writeUInt32BE(value, bodyStart + 4);
156
+ }
157
+ }
158
+ });
159
+ return stamped;
160
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @file MPEG-TS segment format — self-contained `.ts` segments, no init segment.
3
+ *
4
+ * This is the pre-fMP4 behaviour (recovered from the switch commit `dd1ce09`),
5
+ * kept as a selectable alternative rather than deleted. Each segment carries
6
+ * its own parameter sets and its own timestamps, so it is valid on its own —
7
+ * there is no shared init segment that a seek-restart can invalidate, and
8
+ * therefore none of the timeline problems the fMP4 path has to correct for.
9
+ *
10
+ * Trade-off vs fMP4: higher container overhead, and encoders that do not repeat
11
+ * SPS/PPS (CM4 `h264_v4l2m2m`) emit segments after the first with no parameter
12
+ * sets, which is exactly why fMP4 became the default.
13
+ *
14
+ * See {@link SegmentFormat} in `./index.js` for the interface contract.
15
+ */
16
+
17
+ const SEGMENT_PATTERN = /^segment-(\d{5})\.ts$/;
18
+
19
+ /**
20
+ * @type {import("./index.js").SegmentFormat}
21
+ */
22
+ export const mpegtsFormat = {
23
+ id: "mpegts",
24
+ // No init segment: every `.ts` segment is self-describing.
25
+ initFileName: null,
26
+ initContentType: null,
27
+ segmentContentType: "video/mp2t",
28
+ playlistVersion: 3,
29
+
30
+ muxerArgs() {
31
+ return ["-hls_segment_filename", "segment-%05d.ts"];
32
+ },
33
+
34
+ playlistHeaderLines() {
35
+ return []; // no `#EXT-X-MAP`
36
+ },
37
+
38
+ segmentFileName(index) {
39
+ return `segment-${String(index).padStart(5, "0")}.ts`;
40
+ },
41
+
42
+ isSegmentFileName(fileName) {
43
+ return SEGMENT_PATTERN.test(fileName);
44
+ },
45
+
46
+ segmentIndexFromName(fileName) {
47
+ const match = SEGMENT_PATTERN.exec(fileName);
48
+ return match ? Number(match[1]) : -1;
49
+ },
50
+
51
+ /**
52
+ * MPEG-TS segments carry their own timestamps and need no correction, so they
53
+ * are streamed straight from disk (no read-into-memory step).
54
+ */
55
+ needsSegmentRewrite: false,
56
+
57
+ prepareSegmentBytes(bytes) {
58
+ return bytes;
59
+ }
60
+ };