@torrent-tv/proxy 2.9.124 → 2.9.126
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 +610 -599
- package/package.json +2 -2
- package/services/hls-session-manager.js +63 -7
- package/services/segment-formats/fmp4.js +6 -1
- package/services/torrent-worker/client.js +468 -449
- package/test/segment-serve-wiring.test.js +205 -0
|
@@ -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
|
+
});
|