@torrent-tv/proxy 2.9.141 → 2.11.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.
- package/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +17 -0
- package/routes/transcode/session-file/get.js +18 -0
- package/routes/transcode/variant-file/get.js +47 -0
- package/server.js +7 -0
- package/services/container-index/index.js +10 -6
- package/services/hls-session-manager.js +4962 -4183
- package/services/hwaccel.js +55 -16
- package/test/behind-head-repair.test.js +187 -0
- package/test/keyframe-index-accuracy.test.js +68 -0
- package/test/quality-variants.test.js +390 -0
- package/test/segment-serve-wiring.test.js +50 -34
|
@@ -0,0 +1,390 @@
|
|
|
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 variants when its cut grid is real", 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
|
+
// A copy is cut at the source's own keyframes — it has no other choice. A
|
|
143
|
+
// re-encoded rung CAN be cut there too, by being told those times, and then
|
|
144
|
+
// its segments cover the same spans and can stand in the copy's place.
|
|
145
|
+
base.transcodeVideo = false;
|
|
146
|
+
base.cutGrid = "keyframe";
|
|
147
|
+
|
|
148
|
+
const master = manager.buildMasterPlaylist(BASE_ID);
|
|
149
|
+
|
|
150
|
+
assert.ok(master, "the obstacle was never the encoder, it was the cut points");
|
|
151
|
+
assert.match(master, /^v\/1080\/index\.m3u8$/m, "the copy itself is the top rung — no encoder, no cost");
|
|
152
|
+
assert.match(master, /^v\/540\/index\.m3u8$/m);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a copied video with no readable keyframe index is offered nothing", async (t) => {
|
|
156
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
157
|
+
t.after(async () => {
|
|
158
|
+
await manager.disposeAll();
|
|
159
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
160
|
+
});
|
|
161
|
+
// Its playlist claims an even grid that ffmpeg does not cut on. Aligning a
|
|
162
|
+
// rung to that is aligning it to a fiction.
|
|
163
|
+
base.transcodeVideo = false;
|
|
164
|
+
base.cutGrid = "uniform";
|
|
165
|
+
|
|
166
|
+
assert.equal(manager.buildMasterPlaylist(BASE_ID), null);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("the session's own height resolves to the session itself", async (t) => {
|
|
170
|
+
const { manager, dirPath } = await managerWithBase();
|
|
171
|
+
t.after(async () => {
|
|
172
|
+
await manager.disposeAll();
|
|
173
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
assert.deepEqual(
|
|
177
|
+
await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
|
|
178
|
+
{ sessionId: BASE_ID },
|
|
179
|
+
"an encoder is already producing this height; making a second one would be a cold start for nothing"
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a height the master does not offer is refused", async (t) => {
|
|
184
|
+
const { manager, dirPath } = await managerWithBase();
|
|
185
|
+
t.after(async () => {
|
|
186
|
+
await manager.disposeAll();
|
|
187
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
await manager.resolveVariantFile(BASE_ID, 999, "index.m3u8"),
|
|
192
|
+
{ sessionId: null },
|
|
193
|
+
"honouring an arbitrary height would let a client start encoder runs at will"
|
|
194
|
+
);
|
|
195
|
+
assert.deepEqual(
|
|
196
|
+
await manager.resolveVariantFile(BASE_ID, 540, "master.m3u8"),
|
|
197
|
+
{ sessionId: null },
|
|
198
|
+
"a master under a variant would describe variants of a variant"
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a variant's playlist is answered without starting an encoder for it", async (t) => {
|
|
203
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
204
|
+
t.after(async () => {
|
|
205
|
+
await manager.disposeAll();
|
|
206
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const resolved = await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
|
|
210
|
+
|
|
211
|
+
assert.deepEqual(
|
|
212
|
+
resolved,
|
|
213
|
+
{ sessionId: BASE_ID },
|
|
214
|
+
"every variant of a file has the same media playlist — that is what makes them interchangeable"
|
|
215
|
+
);
|
|
216
|
+
assert.equal(
|
|
217
|
+
base.variants,
|
|
218
|
+
undefined,
|
|
219
|
+
"the player fetches a level's playlist to decide with, and may never switch to it"
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a segment request hands the encoder to the variant the viewer moved to", async (t) => {
|
|
224
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
225
|
+
t.after(async () => {
|
|
226
|
+
await manager.disposeAll();
|
|
227
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
228
|
+
});
|
|
229
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
230
|
+
variant.variantHeight = 540;
|
|
231
|
+
variant.variantBases = new Set([BASE_ID]);
|
|
232
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
233
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
234
|
+
// The viewer is a hundred seconds in, and the base is the one encoding.
|
|
235
|
+
base.lastRequestedSegment = 25;
|
|
236
|
+
const encoder = fakeEncoder();
|
|
237
|
+
base.ffmpeg = encoder;
|
|
238
|
+
|
|
239
|
+
const served = await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4");
|
|
240
|
+
|
|
241
|
+
assert.equal(served.sessionId, VARIANT_ID, "the file must be served from the variant, not the base");
|
|
242
|
+
assert.equal(base.activeVariantId, VARIANT_ID, "the variant the viewer is watching is the active one");
|
|
243
|
+
assert.equal(
|
|
244
|
+
encoder.signals.join(","),
|
|
245
|
+
"SIGTERM",
|
|
246
|
+
"the rung nobody is watching must not go on using the host's one encoder"
|
|
247
|
+
);
|
|
248
|
+
assert.equal(base.ffmpeg, null, "a deliberate stop must not read as a run that died");
|
|
249
|
+
assert.equal(
|
|
250
|
+
variant.seekTarget,
|
|
251
|
+
24,
|
|
252
|
+
"a segment request steers nothing, so the variant has to be pointed at the viewer explicitly " +
|
|
253
|
+
"(one segment back, for the preceding keyframe)"
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a rung is placed where the player asked it for, not where the other rung had read to", async (t) => {
|
|
258
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
259
|
+
t.after(async () => {
|
|
260
|
+
await manager.disposeAll();
|
|
261
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
262
|
+
});
|
|
263
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
264
|
+
variant.variantHeight = 540;
|
|
265
|
+
variant.variantBases = new Set([BASE_ID]);
|
|
266
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
267
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
268
|
+
base.ffmpeg = fakeEncoder();
|
|
269
|
+
// The rung being left had read fourteen segments further than the picture had
|
|
270
|
+
// played — an encoder running at several times realtime fills the buffer far
|
|
271
|
+
// ahead. Measured 2026-08-11: 56 s of gap, and using the read head placed the
|
|
272
|
+
// new run past everything the player then asked for, which no request could
|
|
273
|
+
// ever be answered from.
|
|
274
|
+
base.lastRequestedSegment = 70;
|
|
275
|
+
base.viewerPositionSeconds = 280;
|
|
276
|
+
|
|
277
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00056.mp4");
|
|
278
|
+
|
|
279
|
+
assert.equal(
|
|
280
|
+
variant.seekTarget,
|
|
281
|
+
55,
|
|
282
|
+
"the segment the player asked this rung for is where it must begin (one back for the keyframe)"
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("the viewer's position is kept current by the segments they ask for", async (t) => {
|
|
287
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
288
|
+
t.after(async () => {
|
|
289
|
+
await manager.disposeAll();
|
|
290
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
291
|
+
});
|
|
292
|
+
// A seek an hour ago is the only thing that ever wrote this field, and
|
|
293
|
+
// playback reports no position at all. Read as it stood, a quality change
|
|
294
|
+
// would place the new variant's encode run back at the seek — and since a
|
|
295
|
+
// segment request steers nothing, the segments the player then asks for would
|
|
296
|
+
// never be produced by anyone.
|
|
297
|
+
base.viewerPositionSeconds = 40;
|
|
298
|
+
|
|
299
|
+
await manager.getFileStream(BASE_ID, "segment-00090.mp4", { requestSeq: 1 });
|
|
300
|
+
|
|
301
|
+
assert.equal(
|
|
302
|
+
base.viewerPositionSeconds,
|
|
303
|
+
360,
|
|
304
|
+
"a request for segment #90 of a four-second grid says where the viewer is now"
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a playlist or an init segment does not move the encoder", async (t) => {
|
|
309
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
310
|
+
t.after(async () => {
|
|
311
|
+
await manager.disposeAll();
|
|
312
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
313
|
+
});
|
|
314
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
315
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
316
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
317
|
+
base.ffmpeg = fakeEncoder();
|
|
318
|
+
|
|
319
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
320
|
+
await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
|
|
321
|
+
await manager.resolveVariantFile(BASE_ID, 540, "init.mp4");
|
|
322
|
+
|
|
323
|
+
assert.notEqual(
|
|
324
|
+
base.activeVariantId,
|
|
325
|
+
VARIANT_ID,
|
|
326
|
+
"hls.js fetches a level's playlist and init to decide with, and may never switch to it"
|
|
327
|
+
);
|
|
328
|
+
assert.ok(base.ffmpeg, "the stream on screen must keep its encoder while the player is only looking");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a downshift does not rename the variant the viewer is watching", async (t) => {
|
|
332
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
333
|
+
t.after(async () => {
|
|
334
|
+
await manager.disposeAll();
|
|
335
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
336
|
+
});
|
|
337
|
+
// The player fetched the master once and addresses this variant as 812p for
|
|
338
|
+
// the rest of the session. The realtime budget then finds the host cannot
|
|
339
|
+
// keep up and steps the encode down — inside this variant, which is what it
|
|
340
|
+
// has always done.
|
|
341
|
+
assert.equal(manager.variantHeightOf(base), 812);
|
|
342
|
+
base.encodeHeight = 540;
|
|
343
|
+
|
|
344
|
+
assert.equal(
|
|
345
|
+
manager.variantHeightOf(base),
|
|
346
|
+
812,
|
|
347
|
+
"the name stays; renaming it would leave the player addressing a variant nobody answers for"
|
|
348
|
+
);
|
|
349
|
+
assert.deepEqual(
|
|
350
|
+
await manager.resolveVariantFile(BASE_ID, 812, "segment-00000.mp4"),
|
|
351
|
+
{ sessionId: BASE_ID },
|
|
352
|
+
"a second session at the height the host just failed to manage is the opposite of what a downshift is for"
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("the cut grid follows the grid asked for, not who produces the frames", () => {
|
|
357
|
+
// Why a segment from one encoder can stand where another's would have: both
|
|
358
|
+
// are cut at the same times. Which times is a property of the SESSION — the
|
|
359
|
+
// even grid, or the source's own keyframes — and it must not be re-derived
|
|
360
|
+
// from whether the video is copied, because a variant of a copied stream is
|
|
361
|
+
// re-encoded and still has to land on the copy's cuts.
|
|
362
|
+
const shape = { durationSeconds: 100, segDur: SEGMENT_SECONDS, startTime: 0 };
|
|
363
|
+
const keyframeTimes = [0, 3.1, 9.7, 14.2, 21.5, 40, 61.25];
|
|
364
|
+
|
|
365
|
+
const even = computeSegmentBoundaries({ ...shape, useKeyframeGrid: false, keyframeTimes });
|
|
366
|
+
assert.equal(even[1], SEGMENT_SECONDS, "the even grid ignores the source's keyframes");
|
|
367
|
+
assert.equal(even.at(-1), 100);
|
|
368
|
+
|
|
369
|
+
const source = computeSegmentBoundaries({ ...shape, useKeyframeGrid: true, keyframeTimes });
|
|
370
|
+
assert.deepEqual(
|
|
371
|
+
source,
|
|
372
|
+
[0, 9.7, 14.2, 21.5, 40, 61.25, 100],
|
|
373
|
+
"the source's own keyframes, kept only where they are at least a segment apart"
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// The one that matters: a copy and a re-encoded rung of it, given the same
|
|
377
|
+
// grid, produce the SAME table. Segment N then covers the same span in both,
|
|
378
|
+
// which is what lets one stand where the other would have.
|
|
379
|
+
assert.deepEqual(
|
|
380
|
+
computeSegmentBoundaries({ ...shape, useKeyframeGrid: true, keyframeTimes }),
|
|
381
|
+
source,
|
|
382
|
+
"a variant inherits the grid, so its boundaries are the same values"
|
|
383
|
+
);
|
|
384
|
+
// And with no index there is nothing to align to — the even grid, whoever asks.
|
|
385
|
+
assert.deepEqual(
|
|
386
|
+
computeSegmentBoundaries({ ...shape, useKeyframeGrid: true, keyframeTimes: null }),
|
|
387
|
+
even,
|
|
388
|
+
"no keyframes means no keyframe grid, however the caller asks"
|
|
389
|
+
);
|
|
390
|
+
});
|
|
@@ -153,6 +153,22 @@ async function managerWithReadySegment(overrides = {}) {
|
|
|
153
153
|
return { manager, session, dirPath };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
test("serving a segment records what its real start says about the container's index", async (t) => {
|
|
157
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
158
|
+
t.after(async () => {
|
|
159
|
+
await manager.disposeAll();
|
|
160
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
// The tally is counted in the module tests; what this pins is that serving a
|
|
163
|
+
// segment reaches it at all. A counter nothing increments reports a clean
|
|
164
|
+
// index for every file forever, which is worse than no measurement.
|
|
165
|
+
session.indexCheck = { checked: 0, disagreed: 0, maxDeviationSec: 0, firstDisagreementIndex: -1, seen: new Set() };
|
|
166
|
+
|
|
167
|
+
await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
168
|
+
|
|
169
|
+
assert.equal(session.indexCheck.checked, 1, "the boundary that was just produced must have been examined");
|
|
170
|
+
});
|
|
171
|
+
|
|
156
172
|
test("a segment that exists is served, not reported as still being produced", async (t) => {
|
|
157
173
|
const { manager, dirPath } = await managerWithReadySegment();
|
|
158
174
|
t.after(async () => {
|
|
@@ -226,37 +242,37 @@ test("a run's FIRST segment is served once the encoder has passed it, without wa
|
|
|
226
242
|
"the encoder is past this segment's end, so it is finished — the absence of a next one says nothing"
|
|
227
243
|
);
|
|
228
244
|
});
|
|
229
|
-
|
|
230
|
-
test("a segment is found in the run directory that produced it, newest run first", async (t) => {
|
|
231
|
-
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
232
|
-
t.after(async () => {
|
|
233
|
-
await manager.disposeAll();
|
|
234
|
-
await rm(dirPath, { recursive: true, force: true });
|
|
235
|
-
});
|
|
236
|
-
// Runs write into a directory each — that is what lets a restart begin
|
|
237
|
-
// without waiting for its predecessor to die, which measured 0.7-1.3 s of
|
|
238
|
-
// every seek. A later run's answer supersedes an earlier one's, because the
|
|
239
|
-
// older file may be the truncated output of a run that was killed mid-write.
|
|
240
|
-
const { mkdir } = await import("node:fs/promises");
|
|
241
|
-
const piece = selfContainedPiece(SEGMENT_START_SECONDS);
|
|
242
|
-
await mkdir(path.join(dirPath, "run-1"), { recursive: true });
|
|
243
|
-
await mkdir(path.join(dirPath, "run-2"), { recursive: true });
|
|
244
|
-
await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
|
|
245
|
-
await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
|
|
246
|
-
await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
|
|
247
|
-
await rm(path.join(dirPath, "segment-00000.mp4"));
|
|
248
|
-
await rm(path.join(dirPath, "segment-00001.mp4"));
|
|
249
|
-
session.encodeStartIndex = 0;
|
|
250
|
-
|
|
251
|
-
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
252
|
-
|
|
253
|
-
assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
|
|
254
|
-
const chunks = [];
|
|
255
|
-
for await (const chunk of result.stream) {
|
|
256
|
-
chunks.push(chunk);
|
|
257
|
-
}
|
|
258
|
-
assert.ok(
|
|
259
|
-
Buffer.concat(chunks).length > 8,
|
|
260
|
-
"the newest run's output must win — the older file here is the 8-byte stub a killed run leaves"
|
|
261
|
-
);
|
|
262
|
-
});
|
|
245
|
+
|
|
246
|
+
test("a segment is found in the run directory that produced it, newest run first", async (t) => {
|
|
247
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
248
|
+
t.after(async () => {
|
|
249
|
+
await manager.disposeAll();
|
|
250
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
251
|
+
});
|
|
252
|
+
// Runs write into a directory each — that is what lets a restart begin
|
|
253
|
+
// without waiting for its predecessor to die, which measured 0.7-1.3 s of
|
|
254
|
+
// every seek. A later run's answer supersedes an earlier one's, because the
|
|
255
|
+
// older file may be the truncated output of a run that was killed mid-write.
|
|
256
|
+
const { mkdir } = await import("node:fs/promises");
|
|
257
|
+
const piece = selfContainedPiece(SEGMENT_START_SECONDS);
|
|
258
|
+
await mkdir(path.join(dirPath, "run-1"), { recursive: true });
|
|
259
|
+
await mkdir(path.join(dirPath, "run-2"), { recursive: true });
|
|
260
|
+
await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
|
|
261
|
+
await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
|
|
262
|
+
await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
|
|
263
|
+
await rm(path.join(dirPath, "segment-00000.mp4"));
|
|
264
|
+
await rm(path.join(dirPath, "segment-00001.mp4"));
|
|
265
|
+
session.encodeStartIndex = 0;
|
|
266
|
+
|
|
267
|
+
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
268
|
+
|
|
269
|
+
assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
|
|
270
|
+
const chunks = [];
|
|
271
|
+
for await (const chunk of result.stream) {
|
|
272
|
+
chunks.push(chunk);
|
|
273
|
+
}
|
|
274
|
+
assert.ok(
|
|
275
|
+
Buffer.concat(chunks).length > 8,
|
|
276
|
+
"the newest run's output must win — the older file here is the 8-byte stub a killed run leaves"
|
|
277
|
+
);
|
|
278
|
+
});
|