@torrent-tv/proxy 2.9.124 → 2.9.126

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,205 @@
1
+ /**
2
+ * @file A finished segment on disk must reach the viewer as bytes.
3
+ *
4
+ * The module tests cover each piece of the fMP4 path on its own, and every one
5
+ * of them passed while playback was dead: 2.9.124 called
6
+ * `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
7
+ * unit test imports that function straight from `mp4-boxes.js`, so the gap
8
+ * between a module and its CALLER was invisible. This test asks the session
9
+ * manager for a segment that exists and insists on getting it.
10
+ *
11
+ * The second half pins the reason a one-word slip cost a whole release: the
12
+ * failure was reported as "still being produced". Measured 2026-08-08 — segment
13
+ * #0 was held for 45 281 ms with twelve finished segments in the directory, and
14
+ * the log said nothing at all. Anything that goes wrong while preparing a file
15
+ * that EXISTS must be named and answered, never turned into an endless wait.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
24
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
25
+
26
+ const MOVIE_TIMESCALE = 1000;
27
+ const VIDEO_TIMESCALE = 90_000;
28
+ const AUDIO_TIMESCALE = 48_000;
29
+ const SEGMENT_START_SECONDS = 12.5;
30
+ const SESSION_ID = "11111111-2222-3333-4444-555555555555";
31
+
32
+ /**
33
+ * @param {string} type
34
+ * @param {Buffer} body
35
+ * @returns {Buffer}
36
+ */
37
+ function box(type, body) {
38
+ const head = Buffer.alloc(8);
39
+ head.writeUInt32BE(8 + body.length, 0);
40
+ head.write(type, 4, "latin1");
41
+ return Buffer.concat([head, body]);
42
+ }
43
+
44
+ /**
45
+ * `elst` holding one empty edit — how the `segment` muxer records where the
46
+ * piece sits on the source timeline.
47
+ *
48
+ * @param {number} offsetSeconds
49
+ * @returns {Buffer}
50
+ */
51
+ function emptyEdit(offsetSeconds) {
52
+ const body = Buffer.alloc(16);
53
+ body.writeUInt32BE(1, 4); // entry count
54
+ body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
55
+ body.writeInt32BE(-1, 12); // media_time
56
+ return box("elst", body);
57
+ }
58
+
59
+ /**
60
+ * @param {number} trackId
61
+ * @param {number} timescale
62
+ * @param {number} offsetSeconds
63
+ * @returns {Buffer}
64
+ */
65
+ function trak(trackId, timescale, offsetSeconds) {
66
+ const tkhdBody = Buffer.alloc(84);
67
+ tkhdBody.writeUInt32BE(trackId, 12);
68
+ const mdhdBody = Buffer.alloc(20);
69
+ mdhdBody.writeUInt32BE(timescale, 12);
70
+ return box("trak", Buffer.concat([
71
+ box("tkhd", tkhdBody),
72
+ box("edts", emptyEdit(offsetSeconds)),
73
+ box("mdia", box("mdhd", mdhdBody))
74
+ ]));
75
+ }
76
+
77
+ /**
78
+ * @param {number} trackId
79
+ * @returns {Buffer}
80
+ */
81
+ function traf(trackId) {
82
+ const tfhdBody = Buffer.alloc(8);
83
+ tfhdBody.writeUInt32BE(trackId, 4);
84
+ const tfdtBody = Buffer.alloc(12);
85
+ tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
86
+ tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
87
+ return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
88
+ }
89
+
90
+ /**
91
+ * A piece shaped like the `segment` muxer's output: header, two fragments and a
92
+ * trailing random-access index, all in one file.
93
+ *
94
+ * @param {number} offsetSeconds
95
+ * @returns {Buffer}
96
+ */
97
+ function selfContainedPiece(offsetSeconds) {
98
+ const mvhdBody = Buffer.alloc(100);
99
+ mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
100
+ const moov = box("moov", Buffer.concat([
101
+ box("mvhd", mvhdBody),
102
+ trak(1, VIDEO_TIMESCALE, offsetSeconds),
103
+ trak(2, AUDIO_TIMESCALE, offsetSeconds)
104
+ ]));
105
+ const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
106
+ const mdat = box("mdat", Buffer.alloc(64, 0x5a));
107
+ const mfra = box("mfra", Buffer.alloc(24, 0));
108
+ return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
109
+ }
110
+
111
+ /**
112
+ * A manager holding one session whose segments are already on disk, cut at
113
+ * explicit times — the ordinary keyframe-cut path.
114
+ *
115
+ * @param {{ segmentFormat?: object }} [overrides]
116
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
117
+ */
118
+ async function managerWithReadySegment(overrides = {}) {
119
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
120
+ const piece = selfContainedPiece(SEGMENT_START_SECONDS);
121
+ // Two segments, because a piece is only finished once the next one exists.
122
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
123
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
124
+
125
+ const manager = new HlsSessionManager({
126
+ enabled: true,
127
+ ffmpegBin: "ffmpeg",
128
+ localBindHost: "127.0.0.1",
129
+ localPort: 9090
130
+ });
131
+ const session = {
132
+ id: SESSION_ID,
133
+ dirPath,
134
+ state: "ready",
135
+ fileName: "video.mkv",
136
+ startedAt: Date.now(),
137
+ createEntryMs: Date.now(),
138
+ lastAccessedAt: Date.now(),
139
+ ffmpeg: null,
140
+ lastError: "",
141
+ consumers: new Set(),
142
+ segmentFormat: overrides.segmentFormat ?? fmp4Format,
143
+ usesExplicitCuts: true,
144
+ useSyntheticPlaylist: true,
145
+ playlistText: "#EXTM3U\n",
146
+ segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
147
+ initBytes: fmp4Format.extractInit(piece),
148
+ encodeStartIndex: 0,
149
+ firstSegmentLogged: false,
150
+ waitEpoch: 0
151
+ };
152
+ manager.sessionsById.set(SESSION_ID, session);
153
+ return { manager, session, dirPath };
154
+ }
155
+
156
+ test("a segment that exists is served, not reported as still being produced", async (t) => {
157
+ const { manager, dirPath } = await managerWithReadySegment();
158
+ t.after(async () => {
159
+ await manager.disposeAll();
160
+ await rm(dirPath, { recursive: true, force: true });
161
+ });
162
+
163
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
164
+
165
+ assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
166
+ assert.equal(result.contentType, fmp4Format.segmentContentType);
167
+
168
+ const chunks = [];
169
+ for await (const chunk of result.stream) {
170
+ chunks.push(chunk);
171
+ }
172
+ const served = Buffer.concat(chunks);
173
+ assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
174
+
175
+ // The position the PIECE states, carried into the fragment it belongs to.
176
+ // Reading it is the step that threw in 2.9.124.
177
+ assert.equal(
178
+ Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
179
+ Math.round(SEGMENT_START_SECONDS * VIDEO_TIMESCALE),
180
+ "the segment must be stamped with where it really begins"
181
+ );
182
+ });
183
+
184
+ test("a fault while preparing an existing segment is named, not turned into a wait", async (t) => {
185
+ const broken = {
186
+ ...fmp4Format,
187
+ readSegmentStartSeconds() {
188
+ throw new ReferenceError("readSelfContainedStartSeconds is not defined");
189
+ }
190
+ };
191
+ const { manager, dirPath } = await managerWithReadySegment({ segmentFormat: broken });
192
+ t.after(async () => {
193
+ await manager.disposeAll();
194
+ await rm(dirPath, { recursive: true, force: true });
195
+ });
196
+
197
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
198
+
199
+ assert.equal(
200
+ result.kind,
201
+ "failed",
202
+ "answering 'warming-up' hides the fault and holds every request until the viewer gives up"
203
+ );
204
+ assert.match(result.message, /readSelfContainedStartSeconds is not defined/);
205
+ });