@torrent-tv/proxy 2.83.8 → 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.
@@ -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 are.
67
+ * Where their picture stood when they last said so, and when they said it.
68
68
  *
69
- * Set when they arrive, from the position their own request named, and
70
- * moved by the segments they ask for and by the seeks they report. Never
71
- * null for a viewer this process has met: "we do not know where they are"
72
- * is not a state a viewer can be in, because a viewer arrives by asking for
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
- * `seeked` holds a position they STATED, for as long as they stay there,
76
- * which is the distinction a cold open's soundtrack placement turns on: a
77
- * request is evidence about where their player is reading, a seek is the
78
- * viewer saying where they are.
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
- * @type {{ segment: number, seconds: number, at: number, seeked?: number | null } | null}
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, seeked: seconds };
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 still watching.
269
+ * Whether this viewer is here.
260
270
  *
261
- * Two ways to stop being present, and neither of them is "asked for nothing
262
- * recently". Something must have SAID they are gone, or nothing at all must
263
- * have been heard from them for longer than any silence a watching viewer can
264
- * produce.
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
- * The second half exists only because the connection cannot always say: an
267
- * `onClosed` on a data channel does not always come, and a transport that is
268
- * not a data channel at all may have nothing to say. It is a backstop for a
269
- * missing statement, not the ordinary way a viewer leaves.
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(now, staleAfterMs) {
294
- if (this.gone) {
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, or null when they have somehow never been
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
- * @returns {number | null}
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
- return Number.isFinite(this.position.seeked) ? this.position.seeked : this.position.seconds;
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
- * @param {number} params.staleAfterMs - Silence longer than any a watching
24
- * viewer can produce.
25
- * @returns {number[]} A segment number per present viewer, unsorted.
26
- */
27
- export function viewerSegmentsOn({ sessions, outputKey, segmentAt, now, staleAfterMs }) {
28
- const at = [];
29
- for (const session of sessions) {
30
- if (session.outputKey !== outputKey) {
31
- continue;
32
- }
33
- for (const viewer of viewersOf(session).values()) {
34
- if (!viewer.isPresent(now, staleAfterMs)) {
35
- continue;
36
- }
37
- const seconds = viewer.positionSeconds();
38
- if (!Number.isFinite(seconds)) {
39
- continue;
40
- }
41
- const index = segmentAt(session, /** @type {number} */ (seconds));
42
- if (Number.isInteger(index) && index >= 0) {
43
- at.push(index);
44
- }
45
- }
46
- }
47
- return at;
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
+ }
@@ -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
- manager.sessionsById.set(SESSION_ID, {
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
- // The viewer is at segment #25.
49
- furthestViewerSeconds: 100
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 nothing has been heard from at all stops being waited for", (t) => {
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 gone = viewerOf(session, "gone");
119
- gone.position = { segment: 5, seconds: 20, at: Date.now() - 10 * 60 * 1000 };
120
- // What decides it is when they were last HEARD FROM, not when they last
121
- // asked for a segment: a viewer holding a full cushion asks for nothing for
122
- // as long as it takes to drain, and is no less present for it.
123
- gone.seen(Date.now() - 10 * 60 * 1000);
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