@torrent-tv/proxy 2.74.1 → 2.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1504 -1453
- package/CLAUDE.md +19 -6
- package/biome.json +182 -1
- package/docs/container-architecture.md +27 -6
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +9 -214
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +281 -80
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +327 -8
- package/services/container/Mp4Container.js +373 -27
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -747
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +370 -333
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/AudioTrack.js +131 -40
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/audio-inventory.test.js +176 -177
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/video-facts.test.js +102 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{container-index → container}/ebml-reader.js +0 -0
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a viewer is watching, and what happens to it when they leave.
|
|
3
|
+
*
|
|
4
|
+
* The relation "this person watches this output" is indexed both ways — the
|
|
5
|
+
* output holds its viewers, the viewer holds its outputs — and these checks
|
|
6
|
+
* hold the three things that go wrong when the two indexes come apart.
|
|
7
|
+
*
|
|
8
|
+
* 1. The viewer must never be dropped from the PICTURE. Their chosen
|
|
9
|
+
* soundtrack, their position and their step are recorded there, and the
|
|
10
|
+
* picture is the only id the browser knows. Dropping them was reachable by
|
|
11
|
+
* an ordinary sequence — down a step, back to the picture's own height, down
|
|
12
|
+
* again — and cost them the soundtrack they had picked.
|
|
13
|
+
* 2. A viewer who leaves must be subtracted from EVERY output they were
|
|
14
|
+
* watching, and an output with nobody left must be let go. Nothing outside
|
|
15
|
+
* the session manager knows the id of a quality step or of a separately
|
|
16
|
+
* published soundtrack, so nothing else can ever release one.
|
|
17
|
+
* 3. Whatever removes a viewer from an output must release what their watching
|
|
18
|
+
* claimed of production. The only place a claim is released is the plan's
|
|
19
|
+
* pass over the output's viewers, so a viewer deleted by any other route
|
|
20
|
+
* leaves a claim that nothing can reach.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import test from "node:test";
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { HlsSessionManager, variantConsumerId } from "../services/hls-session-manager.js";
|
|
29
|
+
import { Viewers } from "../services/viewer/Viewers.js";
|
|
30
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
31
|
+
import { Timeline } from "../services/output/Timeline.js";
|
|
32
|
+
import { Output } from "../services/output/Output.js";
|
|
33
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
34
|
+
import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
|
|
35
|
+
|
|
36
|
+
const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
37
|
+
const STEP_ID = "11111111-2222-3333-4444-555555555555";
|
|
38
|
+
const AUDIO_ID = "99999999-8888-7777-6666-555555555555";
|
|
39
|
+
const VIEWER = "viewer-one";
|
|
40
|
+
const SEGMENT_SECONDS = 4;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A session shaped like a live one, without the ffmpeg run behind it.
|
|
44
|
+
*
|
|
45
|
+
* @param {{ id: string, dirPath: string, file: SourceFile, encodeHeight?: number,
|
|
46
|
+
* audioOnly?: boolean, isStep?: boolean }} params
|
|
47
|
+
* @returns {object}
|
|
48
|
+
*/
|
|
49
|
+
function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, isStep = false }) {
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
dirPath,
|
|
53
|
+
timeline: new Timeline({
|
|
54
|
+
boundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
|
|
55
|
+
cutGrid: "uniform"
|
|
56
|
+
}),
|
|
57
|
+
state: "ready",
|
|
58
|
+
file,
|
|
59
|
+
get inputFile() { return this.file; },
|
|
60
|
+
get audioFile() { return this.file; },
|
|
61
|
+
startedAt: Date.now(),
|
|
62
|
+
createEntryMs: Date.now(),
|
|
63
|
+
lastAccessedAt: Date.now(),
|
|
64
|
+
ffmpeg: null,
|
|
65
|
+
lastError: "",
|
|
66
|
+
consumers: new Set(),
|
|
67
|
+
segmentFormat: fmp4Format,
|
|
68
|
+
transcodeVideo: !audioOnly,
|
|
69
|
+
transcodeAudio: true,
|
|
70
|
+
audioTrackIndex: 0,
|
|
71
|
+
audioOnly,
|
|
72
|
+
isStep,
|
|
73
|
+
variantHeight: isStep ? encodeHeight : undefined,
|
|
74
|
+
// Left set so that disposal does not remove a directory the other sessions
|
|
75
|
+
// of this output are still named by.
|
|
76
|
+
outputKey: `output-${id}`,
|
|
77
|
+
output: new Output({
|
|
78
|
+
encodeWidth: 0,
|
|
79
|
+
encodeHeight,
|
|
80
|
+
outputFps: 24,
|
|
81
|
+
softwarePreset: null,
|
|
82
|
+
applyTonemap: false
|
|
83
|
+
}),
|
|
84
|
+
encodeRunGeneration: 0,
|
|
85
|
+
lastRestartAt: 0,
|
|
86
|
+
seekFailureTarget: -1,
|
|
87
|
+
seekFailureCount: 0,
|
|
88
|
+
seekSettleTimer: null,
|
|
89
|
+
seekTarget: null,
|
|
90
|
+
waitEpoch: 0,
|
|
91
|
+
runs: new Set(),
|
|
92
|
+
viewers: new Map(),
|
|
93
|
+
usesExplicitCuts: false,
|
|
94
|
+
useSyntheticPlaylist: true,
|
|
95
|
+
playlistText: "#EXTM3U\n",
|
|
96
|
+
segmentCount: 100,
|
|
97
|
+
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A picture with one quality step and one separately published soundtrack, all
|
|
103
|
+
* of one file, and one viewer watching all three — which is what an ordinary
|
|
104
|
+
* session looks like once the viewer has picked a language and a quality.
|
|
105
|
+
*
|
|
106
|
+
* @returns {Promise<{ manager: HlsSessionManager, base: object, step: object,
|
|
107
|
+
* audio: object, dirPath: string, released: string[] }>}
|
|
108
|
+
*/
|
|
109
|
+
async function pictureWithStepAndSoundtrack() {
|
|
110
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-outputs-"));
|
|
111
|
+
const manager = new HlsSessionManager({
|
|
112
|
+
enabled: true,
|
|
113
|
+
ffmpegBin: "ffmpeg",
|
|
114
|
+
localBindHost: "127.0.0.1",
|
|
115
|
+
localPort: 9090
|
|
116
|
+
});
|
|
117
|
+
const file = new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" })
|
|
118
|
+
.learn({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 });
|
|
119
|
+
const base = fakeSession({ id: BASE_ID, dirPath, file, encodeHeight: 812 });
|
|
120
|
+
const step = fakeSession({ id: STEP_ID, dirPath, file, encodeHeight: 540, isStep: true });
|
|
121
|
+
const audio = fakeSession({ id: AUDIO_ID, dirPath, file, audioOnly: true });
|
|
122
|
+
base.consumers = new Set([VIEWER]);
|
|
123
|
+
// The family's own claim, which is how a picture holds what it made: the
|
|
124
|
+
// browser never learns these two ids.
|
|
125
|
+
step.consumers = new Set([variantConsumerId(BASE_ID)]);
|
|
126
|
+
audio.consumers = new Set([variantConsumerId(BASE_ID)]);
|
|
127
|
+
for (const session of [base, step, audio]) {
|
|
128
|
+
manager.sessionsById.set(session.id, session);
|
|
129
|
+
}
|
|
130
|
+
file.stepHeights.set(540, 540);
|
|
131
|
+
/** @type {string[]} */
|
|
132
|
+
const released = [];
|
|
133
|
+
const realRelease = manager.encodeOrchestrator.release.bind(manager.encodeOrchestrator);
|
|
134
|
+
manager.encodeOrchestrator.release = (claimant) => {
|
|
135
|
+
released.push(claimant);
|
|
136
|
+
return realRelease(claimant);
|
|
137
|
+
};
|
|
138
|
+
return { manager, base, step, audio, dirPath, released };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test("a viewer who steps down, back to the picture's own height and down again keeps their record", async (t) => {
|
|
142
|
+
const { manager, base, dirPath } = await pictureWithStepAndSoundtrack();
|
|
143
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
144
|
+
startRunOn(base, { process: fakeEncoder() });
|
|
145
|
+
const viewer = manager.viewers.of(base, VIEWER);
|
|
146
|
+
viewer.audio = { trackIndex: 1, transcode: true };
|
|
147
|
+
viewer.head = { segment: 25, seconds: 100, at: Date.now() };
|
|
148
|
+
|
|
149
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4", VIEWER);
|
|
150
|
+
await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4", VIEWER);
|
|
151
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00027.mp4", VIEWER);
|
|
152
|
+
|
|
153
|
+
const known = base.viewers.get(VIEWER);
|
|
154
|
+
assert.ok(known, "the picture is the one id the browser holds — a viewer is never dropped from it");
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
known.audio,
|
|
157
|
+
{ trackIndex: 1, transcode: true },
|
|
158
|
+
"the soundtrack they chose is recorded on the picture, and a quality switch does not touch it"
|
|
159
|
+
);
|
|
160
|
+
assert.equal(known.activeVariantId, STEP_ID, "and the step they moved to is where they are");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a viewer leaving is subtracted from every output, and one nobody is left watching is let go", async (t) => {
|
|
164
|
+
const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
|
|
165
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
166
|
+
// Watching all three, which is what a viewer who picked a language and a
|
|
167
|
+
// quality is doing.
|
|
168
|
+
manager.viewers.of(base, VIEWER).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
169
|
+
manager.viewers.of(step, VIEWER);
|
|
170
|
+
manager.viewers.of(audio, VIEWER);
|
|
171
|
+
assert.deepEqual(
|
|
172
|
+
[...manager.viewers.of(base, VIEWER).outputs].sort(),
|
|
173
|
+
[BASE_ID, STEP_ID, AUDIO_ID].sort(),
|
|
174
|
+
"one viewer, one object, and it knows all three outputs it is watching"
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
|
|
178
|
+
|
|
179
|
+
assert.equal(manager.sessionsById.has(BASE_ID), false, "the picture goes with its last consumer");
|
|
180
|
+
assert.equal(
|
|
181
|
+
manager.sessionsById.has(STEP_ID),
|
|
182
|
+
false,
|
|
183
|
+
"and so does the quality step: nobody is watching it, and no one outside this class knows its id"
|
|
184
|
+
);
|
|
185
|
+
assert.equal(
|
|
186
|
+
manager.sessionsById.has(AUDIO_ID),
|
|
187
|
+
false,
|
|
188
|
+
"and the soundtrack, for the same reason — it used to sit for half an hour holding an encoder, a directory and a claim on the torrent"
|
|
189
|
+
);
|
|
190
|
+
assert.equal(manager.viewers.size, 0, "and nobody is left in the registry, which must not be a map that only grows");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("an output somebody else is still watching is kept when one viewer leaves", async (t) => {
|
|
194
|
+
const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
|
|
195
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
196
|
+
const second = "viewer-two";
|
|
197
|
+
base.consumers.add(second);
|
|
198
|
+
manager.viewers.of(base, VIEWER).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
199
|
+
manager.viewers.of(step, VIEWER);
|
|
200
|
+
manager.viewers.of(audio, VIEWER);
|
|
201
|
+
// The second viewer is listening to the same soundtrack and watching the
|
|
202
|
+
// picture at its own height.
|
|
203
|
+
manager.viewers.of(base, second).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
204
|
+
manager.viewers.of(audio, second);
|
|
205
|
+
|
|
206
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the first viewer left");
|
|
207
|
+
|
|
208
|
+
assert.equal(manager.sessionsById.has(BASE_ID), true, "the picture stays: it still has a consumer");
|
|
209
|
+
assert.equal(
|
|
210
|
+
manager.sessionsById.has(AUDIO_ID),
|
|
211
|
+
true,
|
|
212
|
+
"and the soundtrack stays, because having no listeners is what kills it and it has one"
|
|
213
|
+
);
|
|
214
|
+
assert.equal(
|
|
215
|
+
base.viewers.has(VIEWER),
|
|
216
|
+
false,
|
|
217
|
+
"the viewer who left is gone from the picture"
|
|
218
|
+
);
|
|
219
|
+
assert.equal(
|
|
220
|
+
audio.viewers.has(VIEWER),
|
|
221
|
+
false,
|
|
222
|
+
"and from the soundtrack, which is the half of the relation the viewer holds"
|
|
223
|
+
);
|
|
224
|
+
assert.equal(step.viewers.size, 0, "the step they had is watched by nobody");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("leaving an output releases what the watching claimed of production", async (t) => {
|
|
228
|
+
const { manager, base, step, audio, dirPath, released } = await pictureWithStepAndSoundtrack();
|
|
229
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
230
|
+
manager.viewers.of(base, VIEWER).head = { segment: 25, seconds: 100, at: Date.now() };
|
|
231
|
+
manager.viewers.of(step, VIEWER);
|
|
232
|
+
manager.viewers.of(audio, VIEWER);
|
|
233
|
+
|
|
234
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
|
|
235
|
+
|
|
236
|
+
for (const id of [BASE_ID, STEP_ID, AUDIO_ID]) {
|
|
237
|
+
assert.ok(
|
|
238
|
+
released.includes(`${id}:${VIEWER}`),
|
|
239
|
+
`the claim on ${id.slice(0, 8)} is released — the plan's own pass cannot do it once the viewer is out of the map`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("the two indexes of one relation are written together", () => {
|
|
245
|
+
const viewers = new Viewers();
|
|
246
|
+
const picture = { id: "picture" };
|
|
247
|
+
const soundtrack = { id: "soundtrack" };
|
|
248
|
+
|
|
249
|
+
const viewer = viewers.of(picture, VIEWER);
|
|
250
|
+
viewers.of(soundtrack, VIEWER);
|
|
251
|
+
assert.equal(viewers.of(soundtrack, VIEWER), viewer, "one person is one object, whatever they are watching");
|
|
252
|
+
assert.deepEqual([...viewer.outputs].sort(), ["picture", "soundtrack"]);
|
|
253
|
+
assert.equal(picture.viewers.get(VIEWER), viewer);
|
|
254
|
+
|
|
255
|
+
viewers.leaves(soundtrack, VIEWER);
|
|
256
|
+
assert.deepEqual([...viewer.outputs], ["picture"], "both directions go together");
|
|
257
|
+
assert.equal(soundtrack.viewers.has(VIEWER), false);
|
|
258
|
+
assert.equal(viewers.size, 1, "and the person is still known, because they are still watching something");
|
|
259
|
+
|
|
260
|
+
viewers.leaves(picture, VIEWER);
|
|
261
|
+
assert.equal(viewers.size, 0, "watching nothing, they are forgotten");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("a viewer that cannot name itself belongs to the session that met it", () => {
|
|
265
|
+
const viewers = new Viewers();
|
|
266
|
+
const oneFilm = { id: "one" };
|
|
267
|
+
const another = { id: "another" };
|
|
268
|
+
|
|
269
|
+
const first = viewers.of(oneFilm, "");
|
|
270
|
+
const second = viewers.of(another, "");
|
|
271
|
+
assert.notEqual(first, second, "two anonymous viewers of two films are not one person");
|
|
272
|
+
assert.equal(viewers.size, 0, "and neither is in the registry, which is keyed by a name they do not have");
|
|
273
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One object per person watching, and one deletion to forget them.
|
|
3
|
+
*
|
|
4
|
+
* It was six maps hung on the session, each keyed by consumer id, and the
|
|
5
|
+
* forgetting was already wrong: releasing a consumer emptied none of them, so a
|
|
6
|
+
* viewer who had left went on counting as wanting their soundtrack until their
|
|
7
|
+
* head expired, and their entries stayed for the life of the session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
17
|
+
import { Viewer, viewerOf, viewersOf } from "../services/viewer/Viewer.js";
|
|
18
|
+
|
|
19
|
+
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
20
|
+
|
|
21
|
+
test("a viewer nobody has heard from is not watching", () => {
|
|
22
|
+
const viewer = new Viewer("someone");
|
|
23
|
+
const now = 1_000_000;
|
|
24
|
+
|
|
25
|
+
assert.equal(viewer.isLive(now, 60_000), false, "no head at all: they have never asked");
|
|
26
|
+
viewer.head = { segment: 4, seconds: 16, at: now - 10_000 };
|
|
27
|
+
assert.equal(viewer.isLive(now, 60_000), true);
|
|
28
|
+
viewer.head = { segment: 4, seconds: 16, at: now - 120_000 };
|
|
29
|
+
assert.equal(viewer.isLive(now, 60_000), false, "the tab is gone and nothing released it");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a stated position outranks the segment they last asked for", () => {
|
|
33
|
+
const viewer = new Viewer("someone");
|
|
34
|
+
assert.equal(viewer.positionSeconds(), null);
|
|
35
|
+
|
|
36
|
+
viewer.head = { segment: 10, seconds: 40, at: 1 };
|
|
37
|
+
assert.equal(viewer.positionSeconds(), 40, "where their player is reading");
|
|
38
|
+
|
|
39
|
+
viewer.head = { segment: 10, seconds: 40, at: 1, seeked: 900 };
|
|
40
|
+
assert.equal(viewer.positionSeconds(), 900, "a seek is the viewer saying where they are");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("asking for a viewer twice is the same viewer", () => {
|
|
44
|
+
const session = {};
|
|
45
|
+
const first = viewerOf(session, "one");
|
|
46
|
+
first.audio = { trackIndex: 3, transcode: false };
|
|
47
|
+
|
|
48
|
+
assert.equal(viewerOf(session, "one"), first);
|
|
49
|
+
assert.equal(viewerOf(session, "one").audio.trackIndex, 3);
|
|
50
|
+
assert.notEqual(viewerOf(session, "two"), first);
|
|
51
|
+
assert.equal(viewersOf(session).size, 2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("releasing a consumer forgets everything that was true of them alone", async (t) => {
|
|
55
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-"));
|
|
56
|
+
t.after(async () => {
|
|
57
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
const manager = new HlsSessionManager({
|
|
60
|
+
enabled: true,
|
|
61
|
+
ffmpegBin: "ffmpeg",
|
|
62
|
+
localBindHost: "127.0.0.1",
|
|
63
|
+
localPort: 9090
|
|
64
|
+
});
|
|
65
|
+
const session = {
|
|
66
|
+
id: SESSION_ID,
|
|
67
|
+
dirPath,
|
|
68
|
+
state: "live",
|
|
69
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
|
|
70
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
71
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
72
|
+
get inputFile() { return this.file; },
|
|
73
|
+
get audioFile() { return this.file; },
|
|
74
|
+
consumers: new Set(["staying", "leaving"]),
|
|
75
|
+
viewers: new Map(),
|
|
76
|
+
lastAccessedAt: Date.now()
|
|
77
|
+
};
|
|
78
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
79
|
+
|
|
80
|
+
const leaving = viewerOf(session, "leaving");
|
|
81
|
+
leaving.audio = { trackIndex: 2, transcode: true };
|
|
82
|
+
leaving.activeVariantId = "some-variant";
|
|
83
|
+
leaving.head = { segment: 40, seconds: 160, at: Date.now() };
|
|
84
|
+
viewerOf(session, "staying").audio = { trackIndex: 0, transcode: false };
|
|
85
|
+
|
|
86
|
+
await manager.releaseSessionConsumer(SESSION_ID, "leaving", "the tab was closed");
|
|
87
|
+
|
|
88
|
+
assert.equal(session.viewers.has("leaving"), false, "one deletion, not six");
|
|
89
|
+
assert.equal(session.viewers.has("staying"), true, "and it takes nobody else with it");
|
|
90
|
+
assert.equal(session.consumers.size, 1);
|
|
91
|
+
});
|
package/utils/perf.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* somewhere specific.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { monitorEventLoopDelay
|
|
24
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
25
25
|
|
|
26
26
|
const NANOSECONDS_PER_MILLISECOND = 1e6;
|
|
27
27
|
// Sampling interval for the loop-delay histogram. 20 ms is fine enough to catch
|
|
@@ -57,65 +57,3 @@ export function eventLoopDelay() {
|
|
|
57
57
|
export function resetEventLoopDelay() {
|
|
58
58
|
loopDelay.reset();
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Accumulates how long the parts of one operation took.
|
|
63
|
-
*
|
|
64
|
-
* Usage: `mark()` after each step; `summary()` renders `step=12.3ms` pairs in
|
|
65
|
-
* the order they were marked.
|
|
66
|
-
*/
|
|
67
|
-
export class OperationTimer {
|
|
68
|
-
#startedAt;
|
|
69
|
-
#lastMarkAt;
|
|
70
|
-
#marks;
|
|
71
|
-
|
|
72
|
-
constructor() {
|
|
73
|
-
this.#startedAt = performance.now();
|
|
74
|
-
this.#lastMarkAt = this.#startedAt;
|
|
75
|
-
this.#marks = [];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Record the time since the previous mark under `name`.
|
|
80
|
-
*
|
|
81
|
-
* @param {string} name
|
|
82
|
-
* @returns {number} Milliseconds since the previous mark.
|
|
83
|
-
*/
|
|
84
|
-
mark(name) {
|
|
85
|
-
const now = performance.now();
|
|
86
|
-
const elapsed = now - this.#lastMarkAt;
|
|
87
|
-
this.#lastMarkAt = now;
|
|
88
|
-
this.#marks.push([name, elapsed]);
|
|
89
|
-
return elapsed;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Add a figure measured elsewhere (a running total, a count) so it appears in
|
|
94
|
-
* the same line as the timings.
|
|
95
|
-
*
|
|
96
|
-
* @param {string} name
|
|
97
|
-
* @param {number} value
|
|
98
|
-
* @returns {void}
|
|
99
|
-
*/
|
|
100
|
-
add(name, value) {
|
|
101
|
-
this.#marks.push([name, value]);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Total elapsed time since construction, in milliseconds.
|
|
106
|
-
*
|
|
107
|
-
* @returns {number}
|
|
108
|
-
*/
|
|
109
|
-
totalMs() {
|
|
110
|
-
return performance.now() - this.#startedAt;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* All marks as `name=12.3ms` pairs, in order.
|
|
115
|
-
*
|
|
116
|
-
* @returns {string}
|
|
117
|
-
*/
|
|
118
|
-
summary() {
|
|
119
|
-
return this.#marks.map(([name, value]) => `${name}=${value.toFixed(1)}ms`).join(" ");
|
|
120
|
-
}
|
|
121
|
-
}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
export { Container } from "./Container.js";
|
|
2
|
-
export { MatroskaContainer } from "./MatroskaContainer.js";
|
|
3
|
-
export { Mp4Container } from "./Mp4Container.js";
|
|
4
|
-
export { AviContainer } from "./AviContainer.js";
|
|
5
|
-
export { SubtitleFileContainer, SUBTITLE_FILE_EXTENSIONS } from "./SubtitleFileContainer.js";
|
|
6
|
-
export { ContainerFactory } from "./ContainerFactory.js";
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Keyframe index for AVI, read without downloading the file.
|
|
3
|
-
*
|
|
4
|
-
* AVI ends with an `idx1` chunk: one fixed-size entry per stream chunk, each
|
|
5
|
-
* carrying a flags word whose keyframe bit says whether that chunk starts a
|
|
6
|
-
* keyframe. Frame number times the video stream's frame duration gives the
|
|
7
|
-
* time, so the index alone is enough — no media has to be read.
|
|
8
|
-
*
|
|
9
|
-
* `idx1` lives at the end of the file and the top-level chunk headers state
|
|
10
|
-
* their sizes, so it is reached by stepping over headers (typically two hops:
|
|
11
|
-
* `LIST hdrl`, `LIST movi`), not by scanning.
|
|
12
|
-
*
|
|
13
|
-
* Still relevant despite the format's age: older releases are largely XviD in
|
|
14
|
-
* AVI, and those are exactly the files that get copied rather than re-encoded.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const HEADER_BYTES = 8;
|
|
18
|
-
const PROBE_BYTES = 4096;
|
|
19
|
-
// Keyframe flag in an idx1 entry's flags word (AVIIF_KEYFRAME).
|
|
20
|
-
const KEYFRAME_FLAG = 0x10;
|
|
21
|
-
const IDX1_ENTRY_BYTES = 16;
|
|
22
|
-
// Cap on the idx1 read. One entry per chunk, 16 bytes each — a long film runs
|
|
23
|
-
// to a few MB; beyond this is not a normal index.
|
|
24
|
-
const MAX_IDX1_BYTES = 64 * 1024 * 1024;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Whether this looks like AVI: a RIFF container whose form type is `AVI `.
|
|
28
|
-
*
|
|
29
|
-
* @param {Buffer} head
|
|
30
|
-
* @returns {boolean}
|
|
31
|
-
*/
|
|
32
|
-
export function isAvi(head) {
|
|
33
|
-
return (
|
|
34
|
-
head.length >= 12 &&
|
|
35
|
-
head.toString("latin1", 0, 4) === "RIFF" &&
|
|
36
|
-
head.toString("latin1", 8, 12) === "AVI "
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Microseconds per frame and the video stream's chunk id prefix, from the main
|
|
42
|
-
* header. Both live in the `hdrl` list near the file start.
|
|
43
|
-
*
|
|
44
|
-
* @param {Buffer} head
|
|
45
|
-
* @returns {{ microsecondsPerFrame: number } | null}
|
|
46
|
-
*/
|
|
47
|
-
function readMainHeader(head) {
|
|
48
|
-
// Top-level: "RIFF" size "AVI " then chunks. `avih` sits inside `LIST hdrl`.
|
|
49
|
-
let offset = 12;
|
|
50
|
-
while (offset + HEADER_BYTES <= head.length) {
|
|
51
|
-
const id = head.toString("latin1", offset, offset + 4);
|
|
52
|
-
const size = head.readUInt32LE(offset + 4);
|
|
53
|
-
if (size <= 0) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
if (id === "LIST") {
|
|
57
|
-
// Descend: list type follows the header, then its own chunks.
|
|
58
|
-
const listType = head.toString("latin1", offset + 8, offset + 12);
|
|
59
|
-
if (listType === "hdrl") {
|
|
60
|
-
let inner = offset + 12;
|
|
61
|
-
while (inner + HEADER_BYTES <= Math.min(head.length, offset + 8 + size)) {
|
|
62
|
-
const innerId = head.toString("latin1", inner, inner + 4);
|
|
63
|
-
const innerSize = head.readUInt32LE(inner + 4);
|
|
64
|
-
if (innerSize <= 0) {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
if (innerId === "avih" && inner + 8 + 4 <= head.length) {
|
|
68
|
-
return { microsecondsPerFrame: head.readUInt32LE(inner + 8) };
|
|
69
|
-
}
|
|
70
|
-
inner += HEADER_BYTES + innerSize + (innerSize % 2);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
offset += HEADER_BYTES + 4 + (size - 4) + ((size - 4) % 2);
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
offset += HEADER_BYTES + size + (size % 2);
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Step over top-level chunks to find `idx1`.
|
|
83
|
-
*
|
|
84
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
85
|
-
* @param {number} fileSize
|
|
86
|
-
* @returns {Promise<{ offset: number, size: number } | null>}
|
|
87
|
-
*/
|
|
88
|
-
async function findIdx1(readRange, fileSize) {
|
|
89
|
-
let offset = 12; // Past "RIFF" size "AVI ".
|
|
90
|
-
while (offset + HEADER_BYTES < fileSize) {
|
|
91
|
-
const probe = await readRange(offset, Math.min(fileSize - 1, offset + HEADER_BYTES - 1));
|
|
92
|
-
if (!probe || probe.length < HEADER_BYTES) {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
const id = probe.toString("latin1", 0, 4);
|
|
96
|
-
const size = probe.readUInt32LE(4);
|
|
97
|
-
if (size <= 0) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
if (id === "idx1") {
|
|
101
|
-
return { offset: offset + HEADER_BYTES, size };
|
|
102
|
-
}
|
|
103
|
-
// Chunks are word-aligned; a LIST carries its type inside the payload, so
|
|
104
|
-
// the same size arithmetic covers both cases.
|
|
105
|
-
offset += HEADER_BYTES + size + (size % 2);
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Read the keyframe times of an AVI file.
|
|
112
|
-
*
|
|
113
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
114
|
-
* @param {number} fileSize
|
|
115
|
-
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
116
|
-
* has no `idx1` (OpenDML-only index, interrupted write, damaged upload).
|
|
117
|
-
*/
|
|
118
|
-
export async function readAviKeyframeTimes(readRange, fileSize) {
|
|
119
|
-
const head = await readRange(0, Math.min(PROBE_BYTES - 1, fileSize - 1));
|
|
120
|
-
if (!head || !isAvi(head)) {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
const mainHeader = readMainHeader(head);
|
|
124
|
-
if (!mainHeader || !mainHeader.microsecondsPerFrame) {
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const idx1 = await findIdx1(readRange, fileSize);
|
|
129
|
-
if (!idx1 || idx1.size > MAX_IDX1_BYTES) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const table = await readRange(idx1.offset, Math.min(fileSize - 1, idx1.offset + idx1.size - 1));
|
|
134
|
-
if (!table || table.length < IDX1_ENTRY_BYTES) {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const secondsPerFrame = mainHeader.microsecondsPerFrame / 1e6;
|
|
139
|
-
const times = [];
|
|
140
|
-
let videoFrame = 0;
|
|
141
|
-
for (let at = 0; at + IDX1_ENTRY_BYTES <= table.length; at += IDX1_ENTRY_BYTES) {
|
|
142
|
-
const chunkId = table.toString("latin1", at, at + 4);
|
|
143
|
-
// Video chunks are "##db" (uncompressed) or "##dc" (compressed); audio is
|
|
144
|
-
// "##wb" and must not advance the frame counter.
|
|
145
|
-
const isVideo = chunkId.endsWith("db") || chunkId.endsWith("dc");
|
|
146
|
-
if (!isVideo) {
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
const flags = table.readUInt32LE(at + 4);
|
|
150
|
-
if ((flags & KEYFRAME_FLAG) !== 0) {
|
|
151
|
-
times.push(videoFrame * secondsPerFrame);
|
|
152
|
-
}
|
|
153
|
-
videoFrame += 1;
|
|
154
|
-
}
|
|
155
|
-
if (times.length === 0) {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
// AVI names a keyframe by its FRAME NUMBER, and the time above is that number
|
|
159
|
-
// multiplied by the frame duration the header declares. The frames are the
|
|
160
|
-
// right ones — measured 2026-08-21 against the files themselves, 1196 index
|
|
161
|
-
// entries against 1196 real keyframes and 901 against 901, exactly — but the
|
|
162
|
-
// names are 10-44 ms away from the presentation times the demuxer computes,
|
|
163
|
-
// always under one frame. So the caller is told how far a time here may be
|
|
164
|
-
// from the instant it refers to, and can ask for a seek late enough that it
|
|
165
|
-
// still lands on the frame rather than on the one before it.
|
|
166
|
-
return { times, tolerance: secondsPerFrame };
|
|
167
|
-
}
|