@torrent-tv/proxy 2.80.10 → 2.80.11
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 +1685 -1675
- package/package.json +50 -51
- package/services/encode/run-command.js +729 -707
- package/services/hls-session-manager.js +10168 -10388
- package/services/orchestrators/EncodeOrchestrator.js +726 -726
- package/services/output/LiveOutputs.js +49 -0
- package/services/priority/PriorityOrchestrator.js +278 -174
- package/test/decode-cost-fit.test.js +57 -0
- package/{test-measured/decode-cost.measured.js → test/decode-cost.test.js} +13 -36
- package/test/keyframe-table-budget.test.js +7 -1
- package/test/one-authority.test.js +220 -105
- package/test/piece-store-slow-disk.test.js +32 -2
- package/test/priority-map-per-output.test.js +211 -0
- package/test/quality-variants.test.js +1222 -1209
- package/test/tracks-begin-together.test.js +32 -13
- package/test/tunnel-renewal.test.js +34 -5
- package/test/two-viewers-one-picture.test.js +374 -347
- package/test/viewer-outputs.test.js +277 -278
- package/test-measured/decode-measurement.measured.js +0 -83
|
@@ -1,278 +1,277 @@
|
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
viewer.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
await manager.resolveVariantFile(BASE_ID,
|
|
143
|
-
await manager.resolveVariantFile(BASE_ID,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
assert.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
manager.viewers.of(
|
|
162
|
-
manager.viewers.of(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
[
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
assert.equal(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
base.
|
|
191
|
-
manager.viewers.of(
|
|
192
|
-
manager.viewers.of(
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
manager.viewers.of(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
assert.equal(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
manager.viewers.of(
|
|
224
|
-
manager.viewers.of(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
viewers.of(soundtrack, VIEWER);
|
|
256
|
-
assert.
|
|
257
|
-
assert.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
assert.
|
|
262
|
-
assert.equal(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
viewers.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
assert.
|
|
277
|
-
|
|
278
|
-
});
|
|
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
|
+
failedStartAt: -1,
|
|
86
|
+
failedStartCount: 0,
|
|
87
|
+
seekSettleTimer: null,
|
|
88
|
+
seekTarget: null,
|
|
89
|
+
waitEpoch: 0,
|
|
90
|
+
runs: new Set(),
|
|
91
|
+
viewers: new Map(),
|
|
92
|
+
usesExplicitCuts: false,
|
|
93
|
+
useSyntheticPlaylist: true,
|
|
94
|
+
playlistText: "#EXTM3U\n",
|
|
95
|
+
segmentCount: 100,
|
|
96
|
+
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A picture with one quality step and one separately published soundtrack, all
|
|
102
|
+
* of one file, and one viewer watching all three — which is what an ordinary
|
|
103
|
+
* session looks like once the viewer has picked a language and a quality.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<{ manager: HlsSessionManager, base: object, step: object,
|
|
106
|
+
* audio: object, dirPath: string, released: string[] }>}
|
|
107
|
+
*/
|
|
108
|
+
async function pictureWithStepAndSoundtrack() {
|
|
109
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-outputs-"));
|
|
110
|
+
const manager = new HlsSessionManager({
|
|
111
|
+
enabled: true,
|
|
112
|
+
ffmpegBin: "ffmpeg",
|
|
113
|
+
localBindHost: "127.0.0.1",
|
|
114
|
+
localPort: 9090
|
|
115
|
+
});
|
|
116
|
+
const file = new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" })
|
|
117
|
+
.learn({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 });
|
|
118
|
+
const base = fakeSession({ id: BASE_ID, dirPath, file, encodeHeight: 812 });
|
|
119
|
+
const step = fakeSession({ id: STEP_ID, dirPath, file, encodeHeight: 540, isStep: true });
|
|
120
|
+
const audio = fakeSession({ id: AUDIO_ID, dirPath, file, audioOnly: true });
|
|
121
|
+
base.consumers = new Set([VIEWER]);
|
|
122
|
+
// The family's own claim, which is how a picture holds what it made: the
|
|
123
|
+
// browser never learns these two ids.
|
|
124
|
+
step.consumers = new Set([variantConsumerId(BASE_ID)]);
|
|
125
|
+
audio.consumers = new Set([variantConsumerId(BASE_ID)]);
|
|
126
|
+
for (const session of [base, step, audio]) {
|
|
127
|
+
manager.sessionsById.set(session.id, session);
|
|
128
|
+
}
|
|
129
|
+
file.stepHeights.set(540, 540);
|
|
130
|
+
return { manager, base, step, audio, dirPath };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test("a viewer who steps down, back to the picture's own height and down again keeps their record", async (t) => {
|
|
134
|
+
const { manager, base, dirPath } = await pictureWithStepAndSoundtrack();
|
|
135
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
136
|
+
startRunOn(base, { process: fakeEncoder() });
|
|
137
|
+
const viewer = manager.viewers.of(base, VIEWER);
|
|
138
|
+
viewer.audio = { trackIndex: 1, transcode: true };
|
|
139
|
+
viewer.position = { segment: 25, seconds: 100, at: Date.now() };
|
|
140
|
+
|
|
141
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4", VIEWER);
|
|
142
|
+
await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4", VIEWER);
|
|
143
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00027.mp4", VIEWER);
|
|
144
|
+
|
|
145
|
+
const known = base.viewers.get(VIEWER);
|
|
146
|
+
assert.ok(known, "the picture is the one id the browser holds — a viewer is never dropped from it");
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
known.audio,
|
|
149
|
+
{ trackIndex: 1, transcode: true },
|
|
150
|
+
"the soundtrack they chose is recorded on the picture, and a quality switch does not touch it"
|
|
151
|
+
);
|
|
152
|
+
assert.equal(known.activeVariantId, STEP_ID, "and the step they moved to is where they are");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a viewer leaving is subtracted from every output, and one nobody is left watching is let go", async (t) => {
|
|
156
|
+
const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
|
|
157
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
158
|
+
// Watching all three, which is what a viewer who picked a language and a
|
|
159
|
+
// quality is doing.
|
|
160
|
+
manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
161
|
+
manager.viewers.of(step, VIEWER);
|
|
162
|
+
manager.viewers.of(audio, VIEWER);
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
[...manager.viewers.of(base, VIEWER).outputs].sort(),
|
|
165
|
+
[BASE_ID, STEP_ID, AUDIO_ID].sort(),
|
|
166
|
+
"one viewer, one object, and it knows all three outputs it is watching"
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
|
|
170
|
+
|
|
171
|
+
assert.equal(manager.sessionsById.has(BASE_ID), false, "the picture goes with its last consumer");
|
|
172
|
+
assert.equal(
|
|
173
|
+
manager.sessionsById.has(STEP_ID),
|
|
174
|
+
false,
|
|
175
|
+
"and so does the quality step: nobody is watching it, and no one outside this class knows its id"
|
|
176
|
+
);
|
|
177
|
+
assert.equal(
|
|
178
|
+
manager.sessionsById.has(AUDIO_ID),
|
|
179
|
+
false,
|
|
180
|
+
"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"
|
|
181
|
+
);
|
|
182
|
+
assert.equal(manager.viewers.size, 0, "and nobody is left in the registry, which must not be a map that only grows");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("an output somebody else is still watching is kept when one viewer leaves", async (t) => {
|
|
186
|
+
const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
|
|
187
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
188
|
+
const second = "viewer-two";
|
|
189
|
+
base.consumers.add(second);
|
|
190
|
+
manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
191
|
+
manager.viewers.of(step, VIEWER);
|
|
192
|
+
manager.viewers.of(audio, VIEWER);
|
|
193
|
+
// The second viewer is listening to the same soundtrack and watching the
|
|
194
|
+
// picture at its own height.
|
|
195
|
+
manager.viewers.of(base, second).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
196
|
+
manager.viewers.of(audio, second);
|
|
197
|
+
|
|
198
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the first viewer left");
|
|
199
|
+
|
|
200
|
+
assert.equal(manager.sessionsById.has(BASE_ID), true, "the picture stays: it still has a consumer");
|
|
201
|
+
assert.equal(
|
|
202
|
+
manager.sessionsById.has(AUDIO_ID),
|
|
203
|
+
true,
|
|
204
|
+
"and the soundtrack stays, because having no listeners is what kills it and it has one"
|
|
205
|
+
);
|
|
206
|
+
assert.equal(
|
|
207
|
+
base.viewers.has(VIEWER),
|
|
208
|
+
false,
|
|
209
|
+
"the viewer who left is gone from the picture"
|
|
210
|
+
);
|
|
211
|
+
assert.equal(
|
|
212
|
+
audio.viewers.has(VIEWER),
|
|
213
|
+
false,
|
|
214
|
+
"and from the soundtrack, which is the half of the relation the viewer holds"
|
|
215
|
+
);
|
|
216
|
+
assert.equal(step.viewers.size, 0, "the step they had is watched by nobody");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("nothing is left wanting production once the last viewer has left", async (t) => {
|
|
220
|
+
const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
|
|
221
|
+
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
222
|
+
manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
223
|
+
manager.viewers.of(step, VIEWER);
|
|
224
|
+
manager.viewers.of(audio, VIEWER);
|
|
225
|
+
|
|
226
|
+
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
|
|
227
|
+
|
|
228
|
+
// NOTHING HAS TO BE RELEASED, and that is the point of the shape. A claim per
|
|
229
|
+
// viewer per output used to be held in the encoding layer and taken back one
|
|
230
|
+
// by one when they left — the viewer's own name as the key of a claim, which
|
|
231
|
+
// is the coupling the layer rule forbids. What the plan is given now is a map
|
|
232
|
+
// with nobody's name in it, rebuilt from whoever is watching; a viewer who has
|
|
233
|
+
// left is simply not in the next one.
|
|
234
|
+
for (const id of [BASE_ID, STEP_ID, AUDIO_ID]) {
|
|
235
|
+
const session = manager.sessionsById.get(id);
|
|
236
|
+
assert.equal(session, undefined, `${id.slice(0, 8)} is let go with its last viewer`);
|
|
237
|
+
}
|
|
238
|
+
manager.planEncodersNow();
|
|
239
|
+
for (const address of [base.outputKey, step.outputKey, audio.outputKey]) {
|
|
240
|
+
assert.deepEqual(
|
|
241
|
+
manager.encodeOrchestrator.demand.mapOn(address),
|
|
242
|
+
[],
|
|
243
|
+
"and no output is left asking for an encoder"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("the two indexes of one relation are written together", () => {
|
|
249
|
+
const viewers = new Viewers();
|
|
250
|
+
const picture = { id: "picture" };
|
|
251
|
+
const soundtrack = { id: "soundtrack" };
|
|
252
|
+
|
|
253
|
+
const viewer = viewers.of(picture, VIEWER);
|
|
254
|
+
viewers.of(soundtrack, VIEWER);
|
|
255
|
+
assert.equal(viewers.of(soundtrack, VIEWER), viewer, "one person is one object, whatever they are watching");
|
|
256
|
+
assert.deepEqual([...viewer.outputs].sort(), ["picture", "soundtrack"]);
|
|
257
|
+
assert.equal(picture.viewers.get(VIEWER), viewer);
|
|
258
|
+
|
|
259
|
+
viewers.leaves(soundtrack, VIEWER);
|
|
260
|
+
assert.deepEqual([...viewer.outputs], ["picture"], "both directions go together");
|
|
261
|
+
assert.equal(soundtrack.viewers.has(VIEWER), false);
|
|
262
|
+
assert.equal(viewers.size, 1, "and the person is still known, because they are still watching something");
|
|
263
|
+
|
|
264
|
+
viewers.leaves(picture, VIEWER);
|
|
265
|
+
assert.equal(viewers.size, 0, "watching nothing, they are forgotten");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("a viewer that cannot name itself belongs to the session that met it", () => {
|
|
269
|
+
const viewers = new Viewers();
|
|
270
|
+
const oneFilm = { id: "one" };
|
|
271
|
+
const another = { id: "another" };
|
|
272
|
+
|
|
273
|
+
const first = viewers.of(oneFilm, "");
|
|
274
|
+
const second = viewers.of(another, "");
|
|
275
|
+
assert.notEqual(first, second, "two anonymous viewers of two films are not one person");
|
|
276
|
+
assert.equal(viewers.size, 0, "and neither is in the registry, which is keyed by a name they do not have");
|
|
277
|
+
});
|