@torrent-tv/proxy 2.9.141 → 2.10.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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * @file Quality variants: the master playlist, and what happens when the viewer
3
+ * moves between rungs.
4
+ *
5
+ * What makes a mid-stream change of quality possible at all is that every
6
+ * variant is cut at the SAME times, so a segment produced by one encoder can be
7
+ * appended where another encoder's would have gone. The last test here pins
8
+ * that property at its source; the rest cover the wiring that a route reaches —
9
+ * a module test that imports a function directly cannot see a caller that never
10
+ * calls it (2.9.124).
11
+ */
12
+
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtemp, rm } from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { computeSegmentBoundaries, HlsSessionManager } from "../services/hls-session-manager.js";
19
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
20
+
21
+ const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
22
+ const VARIANT_ID = "11111111-2222-3333-4444-555555555555";
23
+ const SEGMENT_SECONDS = 4;
24
+
25
+ /**
26
+ * A session shaped like a live one, without the ffmpeg run behind it.
27
+ *
28
+ * @param {{ id: string, encodeHeight: number, dirPath: string, transcodeVideo?: boolean }} params
29
+ * @returns {object}
30
+ */
31
+ function fakeSession({ id, encodeHeight, dirPath, transcodeVideo = true }) {
32
+ return {
33
+ id,
34
+ dirPath,
35
+ state: "ready",
36
+ fileName: "video.mkv",
37
+ startedAt: Date.now(),
38
+ createEntryMs: Date.now(),
39
+ lastAccessedAt: Date.now(),
40
+ ffmpeg: null,
41
+ lastError: "",
42
+ consumers: new Set(),
43
+ segmentFormat: fmp4Format,
44
+ transcodeVideo,
45
+ transcodeAudio: true,
46
+ audioTrackIndex: 0,
47
+ sourceKey: "source-1",
48
+ fileIndex: 0,
49
+ sourceWidth: 1920,
50
+ sourceHeight: 1080,
51
+ encodeWidth: 0,
52
+ encodeHeight,
53
+ encodeRunGeneration: 0,
54
+ encodeStartIndex: 0,
55
+ lastRestartAt: 0,
56
+ seekFailureTarget: -1,
57
+ seekFailureCount: 0,
58
+ seekSettleTimer: null,
59
+ seekTarget: null,
60
+ waitEpoch: 0,
61
+ usesExplicitCuts: false,
62
+ useSyntheticPlaylist: true,
63
+ playlistText: "#EXTM3U\n",
64
+ segmentBoundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
65
+ segmentCount: 100,
66
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
67
+ };
68
+ }
69
+
70
+ /**
71
+ * A stand-in for a running ffmpeg: enough of a child process for the signals to
72
+ * be recorded and for teardown to await its exit.
73
+ *
74
+ * @returns {{ pid: number, exitCode: number | null, signalCode: string | null, signals: string[], kill: (signal: string) => void, once: (event: string, handler: () => void) => void }}
75
+ */
76
+ function fakeEncoder() {
77
+ const signals = [];
78
+ return {
79
+ pid: 1234,
80
+ exitCode: null,
81
+ signalCode: null,
82
+ signals,
83
+ kill(signal) {
84
+ signals.push(signal);
85
+ },
86
+ once(event, handler) {
87
+ if (event === "exit") {
88
+ handler();
89
+ }
90
+ }
91
+ };
92
+ }
93
+
94
+ /**
95
+ * @returns {Promise<{ manager: HlsSessionManager, base: object, dirPath: string }>}
96
+ */
97
+ async function managerWithBase() {
98
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "quality-variants-"));
99
+ const manager = new HlsSessionManager({
100
+ enabled: true,
101
+ ffmpegBin: "ffmpeg",
102
+ localBindHost: "127.0.0.1",
103
+ localPort: 9090
104
+ });
105
+ // 812p is what a viewport-sized budget actually produces — deliberately not a
106
+ // ladder rung, because that is the case the master has to carry.
107
+ const base = fakeSession({ id: BASE_ID, encodeHeight: 812, dirPath });
108
+ manager.sessionsById.set(BASE_ID, base);
109
+ return { manager, base, dirPath };
110
+ }
111
+
112
+ test("the master offers every rung, the session's own height among them", async (t) => {
113
+ const { manager, dirPath } = await managerWithBase();
114
+ t.after(async () => {
115
+ await manager.disposeAll();
116
+ await rm(dirPath, { recursive: true, force: true });
117
+ });
118
+
119
+ const master = manager.buildMasterPlaylist(BASE_ID);
120
+
121
+ assert.ok(master, "a re-encoded 1080p source has rungs to choose between");
122
+ const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
123
+ assert.deepEqual(
124
+ heights,
125
+ [1080, 812, 720, 540, 480, 360, 240],
126
+ "the source height, the height already being encoded, and the rungs below it, largest first"
127
+ );
128
+ assert.match(master, /^#EXT-X-VERSION:7$/m, "the version the segment format requires");
129
+ assert.match(master, /RESOLUTION=1280x720/, "each variant states the size it decodes to");
130
+ assert.ok(
131
+ !master.includes("2160"),
132
+ "a rung above the source would be upscaling — invented detail at a higher cost than the source"
133
+ );
134
+ });
135
+
136
+ test("a copied video is offered no variants", async (t) => {
137
+ const { manager, base, dirPath } = await managerWithBase();
138
+ t.after(async () => {
139
+ await manager.disposeAll();
140
+ await rm(dirPath, { recursive: true, force: true });
141
+ });
142
+ base.transcodeVideo = false;
143
+
144
+ assert.equal(
145
+ manager.buildMasterPlaylist(BASE_ID),
146
+ null,
147
+ "its segments are cut at the source's own keyframes, so a re-encoded rung cannot be spliced into it"
148
+ );
149
+ });
150
+
151
+ test("the session's own height resolves to the session itself", async (t) => {
152
+ const { manager, dirPath } = await managerWithBase();
153
+ t.after(async () => {
154
+ await manager.disposeAll();
155
+ await rm(dirPath, { recursive: true, force: true });
156
+ });
157
+
158
+ assert.deepEqual(
159
+ await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
160
+ { sessionId: BASE_ID },
161
+ "an encoder is already producing this height; making a second one would be a cold start for nothing"
162
+ );
163
+ });
164
+
165
+ test("a height the master does not offer is refused", async (t) => {
166
+ const { manager, dirPath } = await managerWithBase();
167
+ t.after(async () => {
168
+ await manager.disposeAll();
169
+ await rm(dirPath, { recursive: true, force: true });
170
+ });
171
+
172
+ assert.deepEqual(
173
+ await manager.resolveVariantFile(BASE_ID, 999, "index.m3u8"),
174
+ { sessionId: null },
175
+ "honouring an arbitrary height would let a client start encoder runs at will"
176
+ );
177
+ assert.deepEqual(
178
+ await manager.resolveVariantFile(BASE_ID, 540, "master.m3u8"),
179
+ { sessionId: null },
180
+ "a master under a variant would describe variants of a variant"
181
+ );
182
+ });
183
+
184
+ test("a variant's playlist is answered without starting an encoder for it", async (t) => {
185
+ const { manager, base, dirPath } = await managerWithBase();
186
+ t.after(async () => {
187
+ await manager.disposeAll();
188
+ await rm(dirPath, { recursive: true, force: true });
189
+ });
190
+
191
+ const resolved = await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
192
+
193
+ assert.deepEqual(
194
+ resolved,
195
+ { sessionId: BASE_ID },
196
+ "every variant of a file has the same media playlist — that is what makes them interchangeable"
197
+ );
198
+ assert.equal(
199
+ base.variants,
200
+ undefined,
201
+ "the player fetches a level's playlist to decide with, and may never switch to it"
202
+ );
203
+ });
204
+
205
+ test("a segment request hands the encoder to the variant the viewer moved to", async (t) => {
206
+ const { manager, base, dirPath } = await managerWithBase();
207
+ t.after(async () => {
208
+ await manager.disposeAll();
209
+ await rm(dirPath, { recursive: true, force: true });
210
+ });
211
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
212
+ variant.variantHeight = 540;
213
+ variant.variantBases = new Set([BASE_ID]);
214
+ manager.sessionsById.set(VARIANT_ID, variant);
215
+ base.variants = new Map([[540, VARIANT_ID]]);
216
+ // The viewer is a hundred seconds in, and the base is the one encoding.
217
+ base.lastRequestedSegment = 25;
218
+ const encoder = fakeEncoder();
219
+ base.ffmpeg = encoder;
220
+
221
+ const served = await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4");
222
+
223
+ assert.equal(served.sessionId, VARIANT_ID, "the file must be served from the variant, not the base");
224
+ assert.equal(base.activeVariantId, VARIANT_ID, "the variant the viewer is watching is the active one");
225
+ assert.equal(
226
+ encoder.signals.join(","),
227
+ "SIGTERM",
228
+ "the rung nobody is watching must not go on using the host's one encoder"
229
+ );
230
+ assert.equal(base.ffmpeg, null, "a deliberate stop must not read as a run that died");
231
+ assert.equal(
232
+ variant.seekTarget,
233
+ 24,
234
+ "a segment request steers nothing, so the variant has to be pointed at the viewer explicitly " +
235
+ "(one segment back, for the preceding keyframe)"
236
+ );
237
+ });
238
+
239
+ test("the viewer's position is kept current by the segments they ask for", async (t) => {
240
+ const { manager, base, dirPath } = await managerWithBase();
241
+ t.after(async () => {
242
+ await manager.disposeAll();
243
+ await rm(dirPath, { recursive: true, force: true });
244
+ });
245
+ // A seek an hour ago is the only thing that ever wrote this field, and
246
+ // playback reports no position at all. Read as it stood, a quality change
247
+ // would place the new variant's encode run back at the seek — and since a
248
+ // segment request steers nothing, the segments the player then asks for would
249
+ // never be produced by anyone.
250
+ base.viewerPositionSeconds = 40;
251
+
252
+ await manager.getFileStream(BASE_ID, "segment-00090.mp4", { requestSeq: 1 });
253
+
254
+ assert.equal(
255
+ base.viewerPositionSeconds,
256
+ 360,
257
+ "a request for segment #90 of a four-second grid says where the viewer is now"
258
+ );
259
+ });
260
+
261
+ test("a playlist or an init segment does not move the encoder", async (t) => {
262
+ const { manager, base, dirPath } = await managerWithBase();
263
+ t.after(async () => {
264
+ await manager.disposeAll();
265
+ await rm(dirPath, { recursive: true, force: true });
266
+ });
267
+ const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
268
+ manager.sessionsById.set(VARIANT_ID, variant);
269
+ base.variants = new Map([[540, VARIANT_ID]]);
270
+ base.ffmpeg = fakeEncoder();
271
+
272
+ base.variants = new Map([[540, VARIANT_ID]]);
273
+ await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
274
+ await manager.resolveVariantFile(BASE_ID, 540, "init.mp4");
275
+
276
+ assert.notEqual(
277
+ base.activeVariantId,
278
+ VARIANT_ID,
279
+ "hls.js fetches a level's playlist and init to decide with, and may never switch to it"
280
+ );
281
+ assert.ok(base.ffmpeg, "the stream on screen must keep its encoder while the player is only looking");
282
+ });
283
+
284
+ test("a downshift does not rename the variant the viewer is watching", async (t) => {
285
+ const { manager, base, dirPath } = await managerWithBase();
286
+ t.after(async () => {
287
+ await manager.disposeAll();
288
+ await rm(dirPath, { recursive: true, force: true });
289
+ });
290
+ // The player fetched the master once and addresses this variant as 812p for
291
+ // the rest of the session. The realtime budget then finds the host cannot
292
+ // keep up and steps the encode down — inside this variant, which is what it
293
+ // has always done.
294
+ assert.equal(manager.variantHeightOf(base), 812);
295
+ base.encodeHeight = 540;
296
+
297
+ assert.equal(
298
+ manager.variantHeightOf(base),
299
+ 812,
300
+ "the name stays; renaming it would leave the player addressing a variant nobody answers for"
301
+ );
302
+ assert.deepEqual(
303
+ await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
304
+ { sessionId: BASE_ID },
305
+ "a second session at the height the host just failed to manage is the opposite of what a downshift is for"
306
+ );
307
+ });
308
+
309
+ test("the cut grid does not depend on the encode height", () => {
310
+ // Why a segment from one encoder can stand where another's would have: on the
311
+ // re-encode path the boundaries are a uniform grid and the encoder is given a
312
+ // fixed GOP of the same length, so segment N covers the same span at every
313
+ // rung. Nothing about the height enters this — and nothing may, or the
314
+ // variants stop being interchangeable.
315
+ const shape = { transcodeVideo: true, durationSeconds: 100, segDur: SEGMENT_SECONDS, startTime: 0 };
316
+ const withIndex = computeSegmentBoundaries({ ...shape, keyframeTimes: [0, 3.1, 9.7, 14.2] });
317
+ const withoutIndex = computeSegmentBoundaries({ ...shape, keyframeTimes: null });
318
+
319
+ assert.deepEqual(
320
+ withIndex,
321
+ withoutIndex,
322
+ "a re-encode forces its own keyframes onto the grid, so the source's keyframes cannot move the cuts"
323
+ );
324
+ assert.equal(withIndex[1], SEGMENT_SECONDS);
325
+ assert.equal(withIndex.at(-1), 100);
326
+ });