@torrent-tv/proxy 2.9.129 → 2.9.131
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 +8 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +19 -3
- package/test/segment-serve-wiring.test.js +228 -205
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.131
|
|
2
|
+
|
|
3
|
+
- **Fix**: The first segment of an encoder run is served once the encoder has passed it, instead of waiting for a successor nobody is producing. A segment counted as finished only when the NEXT one had been started — sound while a run moves forward through a file, and meaningless for the segment a run BEGINS at, because the run has only just arrived there. That is precisely the segment a resume or a seek depends on. Measured 2026-08-09 with the hold instrument: `#807 exists, but the next segment (#808) has not been started yet`, held while it lay complete on disk; in August the same shape held `#317` for 46 s and then answered 404 to a browser that had already given up, three releases in a row. A run whose reported output position is past a segment's end has necessarily closed that segment, so that is what decides it now. Covered by a test that builds exactly the resume shape — a run start with no successor on disk — and insists on the bytes.
|
|
4
|
+
|
|
5
|
+
## 2.9.130
|
|
6
|
+
|
|
7
|
+
- **New**: A segment being held says WHY, at most once every five seconds per file. A hold was silent, and that silence has now cost three releases: a file that exists, a route answering "not yet", and nothing anywhere naming which of the several reasons applied. Measured 2026-08-09 — a run begun mid-file at segment #317 produced two minutes of video from #317 upwards at 10.5x, while #317 itself was held 46 s and then answered 404 once the browser had given up, with not one line about the cause. The line names the reason, the segment the run began at, where the viewer is, whether the encoder is alive, and which index was asked for. The case that matters most is called out on its own: the first segment of a run started mid-file is held by a rule that waits for the NEXT one to exist, and that is exactly the segment a resume depends on.
|
|
8
|
+
|
|
1
9
|
## 2.9.129
|
|
2
10
|
|
|
3
11
|
- **New**: A held segment says why it is held. A hold was silent, and that silence has now cost three releases: a file that exists, a route answering "not yet", and nothing saying which of the several reasons applied. Measured 2026-08-09 — a run begun mid-file at segment #317 produced two minutes of video from #317 upwards at 10.5x while #317 itself was held for 46 116 ms and then answered 404, once the browser had already given up. At most once every five seconds per file it now names the reason (not on disk, or present but the next segment has not been started), together with the index the run began at, where the viewer is, and whether the encoder is alive. The readiness rule — a segment counts as finished once the NEXT one exists — is the suspect for a resume, because for the first segment of a mid-file run that rule decides whether playback begins at all.
|
package/package.json
CHANGED
|
@@ -3404,12 +3404,27 @@ export class HlsSessionManager {
|
|
|
3404
3404
|
const isLast = index >= Math.max(0, (session.segmentBoundaries?.length ?? 1) - 2);
|
|
3405
3405
|
if (!isLast && session.ffmpeg) {
|
|
3406
3406
|
const nextPath = path.join(session.dirPath, session.segmentFormat.segmentFileName(index + 1));
|
|
3407
|
+
// The FIRST segment of a run cannot be judged by "the next one
|
|
3408
|
+
// exists": nothing is producing a next one yet, because the run has
|
|
3409
|
+
// only just begun here. Waiting for it holds precisely the segment a
|
|
3410
|
+
// resume depends on — measured 2026-08-09, #807 held while it lay on
|
|
3411
|
+
// disk, and in August the same shape held #317 for 46 s and then
|
|
3412
|
+
// answered 404 to a browser that had given up.
|
|
3413
|
+
//
|
|
3414
|
+
// What "finished" means for it is that the encoder has moved PAST the
|
|
3415
|
+
// end of its span. ffmpeg reports the output timestamp of its last
|
|
3416
|
+
// encoded frame, so a run whose position is beyond this segment's end
|
|
3417
|
+
// has necessarily closed it.
|
|
3418
|
+
const isRunStart = index === (session.encodeStartIndex ?? -1);
|
|
3419
|
+
const encoderPosition = Number(session.progress?.processedSeconds);
|
|
3420
|
+
const segmentEnd = this.#segmentStartTime(session, index + 1);
|
|
3421
|
+
if (isRunStart && Number.isFinite(encoderPosition) && encoderPosition > segmentEnd) {
|
|
3422
|
+
// Past its end — it is complete, whatever the directory says about
|
|
3423
|
+
// what comes next.
|
|
3424
|
+
} else {
|
|
3407
3425
|
try {
|
|
3408
3426
|
await access(nextPath);
|
|
3409
3427
|
} catch {
|
|
3410
|
-
// The rule is "finished once the NEXT one has been started". For the
|
|
3411
|
-
// FIRST segment of a run begun mid-file this is the one that decides
|
|
3412
|
-
// whether a resume plays at all, so it must say so out loud.
|
|
3413
3428
|
this.#explainHold(
|
|
3414
3429
|
session,
|
|
3415
3430
|
fileName,
|
|
@@ -3417,6 +3432,7 @@ export class HlsSessionManager {
|
|
|
3417
3432
|
);
|
|
3418
3433
|
return { kind: "warming-up" };
|
|
3419
3434
|
}
|
|
3435
|
+
}
|
|
3420
3436
|
}
|
|
3421
3437
|
}
|
|
3422
3438
|
// Cold-start: log the first servable SEGMENT of this session exactly once
|
|
@@ -1,205 +1,228 @@
|
|
|
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
|
-
});
|
|
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
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a run's FIRST segment is served once the encoder has passed it, without waiting for a next one", async (t) => {
|
|
208
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
209
|
+
t.after(async () => {
|
|
210
|
+
await manager.disposeAll();
|
|
211
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
212
|
+
});
|
|
213
|
+
// The shape a resume takes: a run begun mid-file, so its first segment has no
|
|
214
|
+
// successor and nothing is producing one. Waiting for that successor is what
|
|
215
|
+
// held #317 for 46 s and then answered 404 to a browser that had given up.
|
|
216
|
+
await rm(path.join(dirPath, "segment-00001.mp4"));
|
|
217
|
+
session.encodeStartIndex = 0;
|
|
218
|
+
session.ffmpeg = { killed: true, kill() {} };
|
|
219
|
+
session.progress = { processedSeconds: SEGMENT_START_SECONDS + 10 };
|
|
220
|
+
|
|
221
|
+
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
222
|
+
|
|
223
|
+
assert.equal(
|
|
224
|
+
result.kind,
|
|
225
|
+
"file",
|
|
226
|
+
"the encoder is past this segment's end, so it is finished — the absence of a next one says nothing"
|
|
227
|
+
);
|
|
228
|
+
});
|