@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.
- package/CHANGELOG.md +5 -0
- package/bin/cli.js +8 -1
- package/package.json +1 -1
- package/server.js +3 -1
- package/services/hls-session-manager.js +2322 -2312
- package/services/segment-formats/fmp4.js +82 -0
- package/services/segment-formats/index.js +77 -0
- package/services/segment-formats/mp4-boxes.js +160 -0
- package/services/segment-formats/mpegts.js +60 -0
|
@@ -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
|
+
};
|