@torrent-tv/proxy 2.9.123 → 2.9.125
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
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.9.125
|
|
2
|
+
|
|
3
|
+
- **Fix**: Playback works again. 2.9.124 shipped two names it never declared — `readSelfContainedStartSeconds`, called in `segment-formats/fmp4.js` and imported nowhere, and `SEGMENT_START_DISAGREEMENT_SEC` on the line after it — so preparing any segment cut at keyframes threw a `ReferenceError`. That is every ordinary file. Measured 2026-08-08: the playlist and the init segment were served, twelve finished segments lay in the session directory, and the request for segment #0 was held for 45 281 ms and then answered 404 because the browser had given up and released the session. Peers, download rate and transport were all healthy throughout, so nothing in the logs pointed anywhere near the cause.
|
|
4
|
+
- **Fix**: A fault while preparing a file that EXISTS is now reported instead of being passed off as "not produced yet". One `try` covered both the existence check and everything after it, and its `catch` meant only the first, so the exception above came out as "still warming up" — the request was held, the next poll threw the same exception, and so on until the viewer left. Nothing was logged at any point. The existence check now stands alone; a file that goes away between the check and the read still means "not ready", and anything else is logged with its stack and answered as a failure. The same split applies to the init segment.
|
|
5
|
+
- **Chore**: `npm test` runs the linter before the tests. The linter added in 2.9.103 exists precisely to catch an undeclared name, and it names both of these — it simply was not run before releasing 2.9.124, because nothing ran it.
|
|
6
|
+
- **Chore**: A test asks the session manager for a segment that exists and insists on getting the bytes back. Every unit test of the fMP4 path passed while playback was dead: they import the function straight from `mp4-boxes.js`, so the missing import in its CALLER was invisible. A second test pins that a fault in preparing an existing segment is answered as a failure, not as an endless wait.
|
|
7
|
+
|
|
8
|
+
## 2.9.124
|
|
9
|
+
|
|
10
|
+
- **Fix**: Subtitles no longer drift away from the picture. A segment was stamped with the time the PLAYLIST assigned it, and the playlist is built from the container keyframe index — which can be wrong. Measured 2026-08-06 on a Matroska file whose index claimed a keyframe at 157.99 s where the real ones were 153.820 and 164.247: ffmpeg cut at 153.820, the stamp said 157.99, and the player was told that picture belonged 4.17 s later than it did. Subtitles, extracted straight from the source with no offset of any kind, kept the true times, so speech and text sat 4.17 s apart for the whole stretch. The stamp now comes from the piece itself — the muxer records its position as an empty edit at the head of the track edit list, read before the header is stripped — and falls back to the playlist only when the piece does not say. Identical to the old figure whenever the index is honest, so a well-formed file is unaffected. When the two disagree by more than a quarter of a second the log names both, so an index that lies is visible rather than merely felt. Verified against ffmpeg on pieces cut the same way (12/30/36 s read back exactly), and covered by tests including a 64-bit edit list and a non-default movie timescale.
|
|
11
|
+
|
|
1
12
|
## 2.9.123
|
|
2
13
|
|
|
3
14
|
- **Fix**: The check added in 2.9.121 was deleting the file an encoder was writing into. A segment short of a track means one of two very different things — left behind by a run that was killed, or simply not finished yet — and treating them alike removed the file mid-write, after which ffmpeg went on writing to something nobody could open and the segment never appeared. Measured 2026-08-06: segment #225 was deleted 14 s into the run producing it and answered 404 thirty-three seconds later. The readiness rule could not prevent it, because it waves a segment through once the NEXT one exists and that next one had been left by an older run. Ownership decides it now: the current run writes from its start index upwards, so a file at or above that index while the run is alive is unfinished and is waited for, and only a file below it, or any file once no run is producing, is a leftover worth removing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.125",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"major": "npm whoami && npm version major && npm publish && git push --follow-tags",
|
|
20
20
|
"start": "node ./bin/cli.js",
|
|
21
21
|
"dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
|
|
22
|
-
"test": "node --test",
|
|
22
|
+
"test": "npm run lint && node --test",
|
|
23
23
|
"lint": "biome lint ."
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
@@ -293,6 +293,12 @@ const PROGRESS_LOG_INTERVAL_MS = 5_000;
|
|
|
293
293
|
// hashing starves the event loop in bursts, so fewer read iterations means
|
|
294
294
|
// far less time lost between chunks while serving the first segments.
|
|
295
295
|
const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
296
|
+
// How far a segment's own recorded start may sit from the one the playlist
|
|
297
|
+
// assigned it before the disagreement is worth a log line. The two are built
|
|
298
|
+
// from the same keyframe index and normally match to the sample; a quarter of a
|
|
299
|
+
// second is below any drift a viewer could notice, so anything above it is the
|
|
300
|
+
// index being wrong about where a keyframe is rather than rounding.
|
|
301
|
+
const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
|
|
296
302
|
|
|
297
303
|
/**
|
|
298
304
|
* Resolve after a given number of milliseconds.
|
|
@@ -3338,10 +3344,20 @@ export class HlsSessionManager {
|
|
|
3338
3344
|
contentType: session.segmentFormat.initContentType,
|
|
3339
3345
|
isPlaylist: false
|
|
3340
3346
|
};
|
|
3341
|
-
} catch {
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3347
|
+
} catch (error) {
|
|
3348
|
+
if (error?.code === "ENOENT") {
|
|
3349
|
+
// Not produced yet — the encode run started at session creation
|
|
3350
|
+
// writes it early; the caller long-polls until it appears.
|
|
3351
|
+
return { kind: "warming-up" };
|
|
3352
|
+
}
|
|
3353
|
+
logger.error(
|
|
3354
|
+
`transcode ${session.id} could not serve ${initFileName}: ${error?.message ?? error}` +
|
|
3355
|
+
(error?.stack ? `\n${error.stack}` : "")
|
|
3356
|
+
);
|
|
3357
|
+
return {
|
|
3358
|
+
kind: "failed",
|
|
3359
|
+
message: `Could not serve ${initFileName}: ${error?.message ?? String(error)}`
|
|
3360
|
+
};
|
|
3345
3361
|
}
|
|
3346
3362
|
}
|
|
3347
3363
|
|
|
@@ -3360,9 +3376,21 @@ export class HlsSessionManager {
|
|
|
3360
3376
|
this.#enforceLookAheadFor(session);
|
|
3361
3377
|
}
|
|
3362
3378
|
}
|
|
3379
|
+
// Whether the file is there is asked on its own, and nothing else shares
|
|
3380
|
+
// this catch. Everything below is PREPARATION of a file that exists, and a
|
|
3381
|
+
// failure there means something entirely different from "not produced yet"
|
|
3382
|
+
// — but for one release the two were caught together, so an undeclared name
|
|
3383
|
+
// in the fMP4 path read as "the segment is not ready". Every poll threw the
|
|
3384
|
+
// same ReferenceError, every poll answered "wait", and playback never began
|
|
3385
|
+
// on any file cut at keyframes (2.9.124; measured 2026-08-08: segment #0
|
|
3386
|
+
// held for 45 281 ms with twelve finished segments on disk).
|
|
3363
3387
|
try {
|
|
3364
3388
|
await access(filePath);
|
|
3365
|
-
|
|
3389
|
+
} catch {
|
|
3390
|
+
// Not produced yet.
|
|
3391
|
+
return this.#holdForProduction(session, fileName, isPlaylist, options);
|
|
3392
|
+
}
|
|
3393
|
+
try {
|
|
3366
3394
|
// Existing is not the same as finished. The `hls` muxer wrote each
|
|
3367
3395
|
// segment to a temporary name and renamed it once complete, so a file
|
|
3368
3396
|
// appearing WAS a finished segment. The `segment` muxer has no such
|
|
@@ -3453,8 +3481,34 @@ export class HlsSessionManager {
|
|
|
3453
3481
|
}
|
|
3454
3482
|
return { kind: "warming-up" };
|
|
3455
3483
|
}
|
|
3484
|
+
// Where this segment REALLY begins, taken from the piece itself, and
|
|
3485
|
+
// only from the playlist when the piece does not say.
|
|
3486
|
+
//
|
|
3487
|
+
// The playlist's own answer is built from the container's keyframe
|
|
3488
|
+
// index, and an index can be wrong: measured 2026-08-06 on a Matroska
|
|
3489
|
+
// file whose index claimed a keyframe at 157.99 s where the real ones
|
|
3490
|
+
// were 153.82 and 164.247. ffmpeg cut at 153.82, and stamping that
|
|
3491
|
+
// picture with 157.99 told the player it belonged four seconds later
|
|
3492
|
+
// than it did — while subtitles, extracted straight from the source,
|
|
3493
|
+
// kept the true times. Speech and text drifted apart by 4.17 s.
|
|
3494
|
+
//
|
|
3495
|
+
// Read from `raw`, before the header is stripped: the position lives
|
|
3496
|
+
// in an empty edit in the piece's own `moov`, which `stripInit`
|
|
3497
|
+
// removes. Identical to the playlist's figure whenever the index is
|
|
3498
|
+
// honest, so nothing changes for a well-formed file.
|
|
3499
|
+
const trueStart = session.usesExplicitCuts
|
|
3500
|
+
? session.segmentFormat.readSegmentStartSeconds?.(raw) ?? null
|
|
3501
|
+
: null;
|
|
3502
|
+
const declaredStart = this.#segmentStartTime(session, index);
|
|
3503
|
+
if (trueStart !== null && Math.abs(trueStart - declaredStart) > SEGMENT_START_DISAGREEMENT_SEC) {
|
|
3504
|
+
logger.warn(
|
|
3505
|
+
`transcode ${session.id} segment #${index} really starts at ` +
|
|
3506
|
+
`${trueStart.toFixed(3)}s, the playlist says ${declaredStart.toFixed(3)}s — ` +
|
|
3507
|
+
"the container's keyframe index disagrees with the file; using the file"
|
|
3508
|
+
);
|
|
3509
|
+
}
|
|
3456
3510
|
const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
|
|
3457
|
-
startSeconds:
|
|
3511
|
+
startSeconds: trueStart ?? declaredStart,
|
|
3458
3512
|
initBytes: session.initBytes ?? null
|
|
3459
3513
|
});
|
|
3460
3514
|
return {
|
|
@@ -3474,10 +3528,38 @@ export class HlsSessionManager {
|
|
|
3474
3528
|
: session.segmentFormat.segmentContentType,
|
|
3475
3529
|
isPlaylist
|
|
3476
3530
|
};
|
|
3477
|
-
} catch (
|
|
3478
|
-
|
|
3531
|
+
} catch (error) {
|
|
3532
|
+
if (error?.code === "ENOENT") {
|
|
3533
|
+
// The file went away between the check and the read — a leftover being
|
|
3534
|
+
// removed so it can be produced again. Means exactly what never having
|
|
3535
|
+
// existed means.
|
|
3536
|
+
return this.#holdForProduction(session, fileName, isPlaylist, options);
|
|
3537
|
+
}
|
|
3538
|
+
// Anything else is a fault in producing the answer. Name it: a request
|
|
3539
|
+
// answered "wait" for ever tells the viewer nothing and leaves no trace
|
|
3540
|
+
// of what actually happened.
|
|
3541
|
+
logger.error(
|
|
3542
|
+
`transcode ${session.id} could not serve ${fileName}: ${error?.message ?? error}` +
|
|
3543
|
+
(error?.stack ? `\n${error.stack}` : "")
|
|
3544
|
+
);
|
|
3545
|
+
return {
|
|
3546
|
+
kind: "failed",
|
|
3547
|
+
message: `Could not serve ${fileName}: ${error?.message ?? String(error)}`
|
|
3548
|
+
};
|
|
3479
3549
|
}
|
|
3550
|
+
}
|
|
3480
3551
|
|
|
3552
|
+
/**
|
|
3553
|
+
* Answer a request for a file that is not on disk: make sure the encoder is
|
|
3554
|
+
* heading for it, and hold the request.
|
|
3555
|
+
*
|
|
3556
|
+
* @param {HlsSession} session
|
|
3557
|
+
* @param {string} fileName
|
|
3558
|
+
* @param {boolean} isPlaylist
|
|
3559
|
+
* @param {{ requestSeq?: number }} options
|
|
3560
|
+
* @returns {{ kind: "warming-up" }}
|
|
3561
|
+
*/
|
|
3562
|
+
#holdForProduction(session, fileName, isPlaylist, options) {
|
|
3481
3563
|
// A segment was requested that ffmpeg has not produced yet. Decide whether
|
|
3482
3564
|
// to wait for the current encode run to reach it or to restart the encoder
|
|
3483
3565
|
// at this position (server-side seeking). The caller long-polls.
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
* See {@link SegmentFormat} in `./index.js` for the interface contract.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
readSelfContainedStartSeconds,
|
|
15
|
+
readTrackTimescales,
|
|
16
|
+
stampSegmentStartTime,
|
|
17
|
+
walkBoxes
|
|
18
|
+
} from "./mp4-boxes.js";
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* How many distinct tracks have a fragment in this segment.
|
|
@@ -189,6 +194,21 @@ export const fmp4Format = {
|
|
|
189
194
|
return match ? Number(match[1]) : -1;
|
|
190
195
|
},
|
|
191
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Where a self-contained piece says it begins, in seconds, or null.
|
|
199
|
+
*
|
|
200
|
+
* Only the pieces the `segment` muxer writes carry this — they have their
|
|
201
|
+
* own `moov` — which is exactly the path where the playlist's own answer
|
|
202
|
+
* can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
|
|
203
|
+
* removes the header the position lives in.
|
|
204
|
+
*
|
|
205
|
+
* @param {Buffer} piece
|
|
206
|
+
* @returns {number | null}
|
|
207
|
+
*/
|
|
208
|
+
readSegmentStartSeconds(piece) {
|
|
209
|
+
return readSelfContainedStartSeconds(piece);
|
|
210
|
+
},
|
|
211
|
+
|
|
192
212
|
/**
|
|
193
213
|
* fMP4 segments must be read into memory and corrected before being served —
|
|
194
214
|
* see {@link stampSegmentStartTime} for the full reasoning. Without this a
|
|
@@ -183,3 +183,65 @@ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
|
|
|
183
183
|
});
|
|
184
184
|
return stamped;
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Where a self-contained piece really begins, in seconds, or null.
|
|
189
|
+
*
|
|
190
|
+
* The `segment` muxer writes each piece with its own `moov`, and puts the
|
|
191
|
+
* piece's position on the source timeline into an EMPTY EDIT at the head of the
|
|
192
|
+
* track's edit list: an entry whose `media_time` is -1 and whose duration, in
|
|
193
|
+
* the movie timescale, is the offset. That is the piece's own account of where
|
|
194
|
+
* it sits, and it is the only honest one available.
|
|
195
|
+
*
|
|
196
|
+
* It matters because the alternative — the time the playlist ASSIGNED to that
|
|
197
|
+
* segment — can be wrong. The playlist is built from the container's keyframe
|
|
198
|
+
* index, and an index can list times that are not keyframes: measured
|
|
199
|
+
* 2026-08-06 on a Matroska file whose index claimed one at 157.99 s while the
|
|
200
|
+
* real keyframes were at 153.82 and 164.247. The cut therefore produced a piece
|
|
201
|
+
* starting at 153.82, and stamping it with the playlist's 157.99 told the
|
|
202
|
+
* player that picture belonged four seconds later than it did — while the
|
|
203
|
+
* subtitles, extracted straight from the source, kept the true times. The
|
|
204
|
+
* result was a steady 4.17 s desync between speech and text.
|
|
205
|
+
*
|
|
206
|
+
* @param {Buffer} piece
|
|
207
|
+
* @returns {number | null} Seconds, or null when the piece carries no edit list.
|
|
208
|
+
*/
|
|
209
|
+
export function readSelfContainedStartSeconds(piece) {
|
|
210
|
+
let movieTimescale = 0;
|
|
211
|
+
let startSeconds = null;
|
|
212
|
+
walkBoxes(piece, (type, bodyStart, bodyEnd) => {
|
|
213
|
+
if (type === "mvhd" && movieTimescale === 0) {
|
|
214
|
+
const version = piece[bodyStart];
|
|
215
|
+
const offset = version === 1 ? bodyStart + 20 : bodyStart + 12;
|
|
216
|
+
if (offset + 4 <= piece.length) {
|
|
217
|
+
movieTimescale = piece.readUInt32BE(offset);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (type !== "elst" || startSeconds !== null || movieTimescale === 0) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const version = piece[bodyStart];
|
|
225
|
+
const entryStart = bodyStart + 8;
|
|
226
|
+
if (version === 1) {
|
|
227
|
+
if (entryStart + 16 > bodyEnd) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const duration = Number(piece.readBigUInt64BE(entryStart));
|
|
231
|
+
const mediaTime = piece.readBigInt64BE(entryStart + 8);
|
|
232
|
+
if (mediaTime === -1n) {
|
|
233
|
+
startSeconds = duration / movieTimescale;
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (entryStart + 8 > bodyEnd) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const duration = piece.readUInt32BE(entryStart);
|
|
241
|
+
const mediaTime = piece.readInt32BE(entryStart + 4);
|
|
242
|
+
if (mediaTime === -1) {
|
|
243
|
+
startSeconds = duration / movieTimescale;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return startSeconds;
|
|
247
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A segment must be stamped with where it really begins.
|
|
3
|
+
*
|
|
4
|
+
* The playlist's answer comes from the container's keyframe index, and an index
|
|
5
|
+
* can be wrong. Measured 2026-08-06 on a Matroska file whose index claimed a
|
|
6
|
+
* keyframe at 157.99 s: the real ones around there were 153.820 and 164.247, so
|
|
7
|
+
* ffmpeg cut at 153.820 and the segment was stamped 157.99 — telling the player
|
|
8
|
+
* that picture belonged 4.17 s later than it did, while the subtitles, taken
|
|
9
|
+
* straight from the source, kept the true times. Speech and text drifted apart
|
|
10
|
+
* by exactly that much.
|
|
11
|
+
*
|
|
12
|
+
* The piece itself knows better: the `segment` muxer records its position as an
|
|
13
|
+
* empty edit at the head of the track's edit list.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { readSelfContainedStartSeconds } from "../services/segment-formats/mp4-boxes.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A minimal MP4 carrying `moov > mvhd` and `moov > trak > edts > elst`, with a
|
|
22
|
+
* leading empty edit of `offsetSeconds`.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ offsetSeconds: number, timescale?: number, version?: 0 | 1 }} shape
|
|
25
|
+
* @returns {Buffer}
|
|
26
|
+
*/
|
|
27
|
+
function pieceWithEmptyEdit({ offsetSeconds, timescale = 1000, version = 0 }) {
|
|
28
|
+
const box = (type, body) => {
|
|
29
|
+
const head = Buffer.alloc(8);
|
|
30
|
+
head.writeUInt32BE(8 + body.length, 0);
|
|
31
|
+
head.write(type, 4, "latin1");
|
|
32
|
+
return Buffer.concat([head, body]);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mvhd = Buffer.alloc(100);
|
|
36
|
+
mvhd.writeUInt8(0, 0); // version 0
|
|
37
|
+
mvhd.writeUInt32BE(timescale, 12); // timescale
|
|
38
|
+
|
|
39
|
+
const duration = Math.round(offsetSeconds * timescale);
|
|
40
|
+
let elstBody;
|
|
41
|
+
if (version === 1) {
|
|
42
|
+
elstBody = Buffer.alloc(8 + 20);
|
|
43
|
+
elstBody.writeUInt8(1, 0);
|
|
44
|
+
elstBody.writeUInt32BE(1, 4); // one entry
|
|
45
|
+
elstBody.writeBigUInt64BE(BigInt(duration), 8);
|
|
46
|
+
elstBody.writeBigInt64BE(-1n, 16); // empty edit
|
|
47
|
+
} else {
|
|
48
|
+
elstBody = Buffer.alloc(8 + 12);
|
|
49
|
+
elstBody.writeUInt8(0, 0);
|
|
50
|
+
elstBody.writeUInt32BE(1, 4); // one entry
|
|
51
|
+
elstBody.writeUInt32BE(duration, 8);
|
|
52
|
+
elstBody.writeInt32BE(-1, 12); // empty edit
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Buffer.concat([
|
|
56
|
+
box("ftyp", Buffer.alloc(16)),
|
|
57
|
+
box("moov", Buffer.concat([
|
|
58
|
+
box("mvhd", mvhd),
|
|
59
|
+
box("trak", box("edts", box("elst", elstBody)))
|
|
60
|
+
]))
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("the position is read from the empty edit, in seconds", () => {
|
|
65
|
+
assert.equal(readSelfContainedStartSeconds(pieceWithEmptyEdit({ offsetSeconds: 153.82 })), 153.82);
|
|
66
|
+
assert.equal(readSelfContainedStartSeconds(pieceWithEmptyEdit({ offsetSeconds: 0 })), 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a 64-bit edit list is read the same way", () => {
|
|
70
|
+
const piece = pieceWithEmptyEdit({ offsetSeconds: 4321.5, version: 1 });
|
|
71
|
+
assert.equal(readSelfContainedStartSeconds(piece), 4321.5);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the movie timescale is honoured, not assumed", () => {
|
|
75
|
+
const piece = pieceWithEmptyEdit({ offsetSeconds: 12.5, timescale: 90_000 });
|
|
76
|
+
assert.equal(
|
|
77
|
+
readSelfContainedStartSeconds(piece),
|
|
78
|
+
12.5,
|
|
79
|
+
"reading the duration without dividing by the file's own timescale would give 1 125 000"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a piece with no edit list says nothing rather than guessing", () => {
|
|
84
|
+
const bare = Buffer.concat([Buffer.alloc(8), Buffer.from("ftyp", "latin1")]);
|
|
85
|
+
assert.equal(readSelfContainedStartSeconds(bare), null);
|
|
86
|
+
});
|