@torrent-tv/proxy 2.83.7 → 2.84.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 +16 -0
- package/CLAUDE.md +5 -3
- package/bin/cli.js +1 -0
- package/docs/encode-architecture.md +26 -1
- package/docs/logs.md +97 -51
- package/package.json +1 -1
- package/routes/api/client-logs/post.js +86 -0
- package/routes/api/transcode-sessions/post.js +139 -137
- package/server.js +13 -1
- package/services/data-channel-handler.js +7 -0
- package/services/hls-session-manager.js +9777 -10006
- package/services/output/OutputSpec.js +202 -195
- package/services/priority/PriorityMap.js +390 -362
- package/services/priority/PriorityOrchestrator.js +316 -318
- package/services/viewer/Viewer.js +60 -33
- package/services/viewer/positions.js +120 -48
- package/test/client-log-file.test.js +110 -0
- package/test/held-request-width.test.js +8 -5
- package/test/orchestrator-wired.test.js +14 -8
- package/test/output-spec.test.js +157 -157
- package/test/plan-is-steady.test.js +223 -0
- package/test/priority-map-emptied.test.js +207 -207
- package/test/priority-map-per-output.test.js +209 -211
- package/test/quality-variants.test.js +1167 -1222
- package/test/seek-target-not-superseded.test.js +14 -12
- package/test/stale-request-after-seek.test.js +2 -3
- package/test/viewer.test.js +115 -108
- package/utils/client-log-file.js +151 -0
- package/test/viewer-position-on-open.test.js +0 -92
|
@@ -64,20 +64,30 @@ export class Viewer {
|
|
|
64
64
|
/** A soundtrack being prepared for the same reason. @type {string | null} */
|
|
65
65
|
this.warmingAudioId = null;
|
|
66
66
|
/**
|
|
67
|
-
* Where they
|
|
67
|
+
* Where their picture stood when they last said so, and when they said it.
|
|
68
68
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* ONE FACT WITH ONE WRITER: the viewer. It is set when they arrive — from
|
|
70
|
+
* the position their own create request named — and moved only by what they
|
|
71
|
+
* state afterwards, a seek or a report carrying their playhead. Never null
|
|
72
|
+
* for a viewer this process has met, because a viewer arrives by asking for
|
|
73
73
|
* a position.
|
|
74
74
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
75
|
+
* A REQUEST FOR A SEGMENT IS NOT A POSITION and never writes here. It says
|
|
76
|
+
* how far their buffer has reached, which is a different quantity and one
|
|
77
|
+
* nothing in the plan needs: a segment either exists and is served, or does
|
|
78
|
+
* not and is waited for.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
80
|
+
* Two writers is what this replaced, and the cost is measured. Until
|
|
81
|
+
* 2026-09-13 a seek wrote `seeked` while a request wrote `seconds`, and
|
|
82
|
+
* `positionSeconds` preferred whichever was set — so the two took turns and
|
|
83
|
+
* the priority map jumped back and forth by one or two segments several
|
|
84
|
+
* times a second. Field that day, one output over one second:
|
|
85
|
+
* `p100:#9..#10`, `p100:#18..#20`, `p100:#16..#18`, `p100:#18..#20`, with
|
|
86
|
+
* the second viewer stopped dead at 1673.6 s throughout. The encoder
|
|
87
|
+
* placement followed the map, as it must, and the machine spent six minutes
|
|
88
|
+
* on 77 starts and 141 stops against one normal end.
|
|
89
|
+
*
|
|
90
|
+
* @type {{ seconds: number, at: number } | null}
|
|
81
91
|
*/
|
|
82
92
|
this.position = null;
|
|
83
93
|
/**
|
|
@@ -181,7 +191,7 @@ export class Viewer {
|
|
|
181
191
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
182
192
|
return;
|
|
183
193
|
}
|
|
184
|
-
this.position = { seconds, at: now
|
|
194
|
+
this.position = { seconds, at: now };
|
|
185
195
|
this.lastSeenAt = now;
|
|
186
196
|
}
|
|
187
197
|
|
|
@@ -256,21 +266,21 @@ export class Viewer {
|
|
|
256
266
|
}
|
|
257
267
|
|
|
258
268
|
/**
|
|
259
|
-
* Whether this viewer is
|
|
269
|
+
* Whether this viewer is here.
|
|
260
270
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
271
|
+
* PRESENCE IS THE CONNECTION, AND ONLY THE CONNECTION. They are here from the
|
|
272
|
+
* moment they are known until something SAYS they are gone: the browser
|
|
273
|
+
* releasing the session, or their connection closing. Nothing else, and in
|
|
274
|
+
* particular not silence.
|
|
265
275
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
276
|
+
* Silence used to end it, after an interval longer than "any silence a
|
|
277
|
+
* watching viewer can produce" — and there is no such interval. A viewer who
|
|
278
|
+
* has paused, whose tab is hidden and whose timers the browser has throttled,
|
|
279
|
+
* or who holds two minutes of cushion, all say nothing for as long as they
|
|
280
|
+
* like and are all still watching. Deciding presence from that made it a
|
|
281
|
+
* guess with a threshold; deciding it from the connection makes it a fact
|
|
282
|
+
* with an owner.
|
|
270
283
|
*
|
|
271
|
-
* @param {number} now
|
|
272
|
-
* @param {number} staleAfterMs - Longer than any silence a watching viewer
|
|
273
|
-
* can produce. Derived from the cushion, never chosen here.
|
|
274
284
|
* @returns {boolean}
|
|
275
285
|
*/
|
|
276
286
|
/**
|
|
@@ -290,11 +300,8 @@ export class Viewer {
|
|
|
290
300
|
return this.playing !== false && this.onScreen !== false;
|
|
291
301
|
}
|
|
292
302
|
|
|
293
|
-
isPresent(
|
|
294
|
-
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
return now - this.lastSeenAt <= staleAfterMs;
|
|
303
|
+
isPresent() {
|
|
304
|
+
return this.gone !== true;
|
|
298
305
|
}
|
|
299
306
|
|
|
300
307
|
/**
|
|
@@ -308,16 +315,36 @@ export class Viewer {
|
|
|
308
315
|
}
|
|
309
316
|
|
|
310
317
|
/**
|
|
311
|
-
* Where this viewer is, in seconds
|
|
312
|
-
* placed — which for a viewer created through `Viewers.of` cannot happen.
|
|
318
|
+
* Where this viewer is NOW, in seconds of film.
|
|
313
319
|
*
|
|
314
|
-
*
|
|
320
|
+
* A FUNCTION OF TIME, not a stored number. A viewer whose picture is moving
|
|
321
|
+
* covers a second of film every second, so between the moments they speak
|
|
322
|
+
* their position is known exactly — it is where they last were plus the time
|
|
323
|
+
* since. A viewer whose picture is stopped covers nothing and stays where
|
|
324
|
+
* they are, which is the same formula with the rate at zero.
|
|
325
|
+
*
|
|
326
|
+
* position(now) = stated + rate * (now - statedAt), rate = playing ? 1 : 0
|
|
327
|
+
*
|
|
328
|
+
* Held as a stored number instead, it was a staircase: flat for the ten
|
|
329
|
+
* seconds between reports and then a jump, so everything derived from it —
|
|
330
|
+
* the priority map, and through the map every encoder — changed in steps for
|
|
331
|
+
* a viewer who was moving smoothly. And because two writers were filling that
|
|
332
|
+
* number in turn, the steps went backwards as often as forwards.
|
|
333
|
+
*
|
|
334
|
+
* There is nothing to tune here and no state to keep: one reading, one clock,
|
|
335
|
+
* one rate the page itself states.
|
|
336
|
+
*
|
|
337
|
+
* @param {number} [now]
|
|
338
|
+
* @returns {number | null} Null only for a viewer never placed, which for one
|
|
339
|
+
* created through `Viewers.of` cannot happen.
|
|
315
340
|
*/
|
|
316
|
-
positionSeconds() {
|
|
341
|
+
positionSeconds(now = Date.now()) {
|
|
317
342
|
if (this.position === null) {
|
|
318
343
|
return null;
|
|
319
344
|
}
|
|
320
|
-
|
|
345
|
+
const rate = this.playing ? 1 : 0;
|
|
346
|
+
const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
|
|
347
|
+
return this.position.seconds + rate * elapsedSec;
|
|
321
348
|
}
|
|
322
349
|
}
|
|
323
350
|
|
|
@@ -1,48 +1,120 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Where the viewers of one output stand.
|
|
3
|
-
*
|
|
4
|
-
* Asked by whatever has to give disk back: what lies behind every viewer has
|
|
5
|
-
* been played and will not be wanted again unless somebody seeks back, and what
|
|
6
|
-
* lies ahead of the furthest will be wanted eventually. So an answer of "nobody"
|
|
7
|
-
* is the strongest statement there is — everything that output holds is worth
|
|
8
|
-
* less than anything anybody is on their way to.
|
|
9
|
-
*
|
|
10
|
-
* Pure: it is handed the sessions, the clock and a way to turn seconds into a
|
|
11
|
-
* segment number, and it holds none of them.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { viewersOf } from "./Viewer.js";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @param {object} params
|
|
18
|
-
* @param {Iterable<{ outputKey?: string }>} params.sessions - Every live session.
|
|
19
|
-
* @param {string} params.outputKey - The output being asked about.
|
|
20
|
-
* @param {(session: object, seconds: number) => number} params.segmentAt - Which
|
|
21
|
-
* segment of THAT session's timeline a moment of film falls in.
|
|
22
|
-
* @param {number} params.now
|
|
23
|
-
* @
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Where the viewers of one output stand.
|
|
3
|
+
*
|
|
4
|
+
* Asked by whatever has to give disk back: what lies behind every viewer has
|
|
5
|
+
* been played and will not be wanted again unless somebody seeks back, and what
|
|
6
|
+
* lies ahead of the furthest will be wanted eventually. So an answer of "nobody"
|
|
7
|
+
* is the strongest statement there is — everything that output holds is worth
|
|
8
|
+
* less than anything anybody is on their way to.
|
|
9
|
+
*
|
|
10
|
+
* Pure: it is handed the sessions, the clock and a way to turn seconds into a
|
|
11
|
+
* segment number, and it holds none of them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { viewersOf } from "./Viewer.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} params
|
|
18
|
+
* @param {Iterable<{ outputKey?: string }>} params.sessions - Every live session.
|
|
19
|
+
* @param {string} params.outputKey - The output being asked about.
|
|
20
|
+
* @param {(session: object, seconds: number) => number} params.segmentAt - Which
|
|
21
|
+
* segment of THAT session's timeline a moment of film falls in.
|
|
22
|
+
* @param {number} params.now
|
|
23
|
+
* @returns {number[]} A segment number per present viewer, unsorted.
|
|
24
|
+
*/
|
|
25
|
+
export function viewerSegmentsOn({ sessions, outputKey, segmentAt, now }) {
|
|
26
|
+
const at = [];
|
|
27
|
+
for (const session of sessions) {
|
|
28
|
+
if (session.outputKey !== outputKey) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const viewer of viewersOf(session).values()) {
|
|
32
|
+
if (!viewer.isPresent()) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const seconds = viewer.positionSeconds(now);
|
|
36
|
+
if (!Number.isFinite(seconds)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const index = segmentAt(session, /** @type {number} */ (seconds));
|
|
40
|
+
if (Number.isInteger(index) && index >= 0) {
|
|
41
|
+
at.push(index);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return at;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Where a viewer of one output is, in seconds of film, NOW.
|
|
50
|
+
*
|
|
51
|
+
* One reading with one owner, and a function of time rather than a stored
|
|
52
|
+
* number: a viewer states where their picture is, and the clock carries it
|
|
53
|
+
* forward between statements.
|
|
54
|
+
*
|
|
55
|
+
* Three readings ranked by priority used to answer this — a seek, the start of
|
|
56
|
+
* the last segment requested, the position the output was opened at — with a
|
|
57
|
+
* second function saying which had answered. They were three different
|
|
58
|
+
* quantities wearing one name, two of them written at different rates, so the
|
|
59
|
+
* answer alternated between them several times a second and every encoder
|
|
60
|
+
* followed it.
|
|
61
|
+
*
|
|
62
|
+
* Without a name, the FURTHEST viewer: what lies behind them has already been
|
|
63
|
+
* made, so that is what a reading about no particular person wants. With
|
|
64
|
+
* nobody present at all, where the output was opened.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} session
|
|
67
|
+
* @param {string} [consumerId] - Whose position.
|
|
68
|
+
* @param {number} [now]
|
|
69
|
+
* @returns {number} Seconds, never negative.
|
|
70
|
+
*/
|
|
71
|
+
export function viewerSecondsOn(session, consumerId = "", now = Date.now()) {
|
|
72
|
+
const named = consumerId ? session?.viewers?.get(consumerId) ?? null : null;
|
|
73
|
+
if (named) {
|
|
74
|
+
return Math.max(0, named.positionSeconds(now) ?? 0);
|
|
75
|
+
}
|
|
76
|
+
let furthest = null;
|
|
77
|
+
for (const viewer of viewersOf(session).values()) {
|
|
78
|
+
if (!viewer.isPresent()) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const seconds = viewer.positionSeconds(now);
|
|
82
|
+
if (Number.isFinite(seconds)) {
|
|
83
|
+
furthest = furthest === null ? seconds : Math.max(furthest, seconds);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const opened = Number(session?.progress?.startPositionSeconds);
|
|
87
|
+
return Math.max(0, furthest ?? (Number.isFinite(opened) ? opened : 0));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The earliest film anybody present is standing on, across several outputs.
|
|
92
|
+
*
|
|
93
|
+
* WHAT IT IS FOR: a soundtrack is created at a position, and a track begun
|
|
94
|
+
* where the leader stands has nothing to give the viewer behind them. So the
|
|
95
|
+
* earliest, and across every rung of the picture, because two viewers of one
|
|
96
|
+
* film may be on different ones.
|
|
97
|
+
*
|
|
98
|
+
* This is a fact about PEOPLE and it decides one parameter of an output. It
|
|
99
|
+
* does not place encoders: which encoder works where is the priority map's
|
|
100
|
+
* answer and nothing here is consulted for it.
|
|
101
|
+
*
|
|
102
|
+
* @param {Iterable<object>} sessions
|
|
103
|
+
* @param {number} [now]
|
|
104
|
+
* @returns {number | null} Seconds, or null when nobody is present.
|
|
105
|
+
*/
|
|
106
|
+
export function earliestViewerSecondsOn(sessions, now = Date.now()) {
|
|
107
|
+
let earliest = null;
|
|
108
|
+
for (const session of sessions ?? []) {
|
|
109
|
+
for (const viewer of viewersOf(session).values()) {
|
|
110
|
+
if (!viewer.isPresent()) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const seconds = viewer.positionSeconds(now);
|
|
114
|
+
if (Number.isFinite(seconds)) {
|
|
115
|
+
earliest = earliest === null ? seconds : Math.min(earliest, seconds);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return earliest;
|
|
120
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The browser's log, written beside the proxy's.
|
|
3
|
+
*
|
|
4
|
+
* What these hold is the naming, because the name is the whole point: the two
|
|
5
|
+
* halves of a session have to join without guessing, and a torrent name is
|
|
6
|
+
* arbitrary bytes chosen by a stranger that is about to become a path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { createClientLogFiles } from "../utils/client-log-file.js";
|
|
16
|
+
|
|
17
|
+
/** A directory with a proxy log in it, which is what the sink is given. */
|
|
18
|
+
function aDirectory() {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "ttv-client-log-"));
|
|
20
|
+
return { dir, proxyLog: join(dir, "proxy.log") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const aSession = (over = {}) => ({
|
|
24
|
+
sessionId: "abcd1234",
|
|
25
|
+
startedAt: "2026-09-13T16:05:24.123Z",
|
|
26
|
+
torrentName: "Reacher.S04E07.1080p.rus.LostFilm.TV.mkv",
|
|
27
|
+
infoHash: "94dda59fdf252447d6ca5a8775dab2d11a832da5",
|
|
28
|
+
...over
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("the file lands beside the proxy log, named by when it started and what is watched", async () => {
|
|
32
|
+
const { dir, proxyLog } = aDirectory();
|
|
33
|
+
const logs = createClientLogFiles(proxyLog);
|
|
34
|
+
logs.write(aSession(), ["first line"]);
|
|
35
|
+
await logs.close();
|
|
36
|
+
|
|
37
|
+
const [name] = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
38
|
+
assert.ok(name, "a client log file was written");
|
|
39
|
+
// The stamp is UTC and sorts; the session id joins it to the proxy's lines;
|
|
40
|
+
// the film says which one it is without opening the file.
|
|
41
|
+
assert.match(name, /^client-20260913-160524-abcd1234-Reacher\.S04E07\.1080p\.rus\.LostFilm\.TV\.mkv-94dda59f\.log$/);
|
|
42
|
+
assert.equal(readFileSync(join(dir, name), "utf8"), "first line\n");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("lines from one session go on appending to the one file", async () => {
|
|
46
|
+
const { dir, proxyLog } = aDirectory();
|
|
47
|
+
const logs = createClientLogFiles(proxyLog);
|
|
48
|
+
logs.write(aSession(), ["one", "two"]);
|
|
49
|
+
logs.write(aSession(), ["three"]);
|
|
50
|
+
await logs.close();
|
|
51
|
+
|
|
52
|
+
const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
53
|
+
assert.equal(names.length, 1);
|
|
54
|
+
assert.equal(readFileSync(join(dir, names[0]), "utf8"), "one\ntwo\nthree\n");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a session that has not chosen a film yet still gets its own file", async () => {
|
|
58
|
+
// The page opening, a proxy being chosen, a connection failing — all before
|
|
59
|
+
// any torrent exists, and all worth keeping.
|
|
60
|
+
const { dir, proxyLog } = aDirectory();
|
|
61
|
+
const logs = createClientLogFiles(proxyLog);
|
|
62
|
+
logs.write(aSession({ torrentName: "", infoHash: "" }), ["before any film"]);
|
|
63
|
+
await logs.close();
|
|
64
|
+
|
|
65
|
+
const [name] = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
66
|
+
assert.match(name, /-no-torrent-yet\.log$/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the file keeps its name when the film arrives mid-session", async () => {
|
|
70
|
+
// Keyed by the session, not by the name: the earlier lines belong to the same
|
|
71
|
+
// viewing and must not start a second file.
|
|
72
|
+
const { dir, proxyLog } = aDirectory();
|
|
73
|
+
const logs = createClientLogFiles(proxyLog);
|
|
74
|
+
logs.write(aSession({ torrentName: "", infoHash: "" }), ["before"]);
|
|
75
|
+
logs.write(aSession(), ["after"]);
|
|
76
|
+
await logs.close();
|
|
77
|
+
|
|
78
|
+
const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
79
|
+
assert.equal(names.length, 1);
|
|
80
|
+
assert.match(names[0], /-no-torrent-yet\.log$/);
|
|
81
|
+
assert.equal(readFileSync(join(dir, names[0]), "utf8"), "before\nafter\n");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a hostile torrent name cannot escape the directory", async () => {
|
|
85
|
+
const { dir, proxyLog } = aDirectory();
|
|
86
|
+
const logs = createClientLogFiles(proxyLog);
|
|
87
|
+
logs.write(aSession({ torrentName: "../../etc/passwd" }), ["x"]);
|
|
88
|
+
await logs.close();
|
|
89
|
+
|
|
90
|
+
const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
|
|
91
|
+
assert.equal(names.length, 1);
|
|
92
|
+
assert.ok(!names[0].includes(".."), `no traversal in ${names[0]}`);
|
|
93
|
+
assert.ok(!names[0].includes("/"), `no separator in ${names[0]}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("two sessions of one film are two files", async () => {
|
|
97
|
+
const { dir, proxyLog } = aDirectory();
|
|
98
|
+
const logs = createClientLogFiles(proxyLog);
|
|
99
|
+
logs.write(aSession({ sessionId: "aaaa1111" }), ["one viewer"]);
|
|
100
|
+
logs.write(aSession({ sessionId: "bbbb2222", startedAt: "2026-09-13T16:07:11.000Z" }), ["another"]);
|
|
101
|
+
await logs.close();
|
|
102
|
+
|
|
103
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith("client-")).length, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("no proxy log path means nothing is written and nothing throws", async () => {
|
|
107
|
+
const logs = createClientLogFiles("");
|
|
108
|
+
logs.write(aSession(), ["dropped"]);
|
|
109
|
+
await logs.close();
|
|
110
|
+
});
|
|
@@ -17,6 +17,7 @@ import os from "node:os";
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
19
19
|
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
20
|
+
import { viewerOf } from "../services/viewer/Viewer.js";
|
|
20
21
|
|
|
21
22
|
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
22
23
|
const SEGMENT_SECONDS = 4;
|
|
@@ -32,7 +33,7 @@ async function managerWithSession() {
|
|
|
32
33
|
localBindHost: "127.0.0.1",
|
|
33
34
|
localPort: 9090
|
|
34
35
|
});
|
|
35
|
-
|
|
36
|
+
const session = {
|
|
36
37
|
id: SESSION_ID,
|
|
37
38
|
dirPath,
|
|
38
39
|
// Where this file is cut, held by the file. A fixture that stated it
|
|
@@ -44,10 +45,12 @@ async function managerWithSession() {
|
|
|
44
45
|
state: "ready",
|
|
45
46
|
segmentFormat: fmp4Format,
|
|
46
47
|
segmentCount: 200,
|
|
47
|
-
useSyntheticPlaylist: true
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
useSyntheticPlaylist: true
|
|
49
|
+
};
|
|
50
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
51
|
+
// The viewer is at segment #25, and says so themselves.
|
|
52
|
+
viewerOf(session, "").moveTo(25 * SEGMENT_SECONDS);
|
|
53
|
+
viewerOf(session, "").playing = false;
|
|
51
54
|
return { manager, dirPath };
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -109,21 +109,27 @@ test("what a viewer waits for reaches the plan without their name", (t) => {
|
|
|
109
109
|
assert.ok(wanted.some((zone) => zone.to > 5), "and the cushion in front of them");
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
test("a viewer
|
|
112
|
+
test("a viewer who has gone stops being waited for, and silence alone never counts", (t) => {
|
|
113
113
|
const { manager, root, dirPath } = managerWithAnOutput();
|
|
114
114
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
115
115
|
|
|
116
116
|
const session = sessionOn({ id: "one", dirPath });
|
|
117
117
|
manager.sessionsById.set(session.id, session);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
const person = viewerOf(session, "gone");
|
|
119
|
+
person.moveTo(20, Date.now() - 10 * 60 * 1000);
|
|
120
|
+
person.playing = false;
|
|
121
|
+
person.seen(Date.now() - 10 * 60 * 1000);
|
|
122
|
+
|
|
123
|
+
// TEN MINUTES OF SILENCE IS NOT LEAVING. A paused viewer, a hidden tab whose
|
|
124
|
+
// timers the browser has throttled, and one holding a full cushion are all
|
|
125
|
+
// silent and all still watching, so what they want is still wanted.
|
|
125
126
|
manager.planEncodersNow();
|
|
127
|
+
assert.ok(manager.encodeOrchestrator.demand.mapOn(KEY).length > 0, "still watching");
|
|
126
128
|
|
|
129
|
+
// Something SAYS they are gone — the browser released the session, or their
|
|
130
|
+
// connection closed. That is the only way out.
|
|
131
|
+
manager.viewers.leaves(session, "gone");
|
|
132
|
+
manager.planEncodersNow();
|
|
127
133
|
assert.equal(manager.encodeOrchestrator.demand.mapOn(KEY).length, 0);
|
|
128
134
|
});
|
|
129
135
|
|