@torrent-tv/proxy 2.80.9 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1685 -1666
  2. package/package.json +1 -1
  3. package/services/data-channel-handler.js +2 -4
  4. package/services/encode/EncodePlan.js +0 -3
  5. package/services/encode/EncodeRun.js +14 -0
  6. package/services/encode/run-command.js +729 -707
  7. package/services/hls-session-manager.js +10168 -10494
  8. package/services/hwaccel.js +2 -2
  9. package/services/orchestrators/EncodeOrchestrator.js +4 -4
  10. package/services/output/LiveOutputs.js +49 -0
  11. package/services/priority/PriorityOrchestrator.js +278 -174
  12. package/services/quality/EncodeCost.js +555 -555
  13. package/test/auto-quality-step.test.js +514 -507
  14. package/test/contention.test.js +1 -1
  15. package/test/decode-cost-fit.test.js +57 -0
  16. package/test/decode-cost.test.js +13 -36
  17. package/test/encode-orchestrator.test.js +1 -2
  18. package/test/encode-plan-viewers.test.js +18 -19
  19. package/test/encode-plan.test.js +539 -539
  20. package/test/held-request-width.test.js +155 -154
  21. package/test/helpers/encode-run.js +136 -128
  22. package/test/keyframe-table-budget.test.js +7 -1
  23. package/test/one-authority.test.js +220 -105
  24. package/test/orchestrator-wired.test.js +16 -11
  25. package/test/piece-store-slow-disk.test.js +32 -2
  26. package/test/priority-map-per-output.test.js +211 -0
  27. package/test/produced-copy-choice.test.js +358 -327
  28. package/test/quality-variants.test.js +1222 -1209
  29. package/test/segment-serve-wiring.test.js +76 -20
  30. package/test/stale-request-after-seek.test.js +51 -77
  31. package/test/tracks-begin-together.test.js +195 -153
  32. package/test/tunnel-renewal.test.js +34 -5
  33. package/test/two-viewers-one-picture.test.js +374 -347
  34. package/test/viewer-outputs.test.js +18 -14
  35. package/test/behind-head-repair.test.js +0 -261
  36. package/test/decode-measurement.test.js +0 -83
  37. /package/services/{contention.js → encode/contention.js} +0 -0
@@ -21,12 +21,12 @@
21
21
  */
22
22
 
23
23
  import { spawn } from "node:child_process";
24
- import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
24
+ import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
25
25
  import { mkdtemp, readFile, rm } from "node:fs/promises";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
28
  import { fitDecodeCost } from "./decode-cost-fit.js";
29
- import { penaltiesFrom } from "./contention.js";
29
+ import { penaltiesFrom } from "./encode/contention.js";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import {
32
32
  parseFfmpegBitrateKbps,
@@ -29,7 +29,7 @@ import { endOfRun } from "../encode/EncodeRun.js";
29
29
  import { ENCODE_EXIT } from "../encode/encode-exit.js";
30
30
  import { affordableRuns } from "../encode/run-budget.js";
31
31
  import { RunCosts } from "../encode/run-costs.js";
32
- import { contentionPenalty } from "../contention.js";
32
+ import { contentionPenalty } from "../encode/contention.js";
33
33
  import { SegmentDemand } from "../encode/SegmentDemand.js";
34
34
 
35
35
  export class EncodeOrchestrator {
@@ -63,12 +63,12 @@ export class EncodeOrchestrator {
63
63
  * @param {(address: string) => number} params.maxRunsFor - How many encoders
64
64
  * this machine can afford on one output. The same arithmetic that decides
65
65
  * the quality offer; measured per host, never chosen here.
66
- * @param {(params: { address: string, from: number, to: number }) =>
66
+ * @param {(params: { address: string, from: number, to: number, because: string }) =>
67
67
  * import("../encode/EncodeRun.js").EncodeRun} params.makeRun - Build a run
68
68
  * for a stretch. What to read, what to map and how to cut belong to whoever
69
69
  * knows the source.
70
70
  * @param {number} params.segmentSeconds
71
- * @param {import("../contention.js").ContentionPenalties | null}
71
+ * @param {import("../encode/contention.js").ContentionPenalties | null}
72
72
  * [params.contentionPenalties] - How much slower one encoder runs beside
73
73
  * others, MEASURED on this host at startup and keyed by how many others
74
74
  * there are. Null until something has measured it, and then the penalty is
@@ -512,7 +512,7 @@ export class EncodeOrchestrator {
512
512
  //
513
513
  // The run names itself: identity is a property of the thing, and two
514
514
  // places minting names is how one stops being unique.
515
- const run = this.makeRun({ address, from, to });
515
+ const run = this.makeRun({ address, from, to, because });
516
516
  if (!run) {
517
517
  // A refusal, not a wait: no session serves this output, or this position
518
518
  // has failed to start too many times running.
@@ -125,6 +125,55 @@ export class LiveOutputs {
125
125
  return session;
126
126
  }
127
127
 
128
+ /**
129
+ * Whether this output is the one that person is CONSUMING.
130
+ *
131
+ * A person holds a record on more outputs than they are watching, and the two
132
+ * are different facts. The picture is where their record lives — the browser
133
+ * addresses it, their chosen soundtrack is written on it, their position is
134
+ * read from it — so they never stop being known to it; but the moment they
135
+ * step down to 480p, the 1080p output is producing for nobody.
136
+ *
137
+ * Which is why this exists and why it is here. It is asked when the priority
138
+ * map of one output is built, and it decides whether an encoder on that output
139
+ * is wanted at all. Without it every output of a film was handed the film's
140
+ * whole map: the plan wanted an encoder on each, the session manager killed
141
+ * the ones it judged unwatched, and the viewer's own move announced itself and
142
+ * started them again — the two authorities of 2026-09-08.
143
+ *
144
+ * The viewer arrives as PLAIN FIELDS. This layer knows the shape of a film — a
145
+ * picture, its steps, its soundtracks — and the viewer layer knows where a
146
+ * person stands; neither holds the other.
147
+ *
148
+ * STATED AS A REFUSAL, and deliberately only where the refusal is certain.
149
+ * Being known to an output is watching it everywhere except one case, because
150
+ * everywhere else a person who stops watching is let go of: a soundtrack
151
+ * nobody chose, a step nobody is on. The exception is the picture itself,
152
+ * which they are never let go of — and which therefore had no way at all of
153
+ * knowing it was producing for nobody.
154
+ *
155
+ * So a step, a soundtrack, a step being warmed, a step whose init has just
156
+ * been asked for: all watched, as before. The picture: watched unless this
157
+ * person is on a step of it.
158
+ *
159
+ * @param {object} session
160
+ * @param {{ activeVariantId?: string | null, warmingVariantId?: string | null }} viewer
161
+ * @returns {boolean}
162
+ */
163
+ watchedBy(session, viewer) {
164
+ if (!session || !viewer) {
165
+ return false;
166
+ }
167
+ if (session.isStep === true || session.audioOnly === true) {
168
+ return true;
169
+ }
170
+ // The picture. A step of it on their screen is a statement that they are
171
+ // not looking at this; a step merely being made ready for them is not, and
172
+ // during a warm-up both are genuinely being produced.
173
+ const step = viewer.activeVariantId ?? null;
174
+ return step === null || step === session.id;
175
+ }
176
+
128
177
  /**
129
178
  * Which variant a session IS, as a height. Zero encode height means "keep the
130
179
  * source", so the source's own height is the answer.
@@ -1,174 +1,278 @@
1
- /**
2
- * @file The priority map, built once and handed to everybody who acts on it.
3
- *
4
- * One map per film, in seconds of film against a number. It is built from where
5
- * the viewers are and nothing else, and both of the things that do work — the
6
- * encoding and the downloading — read it and decide for themselves. They do not
7
- * talk to each other, and neither of them tells this class anything.
8
- *
9
- * **Why it has to be published rather than asked for.** The downloading lives
10
- * in another thread. Until now it took its orders from the reads themselves:
11
- * every read declared a window around its own head, so fifteen reads declared
12
- * fifteen windows on a piece store that holds sixteen pieces. Half of all
13
- * evictions then took a piece a reader had said it wanted, two thirds of reads
14
- * came back from disk, and what `/stream` handed out stopped being the file's
15
- * bytes — twenty-two source-parse errors, a segment the player could not
16
- * append, and an empty picture for six minutes (field 2026-09-05).
17
- */
18
-
19
- import { emptyMap, mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
20
-
21
- export class PriorityOrchestrator {
22
- /** Where the map goes once it is built. @type {(published: object) => void} */
23
- #publish;
24
-
25
- /** The last map published per film and file, so an unchanged one is not resent. */
26
- #last = new Map();
27
-
28
- /** The last map BUILT per film and file, for whoever reads instead of being
29
- * handed it. @type {Map<string, import("./PriorityMap.js").PriorityMap>} */
30
- #maps = new Map();
31
-
32
- /** Who is watching one session. @type {(session: object) => Map<string, object>} */
33
- #viewersOf;
34
-
35
- /** How wide the first band of one session's file is. @type {(session: object) => number} */
36
- #allowanceFor;
37
-
38
- /**
39
- * This layer states facts and imports nothing above itself, so what it needs
40
- * of a session who is watching it, and how wide an interruption this file
41
- * has shown on this swarm — is passed in.
42
- *
43
- * @param {object} params
44
- * @param {(published: { sourceKey: string, fileIndex: number, durationSeconds: number,
45
- * zones: { from: number, to: number, priority: number }[] }) => void} params.publish
46
- * @param {(session: object) => Map<string, object>} [params.viewersOf]
47
- * @param {(session: object) => number} [params.allowanceFor]
48
- */
49
- constructor({ publish, viewersOf, allowanceFor }) {
50
- this.#publish = typeof publish === "function" ? publish : () => {};
51
- this.#viewersOf = typeof viewersOf === "function" ? viewersOf : () => new Map();
52
- this.#allowanceFor = typeof allowanceFor === "function" ? allowanceFor : () => 0;
53
- }
54
-
55
- /**
56
- * The map for one film, from everyone watching it.
57
- *
58
- * @param {object} params
59
- * @param {string} params.sourceKey
60
- * @param {number} params.fileIndex
61
- * @param {number} params.durationSeconds
62
- * @param {number} params.allowanceSeconds - The measured depth below which an
63
- * interruption reaches a viewer of this file.
64
- * @param {{ atSeconds: number, playing: boolean }[]} params.viewers
65
- * @returns {import("./PriorityMap.js").PriorityMap} One number per second of
66
- * film, merged over everyone watching it.
67
- */
68
- build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers }) {
69
- const map = mergeMaps(
70
- (viewers ?? []).map((viewer) =>
71
- mapForViewer({
72
- atSeconds: viewer.atSeconds,
73
- durationSeconds,
74
- allowanceSeconds,
75
- playing: viewer.playing !== false
76
- })
77
- )
78
- );
79
- const key = `${sourceKey}:${fileIndex}`;
80
- this.#maps.set(key, map);
81
- // Unchanged maps are not republished: the downloading rebuilds what it asks
82
- // the swarm for on every one, and a viewer sitting still would otherwise
83
- // make it do that several times a second. Compared as stretches rather than
84
- // second by second, which is the same comparison over far fewer values.
85
- const zones = runsOf(map);
86
- const shape = JSON.stringify(zones);
87
- if (this.#last.get(key) !== shape) {
88
- this.#last.set(key, shape);
89
- this.#publish({ sourceKey, fileIndex, durationSeconds, zones });
90
- }
91
- return map;
92
- }
93
-
94
- /**
95
- * Build and publish the map for every file anybody is watching.
96
- *
97
- * One map per FILE, not per output: the picture, a quality step and a
98
- * soundtrack of one film are three outputs reading the same bytes, and the
99
- * swarm is asked for bytes. Viewers of all of them merge into one map.
100
- *
101
- * @param {object} params
102
- * @param {Iterable<object[]>} params.sessionGroups - The live sessions, in
103
- * whatever grouping the caller holds them; they are regrouped by file here.
104
- * @param {number} params.staleAfterMs - How long a viewer may be silent and
105
- * still count as watching.
106
- * @param {number} [params.now]
107
- * @returns {void}
108
- */
109
- publishFor({ sessionGroups, staleAfterMs, now = Date.now() }) {
110
- /** @type {Map<string, { sourceKey: string, fileIndex: number, durationSeconds: number, allowanceSeconds: number, viewers: object[] }>} */
111
- const byFile = new Map();
112
- for (const sessions of sessionGroups) {
113
- for (const session of sessions) {
114
- const key = `${session.sourceKey}:${session.fileIndex}`;
115
- let held = byFile.get(key);
116
- if (!held) {
117
- held = {
118
- sourceKey: session.sourceKey,
119
- fileIndex: session.fileIndex,
120
- durationSeconds: Number(session.file?.durationSeconds) || 0,
121
- // The first band is as wide as an interruption this file has
122
- // actually shown on this swarm, never a chosen number.
123
- allowanceSeconds: this.#allowanceFor(session),
124
- viewers: []
125
- };
126
- byFile.set(key, held);
127
- }
128
- for (const viewer of this.#viewersOf(session).values()) {
129
- if (viewer.isPresent(now, staleAfterMs)) {
130
- held.viewers.push({
131
- atSeconds: viewer.positionSeconds() ?? 0,
132
- playing: viewer.playing !== false
133
- });
134
- }
135
- }
136
- }
137
- }
138
- for (const one of byFile.values()) {
139
- // A file of unknown length cannot be divided into zones, and a file
140
- // nobody is watching has nothing to be urgent about.
141
- if (one.durationSeconds > 0 && one.viewers.length > 0) {
142
- this.build(one);
143
- }
144
- }
145
- }
146
-
147
- /**
148
- * Nobody is watching this file any more.
149
- *
150
- * @param {string} sourceKey
151
- * @param {number} fileIndex
152
- */
153
- /**
154
- * The map this class last built for one file.
155
- *
156
- * Read by whoever acts on it and cannot be handed it at the moment it is
157
- * made the encoding decides per output, and one file has several. It is the
158
- * SAME map: built once here, from where the viewers are, and neither read
159
- * changes it.
160
- *
161
- * @param {string} sourceKey
162
- * @param {number} fileIndex
163
- * @returns {import("./PriorityMap.js").PriorityMap} A map of no length where
164
- * none was built, which says the same as a map with nothing in it.
165
- */
166
- mapFor(sourceKey, fileIndex) {
167
- return this.#maps.get(`${sourceKey}:${fileIndex}`) ?? emptyMap(0);
168
- }
169
-
170
- forget(sourceKey, fileIndex) {
171
- this.#last.delete(`${sourceKey}:${fileIndex}`);
172
- this.#maps.delete(`${sourceKey}:${fileIndex}`);
173
- }
174
- }
1
+ /**
2
+ * @file The priority map, built once and handed to everybody who acts on it.
3
+ *
4
+ * One map per film, in seconds of film against a number. It is built from where
5
+ * the viewers are and nothing else, and both of the things that do work — the
6
+ * encoding and the downloading — read it and decide for themselves. They do not
7
+ * talk to each other, and neither of them tells this class anything.
8
+ *
9
+ * **Why it has to be published rather than asked for.** The downloading lives
10
+ * in another thread. Until now it took its orders from the reads themselves:
11
+ * every read declared a window around its own head, so fifteen reads declared
12
+ * fifteen windows on a piece store that holds sixteen pieces. Half of all
13
+ * evictions then took a piece a reader had said it wanted, two thirds of reads
14
+ * came back from disk, and what `/stream` handed out stopped being the file's
15
+ * bytes — twenty-two source-parse errors, a segment the player could not
16
+ * append, and an empty picture for six minutes (field 2026-09-05).
17
+ */
18
+
19
+ import { emptyMap, mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
20
+
21
+ export class PriorityOrchestrator {
22
+ /** Where the map goes once it is built. @type {(published: object) => void} */
23
+ #publish;
24
+
25
+ /** The last map published per film and file, so an unchanged one is not resent. */
26
+ #last = new Map();
27
+
28
+ /** The last map BUILT per film and file, for whoever reads instead of being
29
+ * handed it. @type {Map<string, import("./PriorityMap.js").PriorityMap>} */
30
+ #maps = new Map();
31
+
32
+ /**
33
+ * The last map built per OUTPUT, from the viewers of that output alone.
34
+ *
35
+ * The same fact answered at two scopes, because the two things that act on it
36
+ * ask at two scopes and both are right. The swarm is asked for bytes of a
37
+ * FILE, and the picture, a quality step and a soundtrack of one film read the
38
+ * same bytes — so every viewer of any of them wants that file's bytes.
39
+ * Encoders are placed per OUTPUT, and a person watching 480p wants nothing of
40
+ * the 1080p output at all.
41
+ *
42
+ * One map for both was the second authority over encoders. Every output of a
43
+ * film was handed the whole film's map, so the plan wanted an encoder on every
44
+ * one of them; what actually stopped the ones nobody was watching was the
45
+ * session manager killing them by its own judgement and since a viewer
46
+ * moving between steps also announces itself, the plan started them again on
47
+ * the next pass. Two parties answering "should this encoder exist" by different
48
+ * rules, several times a second.
49
+ *
50
+ * @type {Map<string, import("./PriorityMap.js").PriorityMap>}
51
+ */
52
+ #byOutput = new Map();
53
+
54
+ /** Who is watching one session. @type {(session: object) => Map<string, object>} */
55
+ #viewersOf;
56
+
57
+ /** How wide the first band of one session's file is. @type {(session: object) => number} */
58
+ #allowanceFor;
59
+
60
+ /** Whether this output is what that person is consuming, as opposed to one
61
+ * they merely hold a record on. @type {(session: object, viewer: object) => boolean} */
62
+ #watchedBy;
63
+
64
+ /**
65
+ * This layer states facts and imports nothing above itself, so what it needs
66
+ * of a session — who is watching it, and how wide an interruption this file
67
+ * has shown on this swarm — is passed in.
68
+ *
69
+ * @param {object} params
70
+ * @param {(published: { sourceKey: string, fileIndex: number, durationSeconds: number,
71
+ * zones: { from: number, to: number, priority: number }[] }) => void} params.publish
72
+ * @param {(session: object) => Map<string, object>} [params.viewersOf]
73
+ * @param {(session: object) => number} [params.allowanceFor]
74
+ * @param {(session: object, viewer: object) => boolean} [params.watchedBy] -
75
+ * Whether this output is the one that person is consuming. Which of a film's
76
+ * outputs a person has on screen is a fact about the film's shape, which
77
+ * this layer does not know; absent, every registered viewer counts, and then
78
+ * the per-output map says the same as the per-file one.
79
+ */
80
+ constructor({ publish, viewersOf, allowanceFor, watchedBy }) {
81
+ this.#publish = typeof publish === "function" ? publish : () => {};
82
+ this.#viewersOf = typeof viewersOf === "function" ? viewersOf : () => new Map();
83
+ this.#allowanceFor = typeof allowanceFor === "function" ? allowanceFor : () => 0;
84
+ this.#watchedBy = typeof watchedBy === "function" ? watchedBy : () => true;
85
+ }
86
+
87
+ /**
88
+ * A map from a set of viewers, and the ONE statement of how one is built.
89
+ *
90
+ * Asked at both scopes — once per film for the swarm, once per output for the
91
+ * encoders — and written once, because two copies of how a viewer's map is
92
+ * built is the same two-owners fault this class was split for.
93
+ *
94
+ * @param {object} params
95
+ * @param {number} params.durationSeconds
96
+ * @param {number} params.allowanceSeconds
97
+ * @param {{ atSeconds: number, playing: boolean }[]} params.viewers
98
+ * @returns {import("./PriorityMap.js").PriorityMap} A map of no length where
99
+ * nobody is watching or the film's length is unknown, which says the same as
100
+ * a map with nothing in it.
101
+ */
102
+ #mapFrom({ durationSeconds, allowanceSeconds, viewers }) {
103
+ if (!(durationSeconds > 0) || !(viewers?.length > 0)) {
104
+ return emptyMap(0);
105
+ }
106
+ return mergeMaps(
107
+ viewers.map((viewer) =>
108
+ mapForViewer({
109
+ atSeconds: viewer.atSeconds,
110
+ durationSeconds,
111
+ allowanceSeconds,
112
+ playing: viewer.playing !== false
113
+ })
114
+ )
115
+ );
116
+ }
117
+
118
+ /**
119
+ * The map for one film, from everyone watching it.
120
+ *
121
+ * @param {object} params
122
+ * @param {string} params.sourceKey
123
+ * @param {number} params.fileIndex
124
+ * @param {number} params.durationSeconds
125
+ * @param {number} params.allowanceSeconds - The measured depth below which an
126
+ * interruption reaches a viewer of this file.
127
+ * @param {{ atSeconds: number, playing: boolean }[]} params.viewers
128
+ * @returns {import("./PriorityMap.js").PriorityMap} One number per second of
129
+ * film, merged over everyone watching it.
130
+ */
131
+ build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers }) {
132
+ const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
133
+ const key = `${sourceKey}:${fileIndex}`;
134
+ this.#maps.set(key, map);
135
+ // Unchanged maps are not republished: the downloading rebuilds what it asks
136
+ // the swarm for on every one, and a viewer sitting still would otherwise
137
+ // make it do that several times a second. Compared as stretches rather than
138
+ // second by second, which is the same comparison over far fewer values.
139
+ const zones = runsOf(map);
140
+ const shape = JSON.stringify(zones);
141
+ if (this.#last.get(key) !== shape) {
142
+ this.#last.set(key, shape);
143
+ this.#publish({ sourceKey, fileIndex, durationSeconds, zones });
144
+ }
145
+ return map;
146
+ }
147
+
148
+ /**
149
+ * Build and publish the map for every file anybody is watching.
150
+ *
151
+ * One map per FILE, not per output: the picture, a quality step and a
152
+ * soundtrack of one film are three outputs reading the same bytes, and the
153
+ * swarm is asked for bytes. Viewers of all of them merge into one map.
154
+ *
155
+ * @param {object} params
156
+ * @param {Iterable<object[]>} params.sessionGroups - The live sessions, in
157
+ * whatever grouping the caller holds them; they are regrouped by file here.
158
+ * @param {number} params.staleAfterMs - How long a viewer may be silent and
159
+ * still count as watching.
160
+ * @param {number} [params.now]
161
+ * @returns {void}
162
+ */
163
+ publishFor({ sessionGroups, staleAfterMs, now = Date.now() }) {
164
+ /** @type {Map<string, { sourceKey: string, fileIndex: number, durationSeconds: number, allowanceSeconds: number, viewers: object[] }>} */
165
+ const byFile = new Map();
166
+ /** @type {Map<string, { durationSeconds: number, allowanceSeconds: number, viewers: object[] }>} */
167
+ const byOutput = new Map();
168
+ for (const sessions of sessionGroups) {
169
+ for (const session of sessions) {
170
+ const key = `${session.sourceKey}:${session.fileIndex}`;
171
+ const durationSeconds = Number(session.file?.durationSeconds) || 0;
172
+ // The first band is as wide as an interruption this file has actually
173
+ // shown on this swarm, never a chosen number.
174
+ const allowanceSeconds = this.#allowanceFor(session);
175
+ let held = byFile.get(key);
176
+ if (!held) {
177
+ held = {
178
+ sourceKey: session.sourceKey,
179
+ fileIndex: session.fileIndex,
180
+ durationSeconds,
181
+ allowanceSeconds,
182
+ viewers: []
183
+ };
184
+ byFile.set(key, held);
185
+ }
186
+ // Every output anybody holds a session for, whether or not a viewer is
187
+ // consuming it — an output with nobody on it must get a map with
188
+ // nothing in it, which is how the plan is told to stop its encoders.
189
+ // Left out, it would keep the map it had when somebody was watching.
190
+ const address = session.outputKey ?? "";
191
+ let mine = byOutput.get(address);
192
+ if (!mine) {
193
+ mine = { durationSeconds, allowanceSeconds, viewers: [] };
194
+ byOutput.set(address, mine);
195
+ }
196
+ for (const viewer of this.#viewersOf(session).values()) {
197
+ if (!viewer.isPresent(now, staleAfterMs)) {
198
+ continue;
199
+ }
200
+ const stated = {
201
+ atSeconds: viewer.positionSeconds() ?? 0,
202
+ playing: viewer.playing !== false
203
+ };
204
+ held.viewers.push(stated);
205
+ if (this.#watchedBy(session, viewer)) {
206
+ mine.viewers.push(stated);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ // WHAT THIS PASS SAW IS ALL THERE IS. Everything below is derived from the
212
+ // live sessions, so a file or an output that is not among them is gone —
213
+ // and these maps are the projection of that, never a memory of it. Left to
214
+ // accumulate they were three maps that only grew, which is the shape of half
215
+ // the memory faults recorded in this repository, and `forget` was written
216
+ // for it and called from nowhere.
217
+ for (const key of [...this.#maps.keys()]) {
218
+ if (!byFile.has(key)) {
219
+ this.#maps.delete(key);
220
+ this.#last.delete(key);
221
+ }
222
+ }
223
+ for (const address of [...this.#byOutput.keys()]) {
224
+ if (!byOutput.has(address)) {
225
+ this.#byOutput.delete(address);
226
+ }
227
+ }
228
+ for (const one of byFile.values()) {
229
+ // A file of unknown length cannot be divided into zones, and a file
230
+ // nobody is watching has nothing to be urgent about.
231
+ if (one.durationSeconds > 0 && one.viewers.length > 0) {
232
+ this.build(one);
233
+ }
234
+ }
235
+ for (const [address, one] of byOutput) {
236
+ this.#byOutput.set(address, this.#mapFrom(one));
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Nobody is watching this file any more.
242
+ *
243
+ * @param {string} sourceKey
244
+ * @param {number} fileIndex
245
+ */
246
+ /**
247
+ * The map this class last built for one file.
248
+ *
249
+ * Read by whoever acts on it and cannot be handed it at the moment it is
250
+ * made — the encoding decides per output, and one file has several. It is the
251
+ * SAME map: built once here, from where the viewers are, and neither read
252
+ * changes it.
253
+ *
254
+ * @param {string} sourceKey
255
+ * @param {number} fileIndex
256
+ * @returns {import("./PriorityMap.js").PriorityMap} A map of no length where
257
+ * none was built, which says the same as a map with nothing in it.
258
+ */
259
+ mapFor(sourceKey, fileIndex) {
260
+ return this.#maps.get(`${sourceKey}:${fileIndex}`) ?? emptyMap(0);
261
+ }
262
+
263
+ /**
264
+ * The map for ONE output, from the viewers consuming that output.
265
+ *
266
+ * What the encoding reads. A map of no length says nobody is on this output,
267
+ * which is what makes an encoder on it unwanted — and it is a statement, not
268
+ * an absence: the walk above writes one for every output a session exists
269
+ * for, including the ones everybody has left.
270
+ *
271
+ * @param {string} address
272
+ * @returns {import("./PriorityMap.js").PriorityMap}
273
+ */
274
+ mapForOutput(address) {
275
+ return this.#byOutput.get(address) ?? emptyMap(0);
276
+ }
277
+
278
+ }