@torrent-tv/proxy 2.89.4 → 2.89.6
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 +2330 -2315
- package/package.json +1 -1
- package/services/storage/segment-store/SegmentStore.js +1294 -1272
- package/test/auto-quality-step.test.js +762 -753
- package/test/decode-cost.test.js +475 -462
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/helpers/manager.js +62 -57
- package/test/quality-variants.test.js +1270 -1257
- package/test/segment-closed-on-both-branches.test.js +114 -96
- package/test/segment-judged-by-its-own-header.test.js +222 -221
- package/test/segment-serve-wiring.test.js +9 -3
- package/test/serving-existing-output.test.js +111 -105
- package/test/two-viewers-one-picture.test.js +508 -504
- package/test/viewer-requests-admission.test.js +92 -89
- package/test/viewing-generations.test.js +390 -390
|
@@ -1,96 +1,114 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file A piece is closed by one of two writers, and both must say so.
|
|
3
|
-
*
|
|
4
|
-
* The `segment` muxer writes under a working name and reports the closure on a
|
|
5
|
-
* channel of its own, which reaches `SegmentStore.publish`. The `hls` muxer
|
|
6
|
-
* writes through a temporary name of its own (`+temp_file`) and reports
|
|
7
|
-
* nothing: the file simply appears under the name it is served as. That branch
|
|
8
|
-
* is taken by every re-encoded output cut on the even grid — an ordinary
|
|
9
|
-
* quality step — and for it a wait could only end on its deadline, so a segment
|
|
10
|
-
* lying finished on disk was answered 503 after the whole hold.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import test from "node:test";
|
|
14
|
-
import assert from "node:assert/strict";
|
|
15
|
-
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
-
import os from "node:os";
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import { SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
|
|
19
|
-
import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
|
|
20
|
-
|
|
21
|
-
const KEY = "torrent:abc:fmt=fmp4:grid=uniform@0:video-only:v=libx264/854x480";
|
|
22
|
-
|
|
23
|
-
/** A deadline is the backstop of these waits, never what they measure. */
|
|
24
|
-
const BACKSTOP_MS = 10_000;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @returns {{ store: SegmentStore, root: string }}
|
|
28
|
-
*/
|
|
29
|
-
function storeInATempRoot() {
|
|
30
|
-
const root = mkdtempSync(path.join(os.tmpdir(), "segment-closed-"));
|
|
31
|
-
const store = new SegmentStore({ root, logger: { info: () => {}, warn: () => {} } });
|
|
32
|
-
store.useFormat(KEY, fmp4Format);
|
|
33
|
-
return { store, root };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
test("a piece that simply appears ends the wait that began before it", async (t) => {
|
|
37
|
-
const { store, root } = storeInATempRoot();
|
|
38
|
-
t.after(() => {
|
|
39
|
-
store.dropAll("the test is over");
|
|
40
|
-
rmSync(root, { recursive: true, force: true });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const dir = store.directoryFor(KEY);
|
|
44
|
-
// The wait begins on a segment nobody has written yet — the cold case, and
|
|
45
|
-
// the only one the deadline used to decide.
|
|
46
|
-
const waited = store.waitFor(KEY, 7, BACKSTOP_MS);
|
|
47
|
-
|
|
48
|
-
// What the `hls` muxer does: a temporary name of its own, renamed into place
|
|
49
|
-
// when the piece is whole. Nothing is told; the directory simply moves.
|
|
50
|
-
const temp = path.join(dir, "segment-00007.mp4.tmp");
|
|
51
|
-
writeFileSync(temp, Buffer.alloc(64));
|
|
52
|
-
renameSync(temp, path.join(dir, fmp4Format.segmentFileName(7)));
|
|
53
|
-
|
|
54
|
-
assert.equal(await waited, true, "the piece was on disk and the wait did not end");
|
|
55
|
-
assert.ok(store.pathOf(KEY, 7), "and the store must be able to hand it over");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("clearing up after a run is not its pieces arriving", async (t) => {
|
|
59
|
-
const { store, root } = storeInATempRoot();
|
|
60
|
-
t.after(() => {
|
|
61
|
-
store.dropAll("the test is over");
|
|
62
|
-
rmSync(root, { recursive: true, force: true });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
const dir = store.directoryFor(KEY);
|
|
66
|
-
const unfinished = path.join(dir, fmp4Format.makingFileNameTemplate("3").replace("%05d", "00009"));
|
|
67
|
-
writeFileSync(unfinished, Buffer.alloc(64));
|
|
68
|
-
|
|
69
|
-
let ended = false;
|
|
70
|
-
// 200 ms is a grace before asserting that something did NOT happen, which can
|
|
71
|
-
// only ever pass too easily — never a measurement.
|
|
72
|
-
const waited = store.waitFor(KEY, 9, 200).then((value) => {
|
|
73
|
-
ended = value;
|
|
74
|
-
return value;
|
|
75
|
-
});
|
|
76
|
-
rmSync(unfinished, { force: true });
|
|
77
|
-
|
|
78
|
-
assert.equal(await waited, false, "a directory that moved because a file went holds nothing new");
|
|
79
|
-
assert.equal(ended, false);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("the writer that does report a closure is unaffected", async (t) => {
|
|
83
|
-
const { store, root } = storeInATempRoot();
|
|
84
|
-
t.after(() => {
|
|
85
|
-
store.dropAll("the test is over");
|
|
86
|
-
rmSync(root, { recursive: true, force: true });
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const dir = store.directoryFor(KEY);
|
|
90
|
-
const making = fmp4Format.makingFileNameTemplate("4").replace("%05d", "00004");
|
|
91
|
-
writeFileSync(path.join(dir, making), Buffer.alloc(64));
|
|
92
|
-
|
|
93
|
-
const waited = store.waitFor(KEY, 4, BACKSTOP_MS);
|
|
94
|
-
assert.equal(store.publish(KEY, making, fmp4Format), fmp4Format.segmentFileName(4));
|
|
95
|
-
assert.equal(await waited, true);
|
|
96
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file A piece is closed by one of two writers, and both must say so.
|
|
3
|
+
*
|
|
4
|
+
* The `segment` muxer writes under a working name and reports the closure on a
|
|
5
|
+
* channel of its own, which reaches `SegmentStore.publish`. The `hls` muxer
|
|
6
|
+
* writes through a temporary name of its own (`+temp_file`) and reports
|
|
7
|
+
* nothing: the file simply appears under the name it is served as. That branch
|
|
8
|
+
* is taken by every re-encoded output cut on the even grid — an ordinary
|
|
9
|
+
* quality step — and for it a wait could only end on its deadline, so a segment
|
|
10
|
+
* lying finished on disk was answered 503 after the whole hold.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
|
|
19
|
+
import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
|
|
20
|
+
|
|
21
|
+
const KEY = "torrent:abc:fmt=fmp4:grid=uniform@0:video-only:v=libx264/854x480";
|
|
22
|
+
|
|
23
|
+
/** A deadline is the backstop of these waits, never what they measure. */
|
|
24
|
+
const BACKSTOP_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {{ store: SegmentStore, root: string }}
|
|
28
|
+
*/
|
|
29
|
+
function storeInATempRoot() {
|
|
30
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "segment-closed-"));
|
|
31
|
+
const store = new SegmentStore({ root, logger: { info: () => {}, warn: () => {} } });
|
|
32
|
+
store.useFormat(KEY, fmp4Format);
|
|
33
|
+
return { store, root };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("a piece that simply appears ends the wait that began before it", async (t) => {
|
|
37
|
+
const { store, root } = storeInATempRoot();
|
|
38
|
+
t.after(() => {
|
|
39
|
+
store.dropAll("the test is over");
|
|
40
|
+
rmSync(root, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const dir = store.directoryFor(KEY);
|
|
44
|
+
// The wait begins on a segment nobody has written yet — the cold case, and
|
|
45
|
+
// the only one the deadline used to decide.
|
|
46
|
+
const waited = store.waitFor(KEY, 7, BACKSTOP_MS);
|
|
47
|
+
|
|
48
|
+
// What the `hls` muxer does: a temporary name of its own, renamed into place
|
|
49
|
+
// when the piece is whole. Nothing is told; the directory simply moves.
|
|
50
|
+
const temp = path.join(dir, "segment-00007.mp4.tmp");
|
|
51
|
+
writeFileSync(temp, Buffer.alloc(64));
|
|
52
|
+
renameSync(temp, path.join(dir, fmp4Format.segmentFileName(7)));
|
|
53
|
+
|
|
54
|
+
assert.equal(await waited, true, "the piece was on disk and the wait did not end");
|
|
55
|
+
assert.ok(store.pathOf(KEY, 7), "and the store must be able to hand it over");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("clearing up after a run is not its pieces arriving", async (t) => {
|
|
59
|
+
const { store, root } = storeInATempRoot();
|
|
60
|
+
t.after(() => {
|
|
61
|
+
store.dropAll("the test is over");
|
|
62
|
+
rmSync(root, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const dir = store.directoryFor(KEY);
|
|
66
|
+
const unfinished = path.join(dir, fmp4Format.makingFileNameTemplate("3").replace("%05d", "00009"));
|
|
67
|
+
writeFileSync(unfinished, Buffer.alloc(64));
|
|
68
|
+
|
|
69
|
+
let ended = false;
|
|
70
|
+
// 200 ms is a grace before asserting that something did NOT happen, which can
|
|
71
|
+
// only ever pass too easily — never a measurement.
|
|
72
|
+
const waited = store.waitFor(KEY, 9, 200).then((value) => {
|
|
73
|
+
ended = value;
|
|
74
|
+
return value;
|
|
75
|
+
});
|
|
76
|
+
rmSync(unfinished, { force: true });
|
|
77
|
+
|
|
78
|
+
assert.equal(await waited, false, "a directory that moved because a file went holds nothing new");
|
|
79
|
+
assert.equal(ended, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the writer that does report a closure is unaffected", async (t) => {
|
|
83
|
+
const { store, root } = storeInATempRoot();
|
|
84
|
+
t.after(() => {
|
|
85
|
+
store.dropAll("the test is over");
|
|
86
|
+
rmSync(root, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const dir = store.directoryFor(KEY);
|
|
90
|
+
const making = fmp4Format.makingFileNameTemplate("4").replace("%05d", "00004");
|
|
91
|
+
writeFileSync(path.join(dir, making), Buffer.alloc(64));
|
|
92
|
+
|
|
93
|
+
const waited = store.waitFor(KEY, 4, BACKSTOP_MS);
|
|
94
|
+
assert.equal(store.publish(KEY, making, fmp4Format), fmp4Format.segmentFileName(4));
|
|
95
|
+
assert.equal(await waited, true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a directory removed from outside and made again is watched again", async (t) => {
|
|
99
|
+
const { store, root } = storeInATempRoot();
|
|
100
|
+
t.after(() => {
|
|
101
|
+
store.dropAll("the test is over");
|
|
102
|
+
rmSync(root, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Removed by something other than the store, so its watch was not stopped
|
|
106
|
+
// first. The watch held on that path watches a directory that is gone.
|
|
107
|
+
rmSync(store.directoryFor(KEY), { recursive: true, force: true });
|
|
108
|
+
const dir = store.directoryFor(KEY);
|
|
109
|
+
const waited = store.waitFor(KEY, 3, BACKSTOP_MS);
|
|
110
|
+
|
|
111
|
+
writeFileSync(path.join(dir, fmp4Format.segmentFileName(3)), Buffer.alloc(64));
|
|
112
|
+
|
|
113
|
+
assert.equal(await waited, true, "the piece arrived in the new directory and the wait did not end");
|
|
114
|
+
});
|
|
@@ -1,221 +1,222 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file A piece short of a track is judged against ITS OWN header.
|
|
3
|
-
*
|
|
4
|
-
* The question "does this piece carry every track" has two possible answers and
|
|
5
|
-
* they call for opposite actions, which is why asking it against the session's
|
|
6
|
-
* cached header alone was not enough: a piece that disagrees with that header
|
|
7
|
-
* may be short, or the header may be the odd one out. A self-contained piece
|
|
8
|
-
* carries a `moov` of its own, so it can be compared with itself, and that
|
|
9
|
-
* comparison tells the two apart without asking anything about encoders.
|
|
10
|
-
*
|
|
11
|
-
* WHAT THIS REPLACED. The action used to be chosen by "is any run of this
|
|
12
|
-
* session alive?" — a question about the SESSION, asked about a fact of the
|
|
13
|
-
* BYTES. A run anywhere in the film meant "still being written; waiting for
|
|
14
|
-
* it", and nothing re-judged or re-made the piece afterwards, so one
|
|
15
|
-
* misjudgement became permanent.
|
|
16
|
-
*
|
|
17
|
-
* Field 2026-09-14: `segment-00033.mp4` of a copied picture, already served
|
|
18
|
-
* whole to one viewer at 5 099 421 bytes, was refused to a second viewer for
|
|
19
|
-
* five minutes — four holds of sixty seconds each, then 503 — while the run in
|
|
20
|
-
* force was at #192 and beyond. That viewer's picture stood still 196.2 s and
|
|
21
|
-
* then a further 56.2 s.
|
|
22
|
-
*
|
|
23
|
-
* The run question is also unanswerable and does not need asking: a piece being
|
|
24
|
-
* written is called `making-<tag>-NNNNN.mp4` and takes its served name by a
|
|
25
|
-
* rename, which is atomic. A file under its served name is never being written.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import test from "node:test";
|
|
29
|
-
import assert from "node:assert/strict";
|
|
30
|
-
import { access, writeFile } from "node:fs/promises";
|
|
31
|
-
import path from "node:path";
|
|
32
|
-
import { SourceFile } from "../services/media/SourceFile.js";
|
|
33
|
-
import { Timeline } from "../services/encode/output/Timeline.js";
|
|
34
|
-
import { managerWithOwnStore } from "./helpers/manager.js";
|
|
35
|
-
import { startRunOn } from "./helpers/encode-run.js";
|
|
36
|
-
import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
|
|
37
|
-
import { outputSpec } from "./helpers/output-spec.js";
|
|
38
|
-
|
|
39
|
-
const SESSION_ID = "3333333344445555";
|
|
40
|
-
const OUTPUT_KEY = "own-header:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
|
|
41
|
-
const MOVIE_TIMESCALE = 1000;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* @param {string} type
|
|
45
|
-
* @param {Buffer} body
|
|
46
|
-
* @returns {Buffer}
|
|
47
|
-
*/
|
|
48
|
-
function box(type, body) {
|
|
49
|
-
const head = Buffer.alloc(8);
|
|
50
|
-
head.writeUInt32BE(8 + body.length, 0);
|
|
51
|
-
head.write(type, 4, "latin1");
|
|
52
|
-
return Buffer.concat([head, body]);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @param {number} trackId
|
|
57
|
-
* @returns {Buffer}
|
|
58
|
-
*/
|
|
59
|
-
function trak(trackId) {
|
|
60
|
-
const tkhdBody = Buffer.alloc(84);
|
|
61
|
-
tkhdBody.writeUInt32BE(trackId, 12);
|
|
62
|
-
const mdhdBody = Buffer.alloc(20);
|
|
63
|
-
mdhdBody.writeUInt32BE(90_000, 12);
|
|
64
|
-
return box("trak", Buffer.concat([
|
|
65
|
-
box("tkhd", tkhdBody),
|
|
66
|
-
box("mdia", box("mdhd", mdhdBody))
|
|
67
|
-
]));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* A header declaring `tracks` tracks.
|
|
72
|
-
*
|
|
73
|
-
* @param {number} tracks
|
|
74
|
-
* @returns {Buffer}
|
|
75
|
-
*/
|
|
76
|
-
function moovOf(tracks) {
|
|
77
|
-
const mvhdBody = Buffer.alloc(100);
|
|
78
|
-
mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
|
|
79
|
-
const traks = [];
|
|
80
|
-
for (let id = 1; id <= tracks; id += 1) {
|
|
81
|
-
traks.push(trak(id));
|
|
82
|
-
}
|
|
83
|
-
return box("moov", Buffer.concat([box("mvhd", mvhdBody), ...traks]));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @param {number} trackId
|
|
88
|
-
* @returns {Buffer}
|
|
89
|
-
*/
|
|
90
|
-
function traf(trackId) {
|
|
91
|
-
const tfhdBody = Buffer.alloc(8);
|
|
92
|
-
tfhdBody.writeUInt32BE(trackId, 4);
|
|
93
|
-
const tfdtBody = Buffer.alloc(12);
|
|
94
|
-
tfdtBody.writeUInt8(1, 0);
|
|
95
|
-
tfdtBody.writeBigUInt64BE(0n, 4);
|
|
96
|
-
return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* A piece shaped like the `segment` muxer's output.
|
|
101
|
-
*
|
|
102
|
-
* @param {{ declares: number, carries: number }} shape - How many tracks its own
|
|
103
|
-
* header declares, and how many its fragments actually carry. The two differ
|
|
104
|
-
* only in a piece a run closed short.
|
|
105
|
-
* @returns {Buffer}
|
|
106
|
-
*/
|
|
107
|
-
function selfContainedPiece({ declares, carries }) {
|
|
108
|
-
const trafs = [];
|
|
109
|
-
for (let id = 1; id <= carries; id += 1) {
|
|
110
|
-
trafs.push(traf(id));
|
|
111
|
-
}
|
|
112
|
-
return Buffer.concat([
|
|
113
|
-
box("ftyp", Buffer.alloc(16, 0)),
|
|
114
|
-
moovOf(declares),
|
|
115
|
-
box("moof", Buffer.concat(trafs)),
|
|
116
|
-
box("mdat", Buffer.alloc(64, 0x5a)),
|
|
117
|
-
box("mfra", Buffer.alloc(24, 0))
|
|
118
|
-
]);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* A manager with one session whose first two pieces are already on disk.
|
|
123
|
-
*
|
|
124
|
-
* @param {{ piece: Buffer, sessionHeader: Buffer }} params - The piece written
|
|
125
|
-
* for every number, and what the session has cached as its header.
|
|
126
|
-
* @returns {Promise<{ manager: object, dirPath: string }>}
|
|
127
|
-
*/
|
|
128
|
-
async function managerHolding({ piece, sessionHeader }) {
|
|
129
|
-
const { manager } = managerWithOwnStore();
|
|
130
|
-
manager.segmentStore.useFormat(OUTPUT_KEY, fmp4Format);
|
|
131
|
-
const dirPath = manager.segmentStore.directoryFor(OUTPUT_KEY);
|
|
132
|
-
await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
|
|
133
|
-
await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
|
|
134
|
-
|
|
135
|
-
const session = {
|
|
136
|
-
id: SESSION_ID,
|
|
137
|
-
spec: outputSpec({ transcodeVideo: false }),
|
|
138
|
-
outputKey: OUTPUT_KEY,
|
|
139
|
-
dirPath,
|
|
140
|
-
timeline: new Timeline({ boundaries: [0, 12.5, 25, 37.5, 50, 62.5], cutGrid: "uniform" }),
|
|
141
|
-
state: "ready",
|
|
142
|
-
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" })
|
|
143
|
-
.learn({ durationSeconds: 62.5 }),
|
|
144
|
-
get inputFile() { return this.file; },
|
|
145
|
-
get audioFile() { return this.file; },
|
|
146
|
-
lastAloneSpeed: 2,
|
|
147
|
-
startedAt: Date.now(),
|
|
148
|
-
lastAccessedAt: Date.now(),
|
|
149
|
-
runs: new Set(),
|
|
150
|
-
lastError: "",
|
|
151
|
-
segmentCount: 5,
|
|
152
|
-
segmentFormat: fmp4Format,
|
|
153
|
-
useSyntheticPlaylist: true,
|
|
154
|
-
playlistText: "#EXTM3U\n",
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
manager.encodeRuns.
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
const {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
const {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// the
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file A piece short of a track is judged against ITS OWN header.
|
|
3
|
+
*
|
|
4
|
+
* The question "does this piece carry every track" has two possible answers and
|
|
5
|
+
* they call for opposite actions, which is why asking it against the session's
|
|
6
|
+
* cached header alone was not enough: a piece that disagrees with that header
|
|
7
|
+
* may be short, or the header may be the odd one out. A self-contained piece
|
|
8
|
+
* carries a `moov` of its own, so it can be compared with itself, and that
|
|
9
|
+
* comparison tells the two apart without asking anything about encoders.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS REPLACED. The action used to be chosen by "is any run of this
|
|
12
|
+
* session alive?" — a question about the SESSION, asked about a fact of the
|
|
13
|
+
* BYTES. A run anywhere in the film meant "still being written; waiting for
|
|
14
|
+
* it", and nothing re-judged or re-made the piece afterwards, so one
|
|
15
|
+
* misjudgement became permanent.
|
|
16
|
+
*
|
|
17
|
+
* Field 2026-09-14: `segment-00033.mp4` of a copied picture, already served
|
|
18
|
+
* whole to one viewer at 5 099 421 bytes, was refused to a second viewer for
|
|
19
|
+
* five minutes — four holds of sixty seconds each, then 503 — while the run in
|
|
20
|
+
* force was at #192 and beyond. That viewer's picture stood still 196.2 s and
|
|
21
|
+
* then a further 56.2 s.
|
|
22
|
+
*
|
|
23
|
+
* The run question is also unanswerable and does not need asking: a piece being
|
|
24
|
+
* written is called `making-<tag>-NNNNN.mp4` and takes its served name by a
|
|
25
|
+
* rename, which is atomic. A file under its served name is never being written.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import test from "node:test";
|
|
29
|
+
import assert from "node:assert/strict";
|
|
30
|
+
import { access, writeFile } from "node:fs/promises";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { SourceFile } from "../services/media/SourceFile.js";
|
|
33
|
+
import { Timeline } from "../services/encode/output/Timeline.js";
|
|
34
|
+
import { managerWithOwnStore } from "./helpers/manager.js";
|
|
35
|
+
import { startRunOn } from "./helpers/encode-run.js";
|
|
36
|
+
import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
|
|
37
|
+
import { outputSpec } from "./helpers/output-spec.js";
|
|
38
|
+
|
|
39
|
+
const SESSION_ID = "3333333344445555";
|
|
40
|
+
const OUTPUT_KEY = "own-header:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
|
|
41
|
+
const MOVIE_TIMESCALE = 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} type
|
|
45
|
+
* @param {Buffer} body
|
|
46
|
+
* @returns {Buffer}
|
|
47
|
+
*/
|
|
48
|
+
function box(type, body) {
|
|
49
|
+
const head = Buffer.alloc(8);
|
|
50
|
+
head.writeUInt32BE(8 + body.length, 0);
|
|
51
|
+
head.write(type, 4, "latin1");
|
|
52
|
+
return Buffer.concat([head, body]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {number} trackId
|
|
57
|
+
* @returns {Buffer}
|
|
58
|
+
*/
|
|
59
|
+
function trak(trackId) {
|
|
60
|
+
const tkhdBody = Buffer.alloc(84);
|
|
61
|
+
tkhdBody.writeUInt32BE(trackId, 12);
|
|
62
|
+
const mdhdBody = Buffer.alloc(20);
|
|
63
|
+
mdhdBody.writeUInt32BE(90_000, 12);
|
|
64
|
+
return box("trak", Buffer.concat([
|
|
65
|
+
box("tkhd", tkhdBody),
|
|
66
|
+
box("mdia", box("mdhd", mdhdBody))
|
|
67
|
+
]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A header declaring `tracks` tracks.
|
|
72
|
+
*
|
|
73
|
+
* @param {number} tracks
|
|
74
|
+
* @returns {Buffer}
|
|
75
|
+
*/
|
|
76
|
+
function moovOf(tracks) {
|
|
77
|
+
const mvhdBody = Buffer.alloc(100);
|
|
78
|
+
mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
|
|
79
|
+
const traks = [];
|
|
80
|
+
for (let id = 1; id <= tracks; id += 1) {
|
|
81
|
+
traks.push(trak(id));
|
|
82
|
+
}
|
|
83
|
+
return box("moov", Buffer.concat([box("mvhd", mvhdBody), ...traks]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {number} trackId
|
|
88
|
+
* @returns {Buffer}
|
|
89
|
+
*/
|
|
90
|
+
function traf(trackId) {
|
|
91
|
+
const tfhdBody = Buffer.alloc(8);
|
|
92
|
+
tfhdBody.writeUInt32BE(trackId, 4);
|
|
93
|
+
const tfdtBody = Buffer.alloc(12);
|
|
94
|
+
tfdtBody.writeUInt8(1, 0);
|
|
95
|
+
tfdtBody.writeBigUInt64BE(0n, 4);
|
|
96
|
+
return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A piece shaped like the `segment` muxer's output.
|
|
101
|
+
*
|
|
102
|
+
* @param {{ declares: number, carries: number }} shape - How many tracks its own
|
|
103
|
+
* header declares, and how many its fragments actually carry. The two differ
|
|
104
|
+
* only in a piece a run closed short.
|
|
105
|
+
* @returns {Buffer}
|
|
106
|
+
*/
|
|
107
|
+
function selfContainedPiece({ declares, carries }) {
|
|
108
|
+
const trafs = [];
|
|
109
|
+
for (let id = 1; id <= carries; id += 1) {
|
|
110
|
+
trafs.push(traf(id));
|
|
111
|
+
}
|
|
112
|
+
return Buffer.concat([
|
|
113
|
+
box("ftyp", Buffer.alloc(16, 0)),
|
|
114
|
+
moovOf(declares),
|
|
115
|
+
box("moof", Buffer.concat(trafs)),
|
|
116
|
+
box("mdat", Buffer.alloc(64, 0x5a)),
|
|
117
|
+
box("mfra", Buffer.alloc(24, 0))
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A manager with one session whose first two pieces are already on disk.
|
|
123
|
+
*
|
|
124
|
+
* @param {{ piece: Buffer, sessionHeader: Buffer }} params - The piece written
|
|
125
|
+
* for every number, and what the session has cached as its header.
|
|
126
|
+
* @returns {Promise<{ manager: object, dirPath: string }>}
|
|
127
|
+
*/
|
|
128
|
+
async function managerHolding({ piece, sessionHeader }) {
|
|
129
|
+
const { manager } = managerWithOwnStore();
|
|
130
|
+
manager.segmentStore.useFormat(OUTPUT_KEY, fmp4Format);
|
|
131
|
+
const dirPath = manager.segmentStore.directoryFor(OUTPUT_KEY);
|
|
132
|
+
await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
|
|
133
|
+
await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
|
|
134
|
+
|
|
135
|
+
const session = {
|
|
136
|
+
id: SESSION_ID,
|
|
137
|
+
spec: outputSpec({ transcodeVideo: false }),
|
|
138
|
+
outputKey: OUTPUT_KEY,
|
|
139
|
+
dirPath,
|
|
140
|
+
timeline: new Timeline({ boundaries: [0, 12.5, 25, 37.5, 50, 62.5], cutGrid: "uniform" }),
|
|
141
|
+
state: "ready",
|
|
142
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" })
|
|
143
|
+
.learn({ durationSeconds: 62.5 }),
|
|
144
|
+
get inputFile() { return this.file; },
|
|
145
|
+
get audioFile() { return this.file; },
|
|
146
|
+
lastAloneSpeed: 2,
|
|
147
|
+
startedAt: Date.now(),
|
|
148
|
+
lastAccessedAt: Date.now(),
|
|
149
|
+
runs: new Set(),
|
|
150
|
+
lastError: "",
|
|
151
|
+
segmentCount: 5,
|
|
152
|
+
segmentFormat: fmp4Format,
|
|
153
|
+
useSyntheticPlaylist: true,
|
|
154
|
+
playlistText: "#EXTM3U\n",
|
|
155
|
+
waitEpoch: 0
|
|
156
|
+
};
|
|
157
|
+
manager.outputs.set(SESSION_ID, session);
|
|
158
|
+
// The header this output serves is the store's, one owner for it.
|
|
159
|
+
manager.segmentStore.keepInit(OUTPUT_KEY, sessionHeader);
|
|
160
|
+
// No plan runs here: this file is about the path that answers a request.
|
|
161
|
+
manager.encodeRuns.planEncodersNow = () => {};
|
|
162
|
+
manager.encodeRuns.planEncodersSoon = () => {};
|
|
163
|
+
// A LIVE RUN, deliberately. Under the rule this replaced, a live run anywhere
|
|
164
|
+
// was what turned a refusal into a permanent one — so both cases below are
|
|
165
|
+
// asked with one running, which is the state the field failure happened in.
|
|
166
|
+
startRunOn(session, { from: 0, usesExplicitCuts: true, speedX: 2 });
|
|
167
|
+
return { manager, dirPath };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test("a piece that agrees with its own header is served, whatever the session's header says", async () => {
|
|
171
|
+
// Two tracks declared, two carried: whole by its own account. The session's
|
|
172
|
+
// cached header declares three — the state that cannot be told from a short
|
|
173
|
+
// piece without asking the piece itself.
|
|
174
|
+
const piece = selfContainedPiece({ declares: 2, carries: 2 });
|
|
175
|
+
const { manager, dirPath } = await managerHolding({
|
|
176
|
+
piece,
|
|
177
|
+
sessionHeader: moovOf(3)
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const answer = await manager.serving.getFileStream(SESSION_ID, "segment-00000.mp4");
|
|
181
|
+
|
|
182
|
+
assert.equal(answer.kind, "file", "the piece is whole and must reach the viewer");
|
|
183
|
+
await access(path.join(dirPath, "segment-00000.mp4"));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a piece that disagrees with its own header is removed, so it can be made again", async () => {
|
|
187
|
+
// Two tracks declared, one carried: closed short by a run that was killed
|
|
188
|
+
// mid-piece, which is what this check exists to catch.
|
|
189
|
+
const piece = selfContainedPiece({ declares: 2, carries: 1 });
|
|
190
|
+
const { manager, dirPath } = await managerHolding({
|
|
191
|
+
piece,
|
|
192
|
+
sessionHeader: moovOf(2)
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const answer = await manager.serving.getFileStream(SESSION_ID, "segment-00000.mp4");
|
|
196
|
+
|
|
197
|
+
assert.equal(answer.kind, "warming-up", "a short piece is not servable");
|
|
198
|
+
await assert.rejects(
|
|
199
|
+
() => access(path.join(dirPath, "segment-00000.mp4")),
|
|
200
|
+
/ENOENT/,
|
|
201
|
+
"and it is removed, or nothing will ever make it again"
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a live run elsewhere in the film does not make a short piece permanent", async () => {
|
|
206
|
+
const piece = selfContainedPiece({ declares: 2, carries: 1 });
|
|
207
|
+
const { manager, dirPath } = await managerHolding({
|
|
208
|
+
piece,
|
|
209
|
+
sessionHeader: moovOf(2)
|
|
210
|
+
});
|
|
211
|
+
// The field state exactly: the run in force is hundreds of segments ahead of
|
|
212
|
+
// the piece being asked for.
|
|
213
|
+
startRunOn(manager.outputs.get(SESSION_ID), { from: 192, usesExplicitCuts: true, speedX: 2 });
|
|
214
|
+
|
|
215
|
+
await manager.serving.getFileStream(SESSION_ID, "segment-00000.mp4");
|
|
216
|
+
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
() => access(path.join(dirPath, "segment-00000.mp4")),
|
|
219
|
+
/ENOENT/,
|
|
220
|
+
"a run a thousand segments away says nothing about these bytes"
|
|
221
|
+
);
|
|
222
|
+
});
|