@torrent-tv/proxy 2.83.8 → 2.85.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 +23 -0
- package/CLAUDE.md +5 -3
- package/docs/encode-architecture.md +26 -1
- package/docs/logs.md +97 -51
- package/package.json +1 -1
- package/routes/api/link-probe/get.js +51 -0
- package/routes/api/transcode-sessions/net-report/post.js +84 -64
- package/routes/api/transcode-sessions/post.js +139 -137
- package/routes/transcode/session-file/get.js +268 -240
- package/server.js +3 -0
- package/services/data-channel-handler.js +12 -0
- package/services/delivery-probe.js +842 -828
- package/services/hls-session-manager.js +9759 -10006
- package/services/output/OutputSpec.js +202 -195
- package/services/priority/PriorityMap.js +390 -362
- package/services/priority/PriorityOrchestrator.js +320 -318
- package/services/viewer/Viewer.js +179 -83
- package/services/viewer/positions.js +120 -48
- package/services/viewer/report-intake.js +63 -0
- package/test/held-request-width.test.js +8 -5
- package/test/link-probe.test.js +56 -0
- package/test/orchestrator-wired.test.js +14 -8
- package/test/output-spec.test.js +157 -157
- package/test/plan-is-steady.test.js +225 -0
- package/test/priority-map-emptied.test.js +205 -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-states.test.js +151 -0
- package/test/viewer.test.js +136 -108
- 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
|
/**
|
|
@@ -139,13 +149,35 @@ export class Viewer {
|
|
|
139
149
|
* @type {Set<string>}
|
|
140
150
|
*/
|
|
141
151
|
this.wantsCuesFor = new Set();
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
152
|
+
// WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
|
|
153
|
+
// BLOCKED ON US. Two booleans, because there are three states and one
|
|
154
|
+
// boolean cannot hold them:
|
|
155
|
+
//
|
|
156
|
+
// playing the picture advances, a second of film per second;
|
|
157
|
+
// waiting it does not advance because we have not delivered;
|
|
158
|
+
// neither the viewer stopped it themselves.
|
|
159
|
+
//
|
|
160
|
+
// Collapsed into one, the middle state read as the third: a viewer frozen
|
|
161
|
+
// for want of a segment counted as somebody who had chosen to stop, so
|
|
162
|
+
// nothing in front of them fell due and the work went to whoever was
|
|
163
|
+
// playing. That is backwards — a viewer waiting on us is the most urgent
|
|
164
|
+
// there is. The page tells the two apart exactly (its element says whether
|
|
165
|
+
// the picture advances, and the component owns the viewer's own pause) and
|
|
166
|
+
// states both.
|
|
167
|
+
//
|
|
168
|
+
// `playing` STARTS FALSE, and that is the whole of the fault of
|
|
169
|
+
// 2026-09-14. It used to start true, and the position is carried forward by
|
|
170
|
+
// the clock at that rate, so a viewer who had never said anything walked
|
|
171
|
+
// 146 seconds into a film they had not begun: the tab was hidden, the page
|
|
172
|
+
// deliberately held start-up until it was shown, and this end assumed the
|
|
173
|
+
// film was running the entire time. A statement about somebody else's
|
|
174
|
+
// machine is theirs to make. Until they make it the rate is zero.
|
|
175
|
+
//
|
|
176
|
+
// `waiting` STARTS TRUE, which is what a viewer arriving is: they have
|
|
177
|
+
// asked for a film and hold none of it. So their work is made first from
|
|
178
|
+
// the instant they arrive, without the position ever being invented.
|
|
179
|
+
this.playing = false;
|
|
180
|
+
this.waiting = true;
|
|
149
181
|
// Whether the page carrying this viewer is ON SCREEN, and whether the
|
|
150
182
|
// picture has been pulled out of it.
|
|
151
183
|
//
|
|
@@ -161,7 +193,9 @@ export class Viewer {
|
|
|
161
193
|
// before it could say otherwise.
|
|
162
194
|
this.onScreen = true;
|
|
163
195
|
this.inPictureInPicture = false;
|
|
164
|
-
// Seconds of film held ahead of the picture, as the page last said.
|
|
196
|
+
// Seconds of film held ahead of the picture, as the page last said. It is
|
|
197
|
+
// measured at the moment the position beside it was, which is what makes it
|
|
198
|
+
// the bound on carrying that position forward — see `positionSeconds`.
|
|
165
199
|
this.bufferedSeconds = null;
|
|
166
200
|
}
|
|
167
201
|
|
|
@@ -181,7 +215,14 @@ export class Viewer {
|
|
|
181
215
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
182
216
|
return;
|
|
183
217
|
}
|
|
184
|
-
this.position = { seconds, at: now
|
|
218
|
+
this.position = { seconds, at: now };
|
|
219
|
+
// WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
|
|
220
|
+
// ARRIVED AT. A seek empties the buffer by construction — the player
|
|
221
|
+
// discards what it holds and fetches from the new position — so carrying
|
|
222
|
+
// the old cushion over would license the position to run forward again from
|
|
223
|
+
// material that no longer exists. Zero is the truthful floor until they say
|
|
224
|
+
// otherwise, and a viewer who has just seeked says so within a tick.
|
|
225
|
+
this.bufferedSeconds = 0;
|
|
185
226
|
this.lastSeenAt = now;
|
|
186
227
|
}
|
|
187
228
|
|
|
@@ -195,13 +236,18 @@ export class Viewer {
|
|
|
195
236
|
* places, two of them on a session shared with other people.
|
|
196
237
|
*
|
|
197
238
|
* @param {object} report
|
|
198
|
-
* @param {number} report.linkMbps
|
|
239
|
+
* @param {number} [report.linkMbps] - What their link last measured. Absent
|
|
240
|
+
* until something measurable has crossed it, and then the rest of the
|
|
241
|
+
* report still stands: this is a statement about a viewer, not about a link.
|
|
199
242
|
* @param {number} report.bufferedAheadSec
|
|
200
243
|
* @param {number | null} [report.positionSeconds] - Null from a page that
|
|
201
244
|
* does not say; then the position stands as it was.
|
|
202
|
-
* @param {boolean} [report.playing] -
|
|
203
|
-
*
|
|
204
|
-
*
|
|
245
|
+
* @param {boolean} [report.playing] - Whether the picture is advancing.
|
|
246
|
+
* Absent means it was not stated, and nothing may be assumed about somebody
|
|
247
|
+
* else's machine: the rate is then zero.
|
|
248
|
+
* @param {boolean} [report.waiting] - Whether this viewer is blocked on
|
|
249
|
+
* material we owe them. Absent from a page one release behind; then it is
|
|
250
|
+
* derived from the two facts that page does state.
|
|
205
251
|
* @param {boolean} [report.onScreen] - Whether the page is visible, or the
|
|
206
252
|
* picture is in picture-in-picture. Absent means on screen.
|
|
207
253
|
* @param {boolean} [report.inPictureInPicture] - Whether the picture has been
|
|
@@ -214,87 +260,94 @@ export class Viewer {
|
|
|
214
260
|
bufferedAheadSec,
|
|
215
261
|
positionSeconds = null,
|
|
216
262
|
playing,
|
|
263
|
+
waiting,
|
|
217
264
|
onScreen,
|
|
218
265
|
inPictureInPicture
|
|
219
266
|
},
|
|
220
267
|
now = Date.now()
|
|
221
268
|
) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
269
|
+
const held = Number.isFinite(bufferedAheadSec) && bufferedAheadSec > 0 ? bufferedAheadSec : 0;
|
|
270
|
+
// A LINK FIGURE IS ONE FIELD OF THIS REPORT AND NOT ITS TICKET. A page that
|
|
271
|
+
// has transferred nothing measurable has nothing to say about its link and
|
|
272
|
+
// everything to say about its viewer, and that is the cold open exactly —
|
|
273
|
+
// the moment the position matters most. Held as a precondition, in three
|
|
274
|
+
// places at once, it silenced every statement of the session of 2026-09-14.
|
|
275
|
+
if (Number.isFinite(linkMbps) && linkMbps > 0) {
|
|
276
|
+
this.netReport = {
|
|
277
|
+
linkMbps,
|
|
278
|
+
bufferedAheadSec: held,
|
|
279
|
+
positionSeconds:
|
|
280
|
+
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
|
|
281
|
+
at: now
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
// The position first, then the cushion: `moveTo` clears what was held at
|
|
285
|
+
// wherever they were before, and this report's own figure is measured at
|
|
286
|
+
// the position this report states.
|
|
233
287
|
if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
|
|
234
288
|
this.moveTo(/** @type {number} */ (positionSeconds), now);
|
|
235
289
|
}
|
|
290
|
+
this.bufferedSeconds = held;
|
|
291
|
+
this.playing = playing === true;
|
|
292
|
+
// A page that states `playing` and not `waiting` is one release behind this
|
|
293
|
+
// proxy, which is the ordinary state of a rolling pool — the proxy ships
|
|
294
|
+
// first by rule. It says `false` for both a viewer who stopped the picture
|
|
295
|
+
// and a viewer starved of material, so the two are told apart by the one
|
|
296
|
+
// quantity it does state: a stopped viewer holding film chose to stop, a
|
|
297
|
+
// stopped viewer holding nothing is waiting on us. It errs toward making
|
|
298
|
+
// material, which is the direction that costs an encoder rather than a
|
|
299
|
+
// viewer.
|
|
300
|
+
this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
|
|
301
|
+
this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
|
|
302
|
+
this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
|
|
236
303
|
this.seen(now);
|
|
237
304
|
}
|
|
238
305
|
|
|
239
306
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* Film is consumed at one second per second while the picture moves, so the
|
|
243
|
-
* moment they run dry is now plus what they hold. Stopped, they consume
|
|
244
|
-
* nothing and there is no such moment — which is why a pause needs no rule of
|
|
245
|
-
* its own anywhere: it falls out of this as an absent deadline.
|
|
246
|
-
*
|
|
247
|
-
* @param {number} [now]
|
|
248
|
-
* @returns {number | null}
|
|
249
|
-
*/
|
|
250
|
-
deadlineAt(now = Date.now()) {
|
|
251
|
-
if (!this.playing) {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
255
|
-
return now + held * 1000;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Whether this viewer is still watching.
|
|
307
|
+
* Whether this viewer is here.
|
|
260
308
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
309
|
+
* PRESENCE IS THE CONNECTION, AND ONLY THE CONNECTION. They are here from the
|
|
310
|
+
* moment they are known until something SAYS they are gone: the browser
|
|
311
|
+
* releasing the session, or their connection closing. Nothing else, and in
|
|
312
|
+
* particular not silence.
|
|
265
313
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
314
|
+
* Silence used to end it, after an interval longer than "any silence a
|
|
315
|
+
* watching viewer can produce" — and there is no such interval. A viewer who
|
|
316
|
+
* has paused, whose tab is hidden and whose timers the browser has throttled,
|
|
317
|
+
* or who holds two minutes of cushion, all say nothing for as long as they
|
|
318
|
+
* like and are all still watching. Deciding presence from that made it a
|
|
319
|
+
* guess with a threshold; deciding it from the connection makes it a fact
|
|
320
|
+
* with an owner.
|
|
270
321
|
*
|
|
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
322
|
* @returns {boolean}
|
|
275
323
|
*/
|
|
276
324
|
/**
|
|
277
|
-
* Whether this viewer
|
|
325
|
+
* Whether this viewer wants film NOW — either watching it or waiting for it.
|
|
278
326
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* due, so the work goes to whoever is watching — and both leave them a place
|
|
282
|
-
* in the priority map, because they are still there and will want it again.
|
|
327
|
+
* The one predicate every reading about urgency asks, and it is a union of
|
|
328
|
+
* two states rather than the negation of a pause:
|
|
283
329
|
*
|
|
330
|
+
* playing consuming, and will run dry when their cushion does;
|
|
331
|
+
* waiting consuming nothing only because we have delivered nothing;
|
|
332
|
+
* neither they stopped the picture themselves.
|
|
333
|
+
*
|
|
334
|
+
* A viewer who has stopped it keeps their place in the priority map — they
|
|
335
|
+
* are still there and will want the film in front of them again — but nothing
|
|
336
|
+
* of theirs falls due, so the work goes to whoever is watching or waiting.
|
|
337
|
+
*
|
|
338
|
+
* A page that is not on screen is not consuming: its timers are throttled and
|
|
339
|
+
* it asks for nothing, which is indistinguishable from a full cushion.
|
|
284
340
|
* Picture-in-picture is watching with the tab hidden, and the page folds that
|
|
285
|
-
* into `onScreen` before
|
|
341
|
+
* into `onScreen` before saying it, so it needs no case of its own here.
|
|
286
342
|
*
|
|
287
343
|
* @returns {boolean}
|
|
288
344
|
*/
|
|
289
|
-
|
|
290
|
-
return this.playing
|
|
345
|
+
wantsFilmNow() {
|
|
346
|
+
return (this.playing === true || this.waiting === true) && this.onScreen !== false;
|
|
291
347
|
}
|
|
292
348
|
|
|
293
|
-
isPresent(
|
|
294
|
-
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
return now - this.lastSeenAt <= staleAfterMs;
|
|
349
|
+
isPresent() {
|
|
350
|
+
return this.gone !== true;
|
|
298
351
|
}
|
|
299
352
|
|
|
300
353
|
/**
|
|
@@ -308,16 +361,59 @@ export class Viewer {
|
|
|
308
361
|
}
|
|
309
362
|
|
|
310
363
|
/**
|
|
311
|
-
* Where this viewer is, in seconds
|
|
312
|
-
*
|
|
364
|
+
* Where this viewer is NOW, in seconds of film.
|
|
365
|
+
*
|
|
366
|
+
* A FUNCTION OF TIME, not a stored number. A viewer whose picture is moving
|
|
367
|
+
* covers a second of film every second, so between the moments they speak
|
|
368
|
+
* their position is known exactly — it is where they last were plus the time
|
|
369
|
+
* since. A viewer whose picture is stopped covers nothing and stays where
|
|
370
|
+
* they are, which is the same formula with the rate at zero.
|
|
313
371
|
*
|
|
314
|
-
*
|
|
372
|
+
* position(now) = stated + rate * min(now - statedAt, held),
|
|
373
|
+
* rate = playing ? 1 : 0
|
|
374
|
+
*
|
|
375
|
+
* **THE SECOND TERM IS BOUNDED BY WHAT THEY HELD**, and the bound is stated
|
|
376
|
+
* by the viewer rather than chosen here: nobody can play past the end of what
|
|
377
|
+
* they have. A viewer who said "I hold thirty seconds" and has said nothing
|
|
378
|
+
* for two minutes has played at most thirty of those seconds; the rest of the
|
|
379
|
+
* silence is a viewer who ran dry, or one we are no longer hearing from, and
|
|
380
|
+
* in both cases they are not where the clock alone would put them.
|
|
381
|
+
*
|
|
382
|
+
* Unbounded, it walks a viewer off the end of a film they never started. On
|
|
383
|
+
* 2026-09-14 the tab was hidden for 145 seconds while the page deliberately
|
|
384
|
+
* held start-up until it was shown, the rate was assumed to be one, and this
|
|
385
|
+
* end placed the viewer at 145.979 s — the exact `-ss` the soundtrack's
|
|
386
|
+
* encoder was given, while the browser was asking for segment #0 and getting
|
|
387
|
+
* 503 for sixty seconds. Their cushion was 0.0 s throughout, so this bound
|
|
388
|
+
* alone would have held them at zero whatever the rate said.
|
|
389
|
+
*
|
|
390
|
+
* The bound binds only where the clock has outrun the cushion — a report lost,
|
|
391
|
+
* or a viewer starving — and there it under-states the position. That is the
|
|
392
|
+
* safe direction and deliberately so: under-stating makes material a viewer
|
|
393
|
+
* has already passed, which costs an encoder; over-stating skips material
|
|
394
|
+
* they are about to need, which costs the viewer the picture.
|
|
395
|
+
*
|
|
396
|
+
* Held as a stored number instead, it was a staircase: flat for the ten
|
|
397
|
+
* seconds between reports and then a jump, so everything derived from it —
|
|
398
|
+
* the priority map, and through the map every encoder — changed in steps for
|
|
399
|
+
* a viewer who was moving smoothly. And because two writers were filling that
|
|
400
|
+
* number in turn, the steps went backwards as often as forwards.
|
|
401
|
+
*
|
|
402
|
+
* There is nothing to tune here and no state to keep: one reading, one clock,
|
|
403
|
+
* one rate the page itself states.
|
|
404
|
+
*
|
|
405
|
+
* @param {number} [now]
|
|
406
|
+
* @returns {number | null} Null only for a viewer never placed, which for one
|
|
407
|
+
* created through `Viewers.of` cannot happen.
|
|
315
408
|
*/
|
|
316
|
-
positionSeconds() {
|
|
409
|
+
positionSeconds(now = Date.now()) {
|
|
317
410
|
if (this.position === null) {
|
|
318
411
|
return null;
|
|
319
412
|
}
|
|
320
|
-
|
|
413
|
+
const rate = this.playing === true ? 1 : 0;
|
|
414
|
+
const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
|
|
415
|
+
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
416
|
+
return this.position.seconds + rate * Math.min(elapsedSec, held);
|
|
321
417
|
}
|
|
322
418
|
}
|
|
323
419
|
|
|
@@ -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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A viewer's statement about itself, taken in.
|
|
3
|
+
*
|
|
4
|
+
* The VIEWER layer's own entry point, and the whole of what happens when a page
|
|
5
|
+
* says where it is: the statement reaches the person it is about, and readings
|
|
6
|
+
* that have gone stale stop deciding for the people still here.
|
|
7
|
+
*
|
|
8
|
+
* It lived in the session manager, which is a description of an OUTPUT, and the
|
|
9
|
+
* two questions were tangled there: whether this is a measurement of a LINK,
|
|
10
|
+
* and whether this is a statement by a VIEWER. Required to be both, a page that
|
|
11
|
+
* had measured nothing could say nothing — see `Viewer.report`.
|
|
12
|
+
*
|
|
13
|
+
* Given the session and the registry as plain arguments, so nothing here knows
|
|
14
|
+
* how a session is found, what a variant is, or that there is an encoder.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { viewersOf } from "./Viewer.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Take one report.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} params
|
|
23
|
+
* @param {{ of: (session: object, consumerId: string) => import("./Viewer.js").Viewer }} params.viewers
|
|
24
|
+
* @param {object} params.session - The output this viewer has on screen.
|
|
25
|
+
* @param {string} params.consumerId - Who is reporting; empty when the page
|
|
26
|
+
* does not name itself, and then every such page shares one viewer, which is
|
|
27
|
+
* exactly what one unnamed browser is.
|
|
28
|
+
* @param {object} params.report - What they said about themselves.
|
|
29
|
+
* @param {number} params.now
|
|
30
|
+
* @param {number} params.linkFreshMs - How long a link reading describes the
|
|
31
|
+
* link it was measured on.
|
|
32
|
+
* @returns {true}
|
|
33
|
+
*/
|
|
34
|
+
export function takeViewerReport({ viewers, session, consumerId, report, now, linkFreshMs }) {
|
|
35
|
+
viewers.of(session, consumerId).report(report, now);
|
|
36
|
+
forgetStaleLinkReadings({ session, now, linkFreshMs });
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Drop link readings older than the time they describe.
|
|
42
|
+
*
|
|
43
|
+
* ONLY THE READING EXPIRES. Whether the person is still watching is a different
|
|
44
|
+
* question with its own answer — their connection — and a viewer who has simply
|
|
45
|
+
* stopped reporting is not thereby gone. Answering both from this one place is
|
|
46
|
+
* what stopped a soundtrack's encoder on 2026-09-05, seconds after it started
|
|
47
|
+
* and before it had made the `init.mp4` the picture could not be played
|
|
48
|
+
* without.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} params
|
|
51
|
+
* @param {object} params.session
|
|
52
|
+
* @param {number} params.now
|
|
53
|
+
* @param {number} params.linkFreshMs
|
|
54
|
+
* @returns {void}
|
|
55
|
+
*/
|
|
56
|
+
export function forgetStaleLinkReadings({ session, now, linkFreshMs }) {
|
|
57
|
+
for (const viewer of viewersOf(session).values()) {
|
|
58
|
+
const reading = viewer.netReport;
|
|
59
|
+
if (reading !== null && now - reading.at > linkFreshMs) {
|
|
60
|
+
viewer.netReport = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -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
|
|