@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.
@@ -121,9 +121,14 @@ export class EncodeOrchestrator {
121
121
  * @param {string} params.address
122
122
  * @param {number} params.from
123
123
  * @param {number} params.to
124
+ * @param {number} [params.priority] - Higher is sooner. One viewer states
125
+ * several stretches at once — what must be ready before they set off, what
126
+ * is reachable while they watch it, the rest of the track — and the filling
127
+ * takes them in this order. Absent means one undifferentiated want, which
128
+ * is what a caller that knows only a position states.
124
129
  */
125
- want({ claimant, address, from, to }) {
126
- this.demand.state({ claimant, address, from, to, statedAt: this.now() });
130
+ want({ claimant, address, from, to, priority = 0 }) {
131
+ this.demand.state({ claimant, address, from, to, priority, statedAt: this.now() });
127
132
  }
128
133
 
129
134
  /**
@@ -196,9 +201,14 @@ export class EncodeOrchestrator {
196
201
  });
197
202
  }
198
203
  }
204
+ // Carrying the priority through, because the filling takes the work in that
205
+ // order: what a viewer must have before they set off comes before what is
206
+ // merely in front of them, which comes before the rest of the track. Passed
207
+ // as a plain number so the plan stays arithmetic.
199
208
  const windows = this.demand.windowsOn(address).map((window) => ({
200
209
  from: window.from,
201
- to: window.to
210
+ to: window.to,
211
+ priority: Number(window.priority) || 0
202
212
  }));
203
213
  const live = this.runsOn(address).filter((run) => run.isAlive);
204
214
  const actions = planEncoders({
@@ -1,145 +1,214 @@
1
- /**
2
- * @file One person watching, and everything that is true of them alone.
3
- *
4
- * A viewer is not a property of the material. What they are listening to, which
5
- * quality step they have on screen, what is being prepared for them, where they
6
- * are and what their link can carry — none of it changes a byte of what any
7
- * encoder produces, and none of it belongs to a session, which is a description
8
- * of an OUTPUT.
9
- *
10
- * It was six parallel maps hung on the session, each keyed by consumer id:
11
- * `audioChoiceByConsumer`, `activeVariantByConsumer`,
12
- * `warmingVariantByConsumer`, `warmingAudioByConsumer`, `consumerHeads`,
13
- * `netReports`. Six places to remember to update and six to remember to forget,
14
- * and the forgetting was already wrong — releasing a consumer emptied none of
15
- * them, so a viewer who had left went on counting as wanting their soundtrack
16
- * until their head expired, and their entries stayed for the life of the
17
- * session.
18
- *
19
- * One object, one map, one thing to remove.
20
- *
21
- * **Nothing here knows about ffmpeg, the disk or the torrent.** A viewer states
22
- * what they want and where they are; what to make of that is the orchestrator's
23
- * question, and it reads a union of viewers rather than any one of them.
24
- */
25
-
26
- export class Viewer {
27
- /**
28
- * @param {string} id - The consumer id the browser sends with every request
29
- * that means "this viewer".
30
- */
31
- constructor(id) {
32
- this.id = String(id ?? "");
33
- /**
34
- * Which soundtrack this viewer is listening to and whether their browser
35
- * needs it re-encoded. Theirs alone: two viewers of one picture may have
36
- * chosen different languages, and one browser may decode a track another
37
- * cannot.
38
- * @type {{ trackIndex: number, transcode: boolean }}
39
- */
40
- this.audio = { trackIndex: 0, transcode: false };
41
- /** The quality step on their screen. Null means the base session. @type {string | null} */
42
- this.activeVariantId = null;
43
- /** A step being prepared for a switch they have not made yet. @type {string | null} */
44
- this.warmingVariantId = null;
45
- /** A soundtrack being prepared for the same reason. @type {string | null} */
46
- this.warmingAudioId = null;
47
- /**
48
- * Where they are: the segment they last asked for, or the position they
49
- * stated by seeking. `seeked` holds the stated position for as long as they
50
- * stay there, which is the distinction a cold open's soundtrack placement
51
- * turns on.
52
- * @type {{ segment: number, seconds: number, at: number, seeked?: number } | null}
53
- */
54
- this.head = null;
55
- /** What their link was last measured to carry. @type {object | null} */
56
- this.netReport = null;
57
- /**
58
- * Every output this viewer is watching, by session id: the picture, the
59
- * quality step on their screen, the soundtrack they chose.
60
- *
61
- * WHY THERE ARE TWO SETS AND NOT ONE. There is one relation — this person
62
- * watches this output and it is asked from both ends. An output asks "has
63
- * anybody left?", to decide whether to go on producing. A viewer who leaves
64
- * asks "what was I watching?", so that each of those outputs can be told.
65
- * Neither question can be answered from the other side without walking every
66
- * session in the process, so the relation is indexed both ways. It is
67
- * written in exactly one place — `Viewers.of` and `Viewers.leaves` write
68
- * both directions together — which is what keeps two indexes of one relation
69
- * from becoming two different answers.
70
- *
71
- * This is what replaced a film object. There is no "film" anywhere in this
72
- * proxy its parts are born at different times, die at different times and
73
- * are addressed separately — and the three link fields that stood in for one
74
- * could not say how many people were listening to a soundtrack.
75
- *
76
- * @type {Set<string>}
77
- */
78
- this.outputs = new Set();
79
- }
80
-
81
- /**
82
- * Whether this viewer has been heard from recently enough to still be
83
- * watching.
84
- *
85
- * Nothing releases a session when a channel closes, so without this a viewer
86
- * whose tab is gone would hold a soundtrack or a quality step for the whole
87
- * life of the session.
88
- *
89
- * @param {number} now
90
- * @param {number} staleAfterMs
91
- * @returns {boolean}
92
- */
93
- isLive(now, staleAfterMs) {
94
- return this.head !== null && now - this.head.at <= staleAfterMs;
95
- }
96
-
97
- /**
98
- * Where this viewer is, in seconds, or null when they have never said.
99
- *
100
- * @returns {number | null}
101
- */
102
- positionSeconds() {
103
- if (this.head === null) {
104
- return null;
105
- }
106
- return Number.isFinite(this.head.seeked) ? this.head.seeked : this.head.seconds;
107
- }
108
- }
109
-
110
- /**
111
- * The viewers of one session, made on first use.
112
- *
113
- * @param {object} session
114
- * @returns {Map<string, Viewer>}
115
- */
116
- export function viewersOf(session) {
117
- if (!(session.viewers instanceof Map)) {
118
- session.viewers = new Map();
119
- }
120
- return session.viewers;
121
- }
122
-
123
- /**
124
- * The viewer with this id, made if this session has not met them before.
125
- *
126
- * Use `Viewers.of` instead wherever a registry is at hand: this makes ONE
127
- * viewer per session, so the same person watching a picture, a quality step and
128
- * a soundtrack is three objects, and `outputs` — a fact about the person — is
129
- * then three sets that nothing keeps in step. It is kept for a session assembled
130
- * by hand in a test that has no registry.
131
- *
132
- * @param {object} session
133
- * @param {string} consumerId
134
- * @returns {Viewer}
135
- */
136
- export function viewerOf(session, consumerId) {
137
- const viewers = viewersOf(session);
138
- let viewer = viewers.get(consumerId);
139
- if (!viewer) {
140
- viewer = new Viewer(consumerId);
141
- viewers.set(consumerId, viewer);
142
- }
143
- viewer.outputs.add(session.id);
144
- return viewer;
145
- }
1
+ /**
2
+ * @file One person watching, and everything that is true of them alone.
3
+ *
4
+ * A viewer is not a property of the material. What they are listening to, which
5
+ * quality step they have on screen, what is being prepared for them, where they
6
+ * are and what their link can carry — none of it changes a byte of what any
7
+ * encoder produces, and none of it belongs to a session, which is a description
8
+ * of an OUTPUT.
9
+ *
10
+ * It was six parallel maps hung on the session, each keyed by consumer id:
11
+ * `audioChoiceByConsumer`, `activeVariantByConsumer`,
12
+ * `warmingVariantByConsumer`, `warmingAudioByConsumer`, `consumerHeads`,
13
+ * `netReports`. Six places to remember to update and six to remember to forget,
14
+ * and the forgetting was already wrong — releasing a consumer emptied none of
15
+ * them, so a viewer who had left went on counting as wanting their soundtrack
16
+ * until their head expired, and their entries stayed for the life of the
17
+ * session.
18
+ *
19
+ * One object, one map, one thing to remove.
20
+ *
21
+ * **TWO INDEPENDENT FACTS, NOT ONE.** A viewer is somewhere, and a viewer is
22
+ * either still here or gone. Until 2026-09-05 both were answered by one field
23
+ * the position, which is written only when a segment is requested so a viewer
24
+ * who had just arrived counted as absent, and an output all of whose viewers
25
+ * count as absent has every encoder on it stopped. That is exactly what
26
+ * happened on 2026-09-05: a soundtrack's encoder was stopped 1.25 s after it
27
+ * started, having produced nothing, its `init.mp4` was therefore never made,
28
+ * and the picture could not be played without it. The browser could not rescue
29
+ * itself either, because the only thing that would have marked the viewer
30
+ * present was a request for a segment — which needs the `init.mp4` that the
31
+ * stopped encoder was going to make.
32
+ *
33
+ * So: **position is known from the moment a viewer arrives** — it is in the
34
+ * request that created the output, as a time on the source, and it is either
35
+ * zero or what the address bar carried. And **presence is a fact of the
36
+ * connection**, not of the last file asked for.
37
+ *
38
+ * **Nothing here knows about ffmpeg, the disk or the torrent.** A viewer states
39
+ * what they want and where they are; what to make of that is the orchestrator's
40
+ * question, and it reads a union of viewers rather than any one of them.
41
+ */
42
+
43
+ export class Viewer {
44
+ /**
45
+ * @param {string} id - The consumer id the browser sends with every request
46
+ * that means "this viewer".
47
+ * @param {number} [now] - When they arrived. Presence starts here, so a
48
+ * viewer counts as watching from the instant they are known.
49
+ */
50
+ constructor(id, now = Date.now()) {
51
+ this.id = String(id ?? "");
52
+ /**
53
+ * Which soundtrack this viewer is listening to and whether their browser
54
+ * needs it re-encoded. Theirs alone: two viewers of one picture may have
55
+ * chosen different languages, and one browser may decode a track another
56
+ * cannot.
57
+ * @type {{ trackIndex: number, transcode: boolean }}
58
+ */
59
+ this.audio = { trackIndex: 0, transcode: false };
60
+ /** The quality step on their screen. Null means the base session. @type {string | null} */
61
+ this.activeVariantId = null;
62
+ /** A step being prepared for a switch they have not made yet. @type {string | null} */
63
+ this.warmingVariantId = null;
64
+ /** A soundtrack being prepared for the same reason. @type {string | null} */
65
+ this.warmingAudioId = null;
66
+ /**
67
+ * Where they are.
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
73
+ * a position.
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.
79
+ *
80
+ * @type {{ segment: number, seconds: number, at: number, seeked?: number | null } | null}
81
+ */
82
+ this.position = null;
83
+ /**
84
+ * When this viewer was last known to be there.
85
+ *
86
+ * Every piece of evidence refreshes it: a request of any kind, a link
87
+ * report, an echo of a delivery probe. It is NOT the position's timestamp —
88
+ * a viewer with a full buffer legitimately asks for nothing for a minute
89
+ * and is no less present for it.
90
+ *
91
+ * @type {number}
92
+ */
93
+ this.lastSeenAt = now;
94
+ /**
95
+ * Set when something has SAID this viewer is gone — the browser released
96
+ * the session, or their connection closed. Silence never sets it: a
97
+ * paused viewer, a viewer whose tab is hidden and whose timers the browser
98
+ * has throttled, and a viewer holding two minutes of buffer are all silent
99
+ * and all still watching.
100
+ *
101
+ * @type {boolean}
102
+ */
103
+ this.gone = false;
104
+ /** What their link was last measured to carry. @type {object | null} */
105
+ this.netReport = null;
106
+ /**
107
+ * Every output this viewer is watching, by session id: the picture, the
108
+ * quality step on their screen, the soundtrack they chose.
109
+ *
110
+ * WHY THERE ARE TWO SETS AND NOT ONE. There is one relation — this person
111
+ * watches this output and it is asked from both ends. An output asks "has
112
+ * anybody left?", to decide whether to go on producing. A viewer who leaves
113
+ * asks "what was I watching?", so that each of those outputs can be told.
114
+ * Neither question can be answered from the other side without walking every
115
+ * session in the process, so the relation is indexed both ways. It is
116
+ * written in exactly one place — `Viewers.of` and `Viewers.leaves` write
117
+ * both directions together — which is what keeps two indexes of one relation
118
+ * from becoming two different answers.
119
+ *
120
+ * This is what replaced a film object. There is no "film" anywhere in this
121
+ * proxy — its parts are born at different times, die at different times and
122
+ * are addressed separately — and the three link fields that stood in for one
123
+ * could not say how many people were listening to a soundtrack.
124
+ *
125
+ * @type {Set<string>}
126
+ */
127
+ this.outputs = new Set();
128
+ }
129
+
130
+ /**
131
+ * Whether this viewer is still watching.
132
+ *
133
+ * Two ways to stop being present, and neither of them is "asked for nothing
134
+ * recently". Something must have SAID they are gone, or nothing at all must
135
+ * have been heard from them for longer than any silence a watching viewer can
136
+ * produce.
137
+ *
138
+ * The second half exists only because the connection cannot always say: an
139
+ * `onClosed` on a data channel does not always come, and a transport that is
140
+ * not a data channel at all may have nothing to say. It is a backstop for a
141
+ * missing statement, not the ordinary way a viewer leaves.
142
+ *
143
+ * @param {number} now
144
+ * @param {number} staleAfterMs - Longer than any silence a watching viewer
145
+ * can produce. Derived from the cushion, never chosen here.
146
+ * @returns {boolean}
147
+ */
148
+ isPresent(now, staleAfterMs) {
149
+ if (this.gone) {
150
+ return false;
151
+ }
152
+ return now - this.lastSeenAt <= staleAfterMs;
153
+ }
154
+
155
+ /**
156
+ * Note that this viewer has been heard from.
157
+ *
158
+ * @param {number} [now]
159
+ * @returns {void}
160
+ */
161
+ seen(now = Date.now()) {
162
+ this.lastSeenAt = now;
163
+ }
164
+
165
+ /**
166
+ * Where this viewer is, in seconds, or null when they have somehow never been
167
+ * placed — which for a viewer created through `Viewers.of` cannot happen.
168
+ *
169
+ * @returns {number | null}
170
+ */
171
+ positionSeconds() {
172
+ if (this.position === null) {
173
+ return null;
174
+ }
175
+ return Number.isFinite(this.position.seeked) ? this.position.seeked : this.position.seconds;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The viewers of one session, made on first use.
181
+ *
182
+ * @param {object} session
183
+ * @returns {Map<string, Viewer>}
184
+ */
185
+ export function viewersOf(session) {
186
+ if (!(session.viewers instanceof Map)) {
187
+ session.viewers = new Map();
188
+ }
189
+ return session.viewers;
190
+ }
191
+
192
+ /**
193
+ * The viewer with this id, made if this session has not met them before.
194
+ *
195
+ * Use `Viewers.of` instead wherever a registry is at hand: this makes ONE
196
+ * viewer per session, so the same person watching a picture, a quality step and
197
+ * a soundtrack is three objects, and `outputs` — a fact about the person — is
198
+ * then three sets that nothing keeps in step. It is kept for a session assembled
199
+ * by hand in a test that has no registry.
200
+ *
201
+ * @param {object} session
202
+ * @param {string} consumerId
203
+ * @returns {Viewer}
204
+ */
205
+ export function viewerOf(session, consumerId) {
206
+ const viewers = viewersOf(session);
207
+ let viewer = viewers.get(consumerId);
208
+ if (!viewer) {
209
+ viewer = new Viewer(consumerId);
210
+ viewers.set(consumerId, viewer);
211
+ }
212
+ viewer.outputs.add(session.id);
213
+ return viewer;
214
+ }