@torrent-tv/proxy 2.76.6 → 2.78.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 +1558 -1538
- package/bin/cli.js +14 -1
- package/package.json +1 -1
- package/services/data-channel-handler.js +83 -3
- package/services/encode/DemandMap.js +187 -0
- package/services/encode/EncodePlan.js +283 -272
- package/services/encode/SegmentDemand.js +0 -0
- package/services/hls-session-manager.js +476 -179
- package/services/orchestrators/EncodeOrchestrator.js +13 -3
- package/services/viewer/Viewer.js +214 -145
- package/services/viewer/Viewers.js +213 -124
- package/test/behind-head-repair.test.js +2 -2
- package/test/demand-map.test.js +102 -0
- package/test/encode-plan.test.js +44 -2
- package/test/failed-start-breaker.test.js +151 -0
- package/test/orchestrator-wired.test.js +190 -164
- package/test/quality-variants.test.js +2 -2
- package/test/run-intervals.test.js +67 -303
- package/test/seek-target-not-superseded.test.js +2 -2
- package/test/sidecar-naming.test.js +120 -120
- package/test/stale-request-after-seek.test.js +2 -2
- package/test/two-viewers-one-picture.test.js +2 -2
- package/test/viewer-outputs.test.js +7 -7
- package/test/viewer-presence.test.js +119 -0
- package/test/viewer.test.js +27 -10
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What a torrent's own names say about a track shipped as its own file.
|
|
3
|
-
*
|
|
4
|
-
* The rule worth pinning is the releaser one, because getting it wrong invents
|
|
5
|
-
* an author. In the field case — `[HorribleSubs] Drifters - 02 [1080p].mka` in
|
|
6
|
-
* `Rus Sound/`, beside `[HorribleSubs] Drifters - 02 [1080p].mkv` — the dub
|
|
7
|
-
* carries the picture's own brackets and nothing else, so there is nothing
|
|
8
|
-
* truthful to say about who made it. The subtitles of the same release sit in
|
|
9
|
-
* `Sub/[Stan WarHammer & Nesitach]/`, a bracket the picture does not carry, and
|
|
10
|
-
* that one IS their author.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import test from "node:test";
|
|
14
|
-
import assert from "node:assert/strict";
|
|
1
|
+
/**
|
|
2
|
+
* @file What a torrent's own names say about a track shipped as its own file.
|
|
3
|
+
*
|
|
4
|
+
* The rule worth pinning is the releaser one, because getting it wrong invents
|
|
5
|
+
* an author. In the field case — `[HorribleSubs] Drifters - 02 [1080p].mka` in
|
|
6
|
+
* `Rus Sound/`, beside `[HorribleSubs] Drifters - 02 [1080p].mkv` — the dub
|
|
7
|
+
* carries the picture's own brackets and nothing else, so there is nothing
|
|
8
|
+
* truthful to say about who made it. The subtitles of the same release sit in
|
|
9
|
+
* `Sub/[Stan WarHammer & Nesitach]/`, a bracket the picture does not carry, and
|
|
10
|
+
* that one IS their author.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
15
|
import {
|
|
16
16
|
languageFromFolderNames,
|
|
17
17
|
releaserFrom,
|
|
@@ -34,109 +34,109 @@ function readPath(relativePath, videoName = "") {
|
|
|
34
34
|
});
|
|
35
35
|
return { ...naming, code: naming.code ?? "und", group: naming.releaser };
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const VIDEO_NAME = "[HorribleSubs] Drifters - 02 [1080p].mkv";
|
|
40
|
-
|
|
41
|
-
test("a folder naming a language is read, whole segment or word", () => {
|
|
42
|
-
assert.equal(languageFromFolderNames(["ENG"])?.code, "en");
|
|
43
|
-
assert.equal(languageFromFolderNames(["Rus Sound"])?.code, "ru");
|
|
44
|
-
assert.equal(languageFromFolderNames(["Sub", "Russian"])?.code, "ru");
|
|
45
|
-
assert.equal(languageFromFolderNames([])?.code, undefined);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("the innermost folder answers first", () => {
|
|
49
|
-
assert.equal(languageFromFolderNames(["Russian", "English Dub"])?.code, "en");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("a two-letter word inside a longer folder name is not taken for a language", () => {
|
|
53
|
-
// "No" is Norwegian as a whole segment, but a word in `No Subs` is a word.
|
|
54
|
-
assert.equal(languageFromFolderNames(["No Subs"]), null);
|
|
55
|
-
assert.equal(languageFromFolderNames(["no"])?.code, "no");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("a bracket the picture also carries is not the sidecar's author", () => {
|
|
59
|
-
assert.equal(
|
|
60
|
-
releaserFrom({
|
|
61
|
-
folders: ["Rus Sound"],
|
|
62
|
-
fileName: "[HorribleSubs] Drifters - 02 [1080p].mka",
|
|
63
|
-
videoName: VIDEO_NAME
|
|
64
|
-
}),
|
|
65
|
-
null
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("a bracket only the sidecar carries is its author", () => {
|
|
70
|
-
assert.equal(
|
|
71
|
-
releaserFrom({
|
|
72
|
-
folders: ["Sub", "[Stan WarHammer & Nesitach]"],
|
|
73
|
-
fileName: "[HorribleSubs] Drifters - 02 [1080p].ass",
|
|
74
|
-
videoName: VIDEO_NAME
|
|
75
|
-
}),
|
|
76
|
-
"Stan WarHammer & Nesitach"
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("a bracket describing the encode is never an author", () => {
|
|
81
|
-
for (const token of ["1080p", "x264", "HEVC", "WEB-DL", "AAC", "5.1", "10bit", "78EFD746", "RUS"]) {
|
|
82
|
-
assert.equal(
|
|
83
|
-
releaserFrom({ folders: [], fileName: `Film [${token}].mka`, videoName: "Other.mkv" }),
|
|
84
|
-
null,
|
|
85
|
-
`[${token}] should not be read as a releaser`
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("the innermost folder is preferred to the file name", () => {
|
|
91
|
-
assert.equal(
|
|
92
|
-
releaserFrom({ folders: ["[TeamA]"], fileName: "[TeamB] ep.ass", videoName: "ep.mkv" }),
|
|
93
|
-
"TeamA"
|
|
94
|
-
);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("language and author together, for the field case", () => {
|
|
98
|
-
assert.deepEqual(
|
|
99
|
-
sidecarNaming({
|
|
100
|
-
folders: ["Rus Sound"],
|
|
101
|
-
fileName: "[HorribleSubs] Drifters - 02 [1080p].mka",
|
|
102
|
-
videoName: VIDEO_NAME
|
|
103
|
-
}),
|
|
104
|
-
{ code: "ru", name: "Russian", releaser: null, isForced: false, isHearingImpaired: false, isDefault: false }
|
|
105
|
-
);
|
|
106
|
-
assert.deepEqual(
|
|
107
|
-
sidecarNaming({
|
|
108
|
-
folders: ["Sub", "[Stan WarHammer & Nesitach]"],
|
|
109
|
-
fileName: "[HorribleSubs] Drifters - 02 [1080p].ass",
|
|
110
|
-
videoName: VIDEO_NAME
|
|
111
|
-
}),
|
|
112
|
-
{
|
|
113
|
-
code: null,
|
|
114
|
-
name: null,
|
|
115
|
-
releaser: "Stan WarHammer & Nesitach",
|
|
116
|
-
isForced: false,
|
|
117
|
-
isHearingImpaired: false,
|
|
118
|
-
isDefault: false
|
|
119
|
-
}
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("a subtitle file takes its author from the folder the release put it in", () => {
|
|
124
|
-
const info = readPath("Sub/[Stan WarHammer & Nesitach]/[HorribleSubs] Drifters - 02 [1080p].ass", VIDEO_NAME);
|
|
125
|
-
assert.equal(info.group, "Stan WarHammer & Nesitach");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("the picture's own release group is never reported as the translator", () => {
|
|
129
|
-
const info = readPath("Sub/[HorribleSubs] Drifters - 02 [1080p].ass", VIDEO_NAME);
|
|
130
|
-
assert.equal(info.group, null);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("the language of a subtitle folder is read where the whole segment is not a language", () => {
|
|
134
|
-
const info = readPath("Rus Sub/ep.srt", "ep.mkv");
|
|
135
|
-
assert.equal(info.code, "ru");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("what the rules answer with nothing to go on", () => {
|
|
139
|
-
const info = readPath("ep.srt");
|
|
140
|
-
assert.equal(info.code, "und");
|
|
141
|
-
assert.equal(info.group, null);
|
|
142
|
-
});
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
const VIDEO_NAME = "[HorribleSubs] Drifters - 02 [1080p].mkv";
|
|
40
|
+
|
|
41
|
+
test("a folder naming a language is read, whole segment or word", () => {
|
|
42
|
+
assert.equal(languageFromFolderNames(["ENG"])?.code, "en");
|
|
43
|
+
assert.equal(languageFromFolderNames(["Rus Sound"])?.code, "ru");
|
|
44
|
+
assert.equal(languageFromFolderNames(["Sub", "Russian"])?.code, "ru");
|
|
45
|
+
assert.equal(languageFromFolderNames([])?.code, undefined);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the innermost folder answers first", () => {
|
|
49
|
+
assert.equal(languageFromFolderNames(["Russian", "English Dub"])?.code, "en");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a two-letter word inside a longer folder name is not taken for a language", () => {
|
|
53
|
+
// "No" is Norwegian as a whole segment, but a word in `No Subs` is a word.
|
|
54
|
+
assert.equal(languageFromFolderNames(["No Subs"]), null);
|
|
55
|
+
assert.equal(languageFromFolderNames(["no"])?.code, "no");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a bracket the picture also carries is not the sidecar's author", () => {
|
|
59
|
+
assert.equal(
|
|
60
|
+
releaserFrom({
|
|
61
|
+
folders: ["Rus Sound"],
|
|
62
|
+
fileName: "[HorribleSubs] Drifters - 02 [1080p].mka",
|
|
63
|
+
videoName: VIDEO_NAME
|
|
64
|
+
}),
|
|
65
|
+
null
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a bracket only the sidecar carries is its author", () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
releaserFrom({
|
|
72
|
+
folders: ["Sub", "[Stan WarHammer & Nesitach]"],
|
|
73
|
+
fileName: "[HorribleSubs] Drifters - 02 [1080p].ass",
|
|
74
|
+
videoName: VIDEO_NAME
|
|
75
|
+
}),
|
|
76
|
+
"Stan WarHammer & Nesitach"
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a bracket describing the encode is never an author", () => {
|
|
81
|
+
for (const token of ["1080p", "x264", "HEVC", "WEB-DL", "AAC", "5.1", "10bit", "78EFD746", "RUS"]) {
|
|
82
|
+
assert.equal(
|
|
83
|
+
releaserFrom({ folders: [], fileName: `Film [${token}].mka`, videoName: "Other.mkv" }),
|
|
84
|
+
null,
|
|
85
|
+
`[${token}] should not be read as a releaser`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("the innermost folder is preferred to the file name", () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
releaserFrom({ folders: ["[TeamA]"], fileName: "[TeamB] ep.ass", videoName: "ep.mkv" }),
|
|
93
|
+
"TeamA"
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("language and author together, for the field case", () => {
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
sidecarNaming({
|
|
100
|
+
folders: ["Rus Sound"],
|
|
101
|
+
fileName: "[HorribleSubs] Drifters - 02 [1080p].mka",
|
|
102
|
+
videoName: VIDEO_NAME
|
|
103
|
+
}),
|
|
104
|
+
{ code: "ru", name: "Russian", releaser: null, isForced: false, isHearingImpaired: false, isDefault: false }
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
sidecarNaming({
|
|
108
|
+
folders: ["Sub", "[Stan WarHammer & Nesitach]"],
|
|
109
|
+
fileName: "[HorribleSubs] Drifters - 02 [1080p].ass",
|
|
110
|
+
videoName: VIDEO_NAME
|
|
111
|
+
}),
|
|
112
|
+
{
|
|
113
|
+
code: null,
|
|
114
|
+
name: null,
|
|
115
|
+
releaser: "Stan WarHammer & Nesitach",
|
|
116
|
+
isForced: false,
|
|
117
|
+
isHearingImpaired: false,
|
|
118
|
+
isDefault: false
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a subtitle file takes its author from the folder the release put it in", () => {
|
|
124
|
+
const info = readPath("Sub/[Stan WarHammer & Nesitach]/[HorribleSubs] Drifters - 02 [1080p].ass", VIDEO_NAME);
|
|
125
|
+
assert.equal(info.group, "Stan WarHammer & Nesitach");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("the picture's own release group is never reported as the translator", () => {
|
|
129
|
+
const info = readPath("Sub/[HorribleSubs] Drifters - 02 [1080p].ass", VIDEO_NAME);
|
|
130
|
+
assert.equal(info.group, null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("the language of a subtitle folder is read where the whole segment is not a language", () => {
|
|
134
|
+
const info = readPath("Rus Sub/ep.srt", "ep.mkv");
|
|
135
|
+
assert.equal(info.code, "ru");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("what the rules answer with nothing to go on", () => {
|
|
139
|
+
const info = readPath("ep.srt");
|
|
140
|
+
assert.equal(info.code, "und");
|
|
141
|
+
assert.equal(info.group, null);
|
|
142
|
+
});
|
|
@@ -79,8 +79,8 @@ async function sessionWithRunAt373() {
|
|
|
79
79
|
holdExplainedAt: new Map(),
|
|
80
80
|
seekSettleTimer: null,
|
|
81
81
|
seekTarget: null,
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
failedStartAt: -1,
|
|
83
|
+
failedStartCount: 0,
|
|
84
84
|
waitEpoch: 0,
|
|
85
85
|
firstSegmentLogged: true,
|
|
86
86
|
progress: { processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS, speed: "1.0x", startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
|
|
@@ -114,8 +114,8 @@ async function pictureWithTwoViewers() {
|
|
|
114
114
|
base.consumers = new Set([FIRST, SECOND]);
|
|
115
115
|
// Both viewers are watching the picture, which is what keeps their choices
|
|
116
116
|
// alive; a viewer whose head has expired holds no encoder.
|
|
117
|
-
viewerOf(base, FIRST).
|
|
118
|
-
viewerOf(base, SECOND).
|
|
117
|
+
viewerOf(base, FIRST).position = { segment: 3, seconds: 12, at: Date.now() };
|
|
118
|
+
viewerOf(base, SECOND).position = { segment: 3, seconds: 12, at: Date.now() };
|
|
119
119
|
viewerOf(base, FIRST).audio = { trackIndex: 0, transcode: true };
|
|
120
120
|
viewerOf(base, SECOND).audio = { trackIndex: 1, transcode: true };
|
|
121
121
|
manager.sessionsById.set(BASE_ID, base);
|
|
@@ -83,8 +83,8 @@ function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, i
|
|
|
83
83
|
}),
|
|
84
84
|
encodeRunGeneration: 0,
|
|
85
85
|
lastRestartAt: 0,
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
failedStartAt: -1,
|
|
87
|
+
failedStartCount: 0,
|
|
88
88
|
seekSettleTimer: null,
|
|
89
89
|
seekTarget: null,
|
|
90
90
|
waitEpoch: 0,
|
|
@@ -144,7 +144,7 @@ test("a viewer who steps down, back to the picture's own height and down again k
|
|
|
144
144
|
startRunOn(base, { process: fakeEncoder() });
|
|
145
145
|
const viewer = manager.viewers.of(base, VIEWER);
|
|
146
146
|
viewer.audio = { trackIndex: 1, transcode: true };
|
|
147
|
-
viewer.
|
|
147
|
+
viewer.position = { segment: 25, seconds: 100, at: Date.now() };
|
|
148
148
|
|
|
149
149
|
await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4", VIEWER);
|
|
150
150
|
await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4", VIEWER);
|
|
@@ -165,7 +165,7 @@ test("a viewer leaving is subtracted from every output, and one nobody is left w
|
|
|
165
165
|
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
166
166
|
// Watching all three, which is what a viewer who picked a language and a
|
|
167
167
|
// quality is doing.
|
|
168
|
-
manager.viewers.of(base, VIEWER).
|
|
168
|
+
manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
169
169
|
manager.viewers.of(step, VIEWER);
|
|
170
170
|
manager.viewers.of(audio, VIEWER);
|
|
171
171
|
assert.deepEqual(
|
|
@@ -195,12 +195,12 @@ test("an output somebody else is still watching is kept when one viewer leaves",
|
|
|
195
195
|
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
196
196
|
const second = "viewer-two";
|
|
197
197
|
base.consumers.add(second);
|
|
198
|
-
manager.viewers.of(base, VIEWER).
|
|
198
|
+
manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
199
199
|
manager.viewers.of(step, VIEWER);
|
|
200
200
|
manager.viewers.of(audio, VIEWER);
|
|
201
201
|
// The second viewer is listening to the same soundtrack and watching the
|
|
202
202
|
// picture at its own height.
|
|
203
|
-
manager.viewers.of(base, second).
|
|
203
|
+
manager.viewers.of(base, second).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
204
204
|
manager.viewers.of(audio, second);
|
|
205
205
|
|
|
206
206
|
await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the first viewer left");
|
|
@@ -227,7 +227,7 @@ test("an output somebody else is still watching is kept when one viewer leaves",
|
|
|
227
227
|
test("leaving an output releases what the watching claimed of production", async (t) => {
|
|
228
228
|
const { manager, base, step, audio, dirPath, released } = await pictureWithStepAndSoundtrack();
|
|
229
229
|
t.after(() => rm(dirPath, { recursive: true, force: true }));
|
|
230
|
-
manager.viewers.of(base, VIEWER).
|
|
230
|
+
manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
|
|
231
231
|
manager.viewers.of(step, VIEWER);
|
|
232
232
|
manager.viewers.of(audio, VIEWER);
|
|
233
233
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Presence is a fact of the connection, and it is not the same fact as
|
|
3
|
+
* position.
|
|
4
|
+
*
|
|
5
|
+
* Both halves failed together on 2026-09-05. A viewer who had arrived and asked
|
|
6
|
+
* for nothing counted as absent, so a soundtrack's encoder was stopped 1.25 s
|
|
7
|
+
* after starting with nothing produced — and its `init.mp4` was therefore never
|
|
8
|
+
* made, which is the one file the viewer needed in order to ask for the segment
|
|
9
|
+
* that would have marked them present. And nothing anywhere let a viewer go
|
|
10
|
+
* when their connection closed: the only exits were the browser's own release,
|
|
11
|
+
* which a killed tab never sends, and a silence that a paused viewer produces
|
|
12
|
+
* just as well as a departed one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
21
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
22
|
+
import { Viewers } from "../services/viewer/Viewers.js";
|
|
23
|
+
|
|
24
|
+
const PICTURE = "aaaaaaaa-0000-4000-8000-000000000001";
|
|
25
|
+
const SOUND = "aaaaaaaa-0000-4000-8000-000000000002";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} id
|
|
29
|
+
* @param {string} dirPath
|
|
30
|
+
* @returns {object}
|
|
31
|
+
*/
|
|
32
|
+
function outputOn(id, dirPath) {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
dirPath,
|
|
36
|
+
state: "live",
|
|
37
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
|
|
38
|
+
get inputFile() { return this.file; },
|
|
39
|
+
get audioFile() { return this.file; },
|
|
40
|
+
consumers: new Set(),
|
|
41
|
+
viewers: new Map(),
|
|
42
|
+
lastAccessedAt: Date.now()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("asking for a viewer of an output is that viewer watching it", () => {
|
|
47
|
+
let changes = 0;
|
|
48
|
+
const viewers = new Viewers({ onChange: () => { changes += 1; } });
|
|
49
|
+
const output = { id: "out-1", viewers: new Map() };
|
|
50
|
+
|
|
51
|
+
const first = viewers.of(output, "someone");
|
|
52
|
+
assert.equal(changes, 1, "a new relation is a change");
|
|
53
|
+
assert.equal(first.outputs.has("out-1"), true);
|
|
54
|
+
|
|
55
|
+
viewers.of(output, "someone");
|
|
56
|
+
assert.equal(changes, 1, "asking again about the same output changes nothing");
|
|
57
|
+
|
|
58
|
+
const second = { id: "out-2", viewers: new Map() };
|
|
59
|
+
viewers.of(second, "someone");
|
|
60
|
+
assert.equal(changes, 2, "a second output is a change");
|
|
61
|
+
assert.equal(viewers.get("someone")?.outputs.size, 2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a connection closing takes the person off every output at once", () => {
|
|
65
|
+
const viewers = new Viewers();
|
|
66
|
+
const picture = { id: "picture", viewers: new Map() };
|
|
67
|
+
const sound = { id: "sound", viewers: new Map() };
|
|
68
|
+
const byId = new Map([["picture", picture], ["sound", sound]]);
|
|
69
|
+
|
|
70
|
+
viewers.of(picture, "watcher");
|
|
71
|
+
viewers.of(sound, "watcher");
|
|
72
|
+
viewers.of(picture, "other");
|
|
73
|
+
|
|
74
|
+
const left = viewers.hasGone("watcher", (id) => byId.get(id) ?? null);
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(left.sort(), ["picture", "sound"], "both, not the one the browser holds an id for");
|
|
77
|
+
assert.equal(picture.viewers.has("watcher"), false);
|
|
78
|
+
assert.equal(sound.viewers.has("watcher"), false);
|
|
79
|
+
assert.equal(picture.viewers.has("other"), true, "and nobody else goes with them");
|
|
80
|
+
assert.equal(viewers.get("watcher"), null, "the registry does not keep what it has let go");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a viewer whose connection closed is let go of every output they were watching", async (t) => {
|
|
84
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "presence-"));
|
|
85
|
+
t.after(async () => {
|
|
86
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
const manager = new HlsSessionManager({
|
|
89
|
+
enabled: true,
|
|
90
|
+
ffmpegBin: "ffmpeg",
|
|
91
|
+
localBindHost: "127.0.0.1",
|
|
92
|
+
localPort: 9090
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const picture = outputOn(PICTURE, dirPath);
|
|
96
|
+
const sound = outputOn(SOUND, dirPath);
|
|
97
|
+
picture.consumers.add("watcher");
|
|
98
|
+
sound.consumers.add("watcher");
|
|
99
|
+
manager.sessionsById.set(PICTURE, picture);
|
|
100
|
+
manager.sessionsById.set(SOUND, sound);
|
|
101
|
+
manager.viewers.of(picture, "watcher");
|
|
102
|
+
manager.viewers.of(sound, "watcher");
|
|
103
|
+
|
|
104
|
+
const released = await manager.viewerHasGone("watcher", "the connection closed");
|
|
105
|
+
|
|
106
|
+
assert.equal(released, 2, "the picture and the soundtrack, from one statement");
|
|
107
|
+
assert.equal(manager.viewers.get("watcher"), null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a viewer nobody knows costs nothing to let go of", async () => {
|
|
111
|
+
const manager = new HlsSessionManager({
|
|
112
|
+
enabled: true,
|
|
113
|
+
ffmpegBin: "ffmpeg",
|
|
114
|
+
localBindHost: "127.0.0.1",
|
|
115
|
+
localPort: 9090
|
|
116
|
+
});
|
|
117
|
+
assert.equal(await manager.viewerHasGone("never-seen"), 0);
|
|
118
|
+
assert.equal(await manager.viewerHasGone(""), 0);
|
|
119
|
+
});
|
package/test/viewer.test.js
CHANGED
|
@@ -18,25 +18,42 @@ import { Viewer, viewerOf, viewersOf } from "../services/viewer/Viewer.js";
|
|
|
18
18
|
|
|
19
19
|
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
20
20
|
|
|
21
|
-
test("
|
|
22
|
-
const viewer = new Viewer("someone");
|
|
21
|
+
test("presence and position are two facts, and presence does not wait for a request", () => {
|
|
23
22
|
const now = 1_000_000;
|
|
23
|
+
const viewer = new Viewer("someone", now);
|
|
24
|
+
|
|
25
|
+
// The whole of the 2026-09-05 failure in one assertion: a viewer who has
|
|
26
|
+
// arrived and asked for nothing yet IS watching. Answered from the position
|
|
27
|
+
// alone — which is written only when a segment is requested — this read
|
|
28
|
+
// false, every encoder on their output was stopped for having nobody, and
|
|
29
|
+
// the `init.mp4` they needed in order to request their first segment was
|
|
30
|
+
// therefore never made.
|
|
31
|
+
assert.equal(viewer.position, null, "nothing has placed them yet");
|
|
32
|
+
assert.equal(viewer.isPresent(now, 60_000), true, "and they are still here");
|
|
33
|
+
|
|
34
|
+
viewer.seen(now - 10_000);
|
|
35
|
+
assert.equal(viewer.isPresent(now, 60_000), true);
|
|
36
|
+
|
|
37
|
+
// Silence longer than any a watching viewer can produce. This is a backstop
|
|
38
|
+
// for a connection that failed to say so, not the ordinary way of leaving.
|
|
39
|
+
viewer.seen(now - 120_000);
|
|
40
|
+
assert.equal(viewer.isPresent(now, 60_000), false);
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
viewer.
|
|
29
|
-
assert.equal(viewer.
|
|
42
|
+
// Something SAID they are gone. Then it does not matter how recently they
|
|
43
|
+
// were heard from.
|
|
44
|
+
viewer.seen(now);
|
|
45
|
+
viewer.gone = true;
|
|
46
|
+
assert.equal(viewer.isPresent(now, 60_000), false, "a statement outranks silence");
|
|
30
47
|
});
|
|
31
48
|
|
|
32
49
|
test("a stated position outranks the segment they last asked for", () => {
|
|
33
50
|
const viewer = new Viewer("someone");
|
|
34
51
|
assert.equal(viewer.positionSeconds(), null);
|
|
35
52
|
|
|
36
|
-
viewer.
|
|
53
|
+
viewer.position = { segment: 10, seconds: 40, at: 1 };
|
|
37
54
|
assert.equal(viewer.positionSeconds(), 40, "where their player is reading");
|
|
38
55
|
|
|
39
|
-
viewer.
|
|
56
|
+
viewer.position = { segment: 10, seconds: 40, at: 1, seeked: 900 };
|
|
40
57
|
assert.equal(viewer.positionSeconds(), 900, "a seek is the viewer saying where they are");
|
|
41
58
|
});
|
|
42
59
|
|
|
@@ -80,7 +97,7 @@ test("releasing a consumer forgets everything that was true of them alone", asyn
|
|
|
80
97
|
const leaving = viewerOf(session, "leaving");
|
|
81
98
|
leaving.audio = { trackIndex: 2, transcode: true };
|
|
82
99
|
leaving.activeVariantId = "some-variant";
|
|
83
|
-
leaving.
|
|
100
|
+
leaving.position = { segment: 40, seconds: 160, at: Date.now() };
|
|
84
101
|
viewerOf(session, "staying").audio = { trackIndex: 0, transcode: false };
|
|
85
102
|
|
|
86
103
|
await manager.releaseSessionConsumer(SESSION_ID, "leaving", "the tab was closed");
|