@torrent-tv/proxy 2.79.0 → 2.80.1
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 +1580 -1558
- package/bin/cli.js +20 -1
- package/package.json +1 -1
- package/server.js +16 -0
- package/services/demand/DemandRegister.js +20 -0
- package/services/download/SwarmSelection.js +4 -1
- package/services/encode/EncodePlan.js +159 -31
- package/services/encode/SegmentStore.js +29 -0
- package/services/encode/run-budget.js +8 -0
- package/services/hls-session-manager.js +25 -8
- package/services/orchestrators/EncodeOrchestrator.js +27 -5
- package/services/piece-store/piece-lru.js +91 -31
- package/services/piece-store/shared-piece-store.js +1531 -1512
- package/services/priority/PriorityMap.js +57 -101
- package/services/priority/PriorityOrchestrator.js +149 -0
- package/services/torrent-pool.js +104 -0
- package/services/torrent-worker/client.js +20 -0
- package/services/torrent-worker/piece-reader.js +57 -217
- package/services/torrent-worker/pool-adapter.js +17 -0
- package/services/torrent-worker/protocol.js +10 -0
- package/services/torrent-worker/worker.js +11 -0
- package/services/usrsctp-state.js +35 -6
- package/test/demand-register.test.js +11 -0
- package/test/encode-plan.test.js +65 -0
- package/test/piece-lru.test.js +61 -0
- package/test/piece-store-eviction.test.js +30 -0
- package/test/priority-map-download.test.js +156 -0
- package/test/priority-map.test.js +50 -77
- package/test/read-window.test.js +46 -29
- package/test/usrsctp-state.test.js +18 -2
- package/test/read-bands.test.js +0 -140
package/bin/cli.js
CHANGED
|
@@ -85,6 +85,10 @@ program
|
|
|
85
85
|
.option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
|
|
86
86
|
.option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
|
|
87
87
|
.option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
|
|
88
|
+
.option(
|
|
89
|
+
"--usrsctp-state",
|
|
90
|
+
"Read usrsctp's association state with gdb when a wedge is declared. OFF by default: gdb attaches to THIS process and stops every thread of it while it works."
|
|
91
|
+
)
|
|
88
92
|
.option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
|
|
89
93
|
.option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
|
|
90
94
|
.option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
|
|
@@ -345,7 +349,22 @@ try {
|
|
|
345
349
|
// declared (roadmap item 11) — no source rebuild, the module ships
|
|
346
350
|
// unstripped. A host without gdb just never gets a reading, the same way a
|
|
347
351
|
// host without tcpdump never gets a packet capture.
|
|
348
|
-
|
|
352
|
+
// OFF UNLESS ASKED FOR, and the reason is a field incident rather than
|
|
353
|
+
// caution. On 2026-09-05 a delivery probe declared a wedge that lasted half a
|
|
354
|
+
// second and cleared itself; the reading it triggered attached gdb to this
|
|
355
|
+
// process, which stopped all eighty of its threads. The log ended mid-second,
|
|
356
|
+
// /healthz stopped answering, and the viewer waited a minute and was told the
|
|
357
|
+
// proxy had sent no video. The fifteen-second guard could not fire — it is a
|
|
358
|
+
// timer inside the process gdb had stopped — and killing gdb left the main
|
|
359
|
+
// thread deadlocked for good, so only restarting the addon recovered it.
|
|
360
|
+
//
|
|
361
|
+
// A means of diagnosis may not stop the product. This one attaches to a live
|
|
362
|
+
// process and is triggered by a verdict with a known history of false
|
|
363
|
+
// positives, so it is a thing to switch on deliberately while watching, not
|
|
364
|
+
// something that arms itself.
|
|
365
|
+
usrsctpStateReader = options.usrsctpState === true
|
|
366
|
+
? createUsrsctpStateReader({ log: (message) => logger.info(message) })
|
|
367
|
+
: null;
|
|
349
368
|
|
|
350
369
|
// What this process holds, once a minute. The kernel killed the proxy on
|
|
351
370
|
// 2026-08-28 at 2.4 GB resident and the log had never said a word about
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -172,6 +172,22 @@ export async function startProxyServer({
|
|
|
172
172
|
}
|
|
173
173
|
return torrentPool.getTorrentTotals();
|
|
174
174
|
},
|
|
175
|
+
// The priority map, on its way to the downloading. It lives in another
|
|
176
|
+
// thread, so the map crosses the worker channel; what it does with it —
|
|
177
|
+
// seconds into bytes, what to ask the swarm for, what to keep in memory —
|
|
178
|
+
// is its own business.
|
|
179
|
+
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
180
|
+
const record = sourceRegistry.get(sourceKey);
|
|
181
|
+
if (!record) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
|
|
186
|
+
} catch {
|
|
187
|
+
// Best effort: the map is republished on the next change, and the
|
|
188
|
+
// downloading goes on serving reads meanwhile.
|
|
189
|
+
}
|
|
190
|
+
},
|
|
175
191
|
getSourceStats: async (sourceKey, fileIndex) => {
|
|
176
192
|
const record = sourceRegistry.get(sourceKey);
|
|
177
193
|
if (!record) {
|
|
@@ -84,6 +84,26 @@ export class DemandRegister {
|
|
|
84
84
|
return this.windows().filter((window) => window.urgency === urgency);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* How urgently one byte of one file is wanted, by whoever wants it most.
|
|
89
|
+
*
|
|
90
|
+
* The priority map states its zones here like anybody else, so this answers
|
|
91
|
+
* for a byte nobody is reading yet: it is how a wait is attributed to a level
|
|
92
|
+
* without the reader keeping a forecast of its own.
|
|
93
|
+
*
|
|
94
|
+
* @param {number} fileIndex
|
|
95
|
+
* @param {number} byteOffset
|
|
96
|
+
* @returns {number | null} Null when nothing covers that byte.
|
|
97
|
+
*/
|
|
98
|
+
urgencyAt(fileIndex, byteOffset) {
|
|
99
|
+
// `windows()` is sorted most urgent first, so the first cover is the answer.
|
|
100
|
+
const found = this.windows().find((window) =>
|
|
101
|
+
window.fileIndex === fileIndex &&
|
|
102
|
+
byteOffset >= window.byteStart &&
|
|
103
|
+
byteOffset <= window.byteEnd);
|
|
104
|
+
return found ? found.urgency : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
/**
|
|
88
108
|
* The union of what is wanted of one file at one level, in byte ranges.
|
|
89
109
|
*
|
|
@@ -224,7 +224,10 @@ export class SwarmSelection {
|
|
|
224
224
|
if (!range) {
|
|
225
225
|
continue;
|
|
226
226
|
}
|
|
227
|
-
|
|
227
|
+
// The level goes with the range: it is what eviction compares when
|
|
228
|
+
// everything resident is wanted by somebody, and dropping it here is
|
|
229
|
+
// what left the store choosing by recency alone.
|
|
230
|
+
store.protectRange(window.claimant, range.from, range.to, window.urgency);
|
|
228
231
|
holding.add(window.claimant);
|
|
229
232
|
}
|
|
230
233
|
}
|
|
@@ -232,37 +232,51 @@ export function planEncoders({
|
|
|
232
232
|
// lowest number, because that is where a viewer is stopped. The budget
|
|
233
233
|
// rarely stretches to every gap, so which one is taken first is the whole
|
|
234
234
|
// of what a viewer's presence decides.
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
235
|
+
const budget = Math.max(0, maxRuns - surviving.size);
|
|
236
|
+
const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
|
|
237
|
+
for (const from of placeEncoders({
|
|
238
|
+
coverage,
|
|
239
|
+
windows: wanted,
|
|
240
|
+
howMany: budget,
|
|
241
|
+
speedX: live.reduce((best, run) => Math.max(best, run.speedX || 0), 0)
|
|
242
|
+
})) {
|
|
243
|
+
if (alreadyPlanned.has(from)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
alreadyPlanned.add(from);
|
|
247
|
+
starts.push({
|
|
248
|
+
type: "start",
|
|
249
|
+
from,
|
|
250
|
+
to: endOfStretch(from, coverage.freeRunFrom(from)),
|
|
251
|
+
because: `#${from} is wanted and nobody is making it`
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ONE ENCODER'S WORK ENDS WHERE THE NEXT ONE'S BEGINS.
|
|
256
|
+
//
|
|
257
|
+
// A free stretch may run to the end of the track, and an encoder given all of
|
|
258
|
+
// it stands in the road of every encoder placed behind it: they write the
|
|
259
|
+
// same names, and each one's output is the other's "material somebody else
|
|
260
|
+
// made", so they stop one another. Field 2026-09-05: three encoders started
|
|
261
|
+
// on one track within 200 ms, each into the road another was already writing.
|
|
262
|
+
// Fifteen readers on a piece store that holds sixteen pieces followed, half
|
|
263
|
+
// of all evictions took a piece a reader had declared, and `/stream` began
|
|
264
|
+
// handing out bytes that were not the file's.
|
|
265
|
+
//
|
|
266
|
+
// The bound is taken from the NEXT ENCODER'S START, not from a band edge: a
|
|
267
|
+
// band edge travels with the viewer, so every step forward would leave a
|
|
268
|
+
// sliver just past the previous encoder and buy an encoder for it.
|
|
269
|
+
const placed = [...moves, ...starts].sort(
|
|
270
|
+
(left, right) => /** @type {any} */ (left).from - /** @type {any} */ (right).from
|
|
271
|
+
);
|
|
272
|
+
for (let index = 0; index < placed.length - 1; index += 1) {
|
|
273
|
+
const here = /** @type {{ from: number, to: number }} */ (placed[index]);
|
|
274
|
+
const next = /** @type {{ from: number }} */ (placed[index + 1]);
|
|
275
|
+
if (here.to < 0 || here.to >= next.from) {
|
|
276
|
+
here.to = next.from - 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
266
280
|
return [...stops, ...moves, ...starts, ...keeps];
|
|
267
281
|
}
|
|
268
282
|
|
|
@@ -307,3 +321,117 @@ export function firstUnmetWant(coverage, windows) {
|
|
|
307
321
|
}
|
|
308
322
|
return lowest;
|
|
309
323
|
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Where to put the encoders this machine can afford.
|
|
327
|
+
*
|
|
328
|
+
* Two things are wanted of a division of the film, and they are wanted in this
|
|
329
|
+
* order:
|
|
330
|
+
*
|
|
331
|
+
* 1. **the viewer must not stop.** An encoder starting at `q` stays ahead of a
|
|
332
|
+
* viewer at `p` while `y / s <= q + y - p`, so it holds `(q - p) * s / (1-s)`
|
|
333
|
+
* of film and no more. Beyond that the viewer catches it, and the next
|
|
334
|
+
* encoder has to be standing there. That is where the first ones go, and it
|
|
335
|
+
* is why the stretches grow: the further off one starts, the later the
|
|
336
|
+
* viewer arrives and the longer it may work;
|
|
337
|
+
* 2. **the film should be finished as soon as possible.** Once the viewer is
|
|
338
|
+
* safe, whatever is left is divided EQUALLY between the encoders that
|
|
339
|
+
* remain: equal shares finish together, and any other division finishes when
|
|
340
|
+
* its longest share does. That is what makes seeking cheap — the film exists.
|
|
341
|
+
*
|
|
342
|
+
* At or above realtime the first requirement is met by one encoder for the
|
|
343
|
+
* whole film, and every other encoder goes to the second — which is the common
|
|
344
|
+
* case on a copied picture, and is why "one viewer, one encoder" was never the
|
|
345
|
+
* rule.
|
|
346
|
+
*
|
|
347
|
+
* @param {object} params
|
|
348
|
+
* @param {import("./CoverageMap.js").CoverageMap} params.coverage
|
|
349
|
+
* @param {WantedSpan[]} params.windows - The merged map, in this output's own
|
|
350
|
+
* numbering. Its highest-numbered band starts where the viewer is.
|
|
351
|
+
* @param {number} params.howMany - What the machine affords.
|
|
352
|
+
* @param {number} params.speedX - Measured. Zero when nothing has measured it,
|
|
353
|
+
* and then only the first requirement can be served.
|
|
354
|
+
* @returns {number[]} Where to start each encoder, ascending.
|
|
355
|
+
*/
|
|
356
|
+
export function placeEncoders({ coverage, windows, howMany, speedX }) {
|
|
357
|
+
if (!(howMany > 0) || windows.length === 0) {
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
const byUrgency = [...windows].sort(
|
|
361
|
+
(left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from
|
|
362
|
+
);
|
|
363
|
+
const viewer = byUrgency[0].from;
|
|
364
|
+
const lastWanted = Math.max(...windows.map((span) => span.to));
|
|
365
|
+
|
|
366
|
+
/** @type {number[]} */
|
|
367
|
+
const places = [];
|
|
368
|
+
const take = (at) => {
|
|
369
|
+
const gap = coverage.firstGapFrom(at, lastWanted);
|
|
370
|
+
if (gap !== null && !places.includes(gap)) {
|
|
371
|
+
places.push(gap);
|
|
372
|
+
}
|
|
373
|
+
return gap;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// The first one goes where somebody is stopped.
|
|
377
|
+
let previous = take(viewer);
|
|
378
|
+
if (previous === null) {
|
|
379
|
+
// Nothing ahead is missing. Whatever is left anywhere is divided equally.
|
|
380
|
+
return divideEqually(coverage, byUrgency, howMany, places);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const growth = speedX > 0 && speedX < 1 ? speedX / (1 - speedX) : Number.POSITIVE_INFINITY;
|
|
384
|
+
while (places.length < howMany) {
|
|
385
|
+
const holds = (previous - viewer) * growth;
|
|
386
|
+
if (!Number.isFinite(holds) || previous + holds > lastWanted) {
|
|
387
|
+
// Either it keeps ahead for the rest of the film, or its guarantee runs
|
|
388
|
+
// past the end of what anybody wants. Nobody is in danger further on, so
|
|
389
|
+
// the encoders left over go to finishing the film sooner.
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
const next = take(Math.ceil(previous + Math.max(1, holds)));
|
|
393
|
+
if (next === null || next <= previous) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
previous = next;
|
|
397
|
+
}
|
|
398
|
+
return divideEqually(coverage, byUrgency, howMany, places);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Spread whatever encoders are left over the film that is still missing.
|
|
403
|
+
*
|
|
404
|
+
* Equal shares, because equal shares finish together: any other division is
|
|
405
|
+
* finished when its longest share is, which is later.
|
|
406
|
+
*
|
|
407
|
+
* @param {import("./CoverageMap.js").CoverageMap} coverage
|
|
408
|
+
* @param {WantedSpan[]} byUrgency
|
|
409
|
+
* @param {number} howMany
|
|
410
|
+
* @param {number[]} places
|
|
411
|
+
* @returns {number[]}
|
|
412
|
+
*/
|
|
413
|
+
function divideEqually(coverage, byUrgency, howMany, places) {
|
|
414
|
+
for (const span of byUrgency) {
|
|
415
|
+
if (places.length >= howMany) {
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
const gap = coverage.firstGapFrom(span.from, span.to);
|
|
419
|
+
if (gap === null) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
// Where this band's missing film would be cut if the encoders left over
|
|
423
|
+
// shared it. One share per encoder, and the first share is the gap itself.
|
|
424
|
+
const left = howMany - places.length;
|
|
425
|
+
const width = Math.max(1, Math.floor((span.to - gap + 1) / left));
|
|
426
|
+
for (let at = gap; at <= span.to && places.length < howMany; at += width) {
|
|
427
|
+
const found = coverage.firstGapFrom(at, span.to);
|
|
428
|
+
if (found === null) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
if (!places.includes(found)) {
|
|
432
|
+
places.push(found);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return [...places].sort((left, right) => left - right);
|
|
437
|
+
}
|
|
@@ -258,6 +258,35 @@ export class SegmentStore {
|
|
|
258
258
|
return proven.sort((left, right) => left - right);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* A run is about to write these numbers again: forget that they were closed.
|
|
263
|
+
*
|
|
264
|
+
* A number closed once is not closed for ever. An encoder started at #N
|
|
265
|
+
* rewrites #N and everything after it, and while it is doing so the file
|
|
266
|
+
* under that name is half a segment — but the store remembered the earlier
|
|
267
|
+
* closing and would call it whole. Field 2026-09-05: seventeen runs were
|
|
268
|
+
* stopped and none ended normally, so numbers were being rewritten
|
|
269
|
+
* constantly, and the player met a fatal append error it never recovered
|
|
270
|
+
* from — an empty picture for the six minutes that followed.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} key
|
|
273
|
+
* @param {number} from
|
|
274
|
+
*/
|
|
275
|
+
forgetClosedFrom(key, from) {
|
|
276
|
+
const known = this.#closed.get(key);
|
|
277
|
+
if (!known || !Number.isInteger(from)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
for (const index of known) {
|
|
281
|
+
if (index >= from) {
|
|
282
|
+
known.delete(index);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// What the directory says has to be read again too: the successor rule
|
|
286
|
+
// would otherwise prove the rewritten piece from a file made before it.
|
|
287
|
+
this.#held.delete(key);
|
|
288
|
+
}
|
|
289
|
+
|
|
261
290
|
/**
|
|
262
291
|
* Whether this piece is finished, and may therefore be served.
|
|
263
292
|
*
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Which of them bound the answer is returned beside it, because "why is there
|
|
26
26
|
* only one encoder" is otherwise a question no log can answer.
|
|
27
|
+
*
|
|
28
|
+
* **Only the first is supplied today.** The two readings the others need are
|
|
29
|
+
* both in the torrent thread — what the swarm delivers against the film's own
|
|
30
|
+
* byte rate, and the store's allowance against what one reader keeps — and
|
|
31
|
+
* carrying them across is its own piece of work, deliberately not done here.
|
|
32
|
+
* Until it is, the swarm and the memory terms are inert: this returns what the
|
|
33
|
+
* processor allows, and says so. Stated rather than left to be discovered from
|
|
34
|
+
* a parameter nobody passes.
|
|
27
35
|
*/
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -24,6 +24,7 @@ import { availableShareFrom } from "./available-share.js";
|
|
|
24
24
|
import { contentionPenalty } from "./contention.js";
|
|
25
25
|
import { minimumBufferFrom } from "./supply-margin.js";
|
|
26
26
|
import { mapForViewer } from "./priority/PriorityMap.js";
|
|
27
|
+
import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
|
|
27
28
|
import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
|
|
28
29
|
import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
|
|
29
30
|
import {
|
|
@@ -1522,6 +1523,7 @@ export class HlsSessionManager {
|
|
|
1522
1523
|
softwarePresetBenchmark = null,
|
|
1523
1524
|
decodeCostModel = null,
|
|
1524
1525
|
getSourceStats = null,
|
|
1526
|
+
setPriorityMap = null,
|
|
1525
1527
|
// What a second job costs on this host, measured at startup. Null when it
|
|
1526
1528
|
// could not be measured, and then nothing is corrected — the alternative
|
|
1527
1529
|
// is inventing a penalty, which is the same fault as inventing a fill rate.
|
|
@@ -1578,6 +1580,7 @@ export class HlsSessionManager {
|
|
|
1578
1580
|
// realtime budget to tell a CPU limit from a download-starved input:
|
|
1579
1581
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
1580
1582
|
this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
|
|
1583
|
+
this.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
|
|
1581
1584
|
this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
1582
1585
|
// Totals across every torrent this proxy holds, used to price what the
|
|
1583
1586
|
// torrent itself costs the machine (item 7). Optional: a proxy wired
|
|
@@ -1669,6 +1672,21 @@ export class HlsSessionManager {
|
|
|
1669
1672
|
encodersRunningNow: () => this.#encodersRunningNow(),
|
|
1670
1673
|
torrentCostSecFor: (session) => this.#torrentCostSecFor(session)
|
|
1671
1674
|
});
|
|
1675
|
+
// The priority map, built from where the viewers are and handed to both
|
|
1676
|
+
// sides that act on it. The downloading lives in another thread, so its
|
|
1677
|
+
// copy travels over the worker channel.
|
|
1678
|
+
this.priority = new PriorityOrchestrator({
|
|
1679
|
+
publish: ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
1680
|
+
void Promise.resolve(
|
|
1681
|
+
this.setPriorityMap?.({ sourceKey, fileIndex, durationSeconds, zones })
|
|
1682
|
+
).catch(() => {});
|
|
1683
|
+
},
|
|
1684
|
+
viewersOf: (session) => viewersOf(session),
|
|
1685
|
+
allowanceFor: (session) => minimumBufferFrom({
|
|
1686
|
+
segmentSeconds: this.segmentDurationSec,
|
|
1687
|
+
worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
|
|
1688
|
+
})?.seconds ?? this.segmentDurationSec
|
|
1689
|
+
});
|
|
1672
1690
|
this.encodeOrchestrator = new EncodeOrchestrator({
|
|
1673
1691
|
maxRunsFor: (address) => this.maxRunsForOutput(address),
|
|
1674
1692
|
makeRun: ({ address, from, to }) => this.#makeRunAt(address, from, to),
|
|
@@ -3710,7 +3728,7 @@ export class HlsSessionManager {
|
|
|
3710
3728
|
* @returns {{from: number, to: number, priority: number}[]} In segment
|
|
3711
3729
|
* numbers, both ends inclusive.
|
|
3712
3730
|
*/
|
|
3713
|
-
#demandZonesFor(session, atSeconds) {
|
|
3731
|
+
#demandZonesFor(session, atSeconds, playing = true) {
|
|
3714
3732
|
const boundaries = session.timeline?.boundaries ?? [];
|
|
3715
3733
|
const segmentCount = Number(session.timeline?.segmentCount) || 0;
|
|
3716
3734
|
// Where they are, in this output's own numbering. The viewer holds seconds;
|
|
@@ -3731,12 +3749,7 @@ export class HlsSessionManager {
|
|
|
3731
3749
|
segmentSeconds: this.segmentDurationSec,
|
|
3732
3750
|
worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
|
|
3733
3751
|
})?.seconds ?? this.segmentDurationSec,
|
|
3734
|
-
|
|
3735
|
-
// measures everywhere else it prices an encode. `progress.speed` is
|
|
3736
|
-
// ffmpeg's own cumulative figure and includes the run's start, so it
|
|
3737
|
-
// reads low for the first seconds of every run; `recentSpeed` is the
|
|
3738
|
-
// reading taken over a window and is the one the budget already trusts.
|
|
3739
|
-
encodeSpeedX: Number(session.recentSpeed?.speed) || Number(session.progress?.speed) || 0
|
|
3752
|
+
playing
|
|
3740
3753
|
});
|
|
3741
3754
|
/** @type {{from: number, to: number, priority: number}[]} */
|
|
3742
3755
|
const inSegments = [];
|
|
@@ -3943,7 +3956,7 @@ export class HlsSessionManager {
|
|
|
3943
3956
|
// consulted: the 120 seconds that used to size this window were the
|
|
3944
3957
|
// suspended encoder's threshold, one chosen number answering seven
|
|
3945
3958
|
// different questions.
|
|
3946
|
-
for (const zone of this.#demandZonesFor(session, at)) {
|
|
3959
|
+
for (const zone of this.#demandZonesFor(session, at, viewer.playing !== false)) {
|
|
3947
3960
|
this.encodeOrchestrator.want({
|
|
3948
3961
|
// The claimant is the PERSON, without the priority in it: their
|
|
3949
3962
|
// zones are separate windows, but they leave together, and
|
|
@@ -3958,6 +3971,10 @@ export class HlsSessionManager {
|
|
|
3958
3971
|
}
|
|
3959
3972
|
}
|
|
3960
3973
|
}
|
|
3974
|
+
this.priority.publishFor({
|
|
3975
|
+
sessionGroups: byOutput.values(),
|
|
3976
|
+
staleAfterMs: this.presenceStaleAfterMs()
|
|
3977
|
+
});
|
|
3961
3978
|
this.encodeOrchestrator.reconcile();
|
|
3962
3979
|
}
|
|
3963
3980
|
|
|
@@ -27,6 +27,7 @@ import { CoverageMap } from "../encode/CoverageMap.js";
|
|
|
27
27
|
import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
|
|
28
28
|
import { endOfRun } from "../encode/EncodeRun.js";
|
|
29
29
|
import { ENCODE_EXIT } from "../encode/encode-exit.js";
|
|
30
|
+
import { mergeMaps } from "../priority/PriorityMap.js";
|
|
30
31
|
import { affordableRuns } from "../encode/run-budget.js";
|
|
31
32
|
import { RunCosts } from "../encode/run-costs.js";
|
|
32
33
|
import { SegmentDemand } from "../encode/SegmentDemand.js";
|
|
@@ -244,11 +245,28 @@ export class EncodeOrchestrator {
|
|
|
244
245
|
// order: what a viewer must have before they set off comes before what is
|
|
245
246
|
// merely in front of them, which comes before the rest of the track. Passed
|
|
246
247
|
// as a plain number so the plan stays arithmetic.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
248
|
+
// ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
|
|
249
|
+
//
|
|
250
|
+
// Two viewers a few seconds apart state stretches that overlap, and the
|
|
251
|
+
// plan puts one encoder on each stretch it is given — so unmerged windows
|
|
252
|
+
// buy an encoder per viewer for film they both want, which is the opposite
|
|
253
|
+
// of what sharing the output is for. Merged, the highest number per segment
|
|
254
|
+
// wins and the stretches do not overlap, so one encoder serves everyone
|
|
255
|
+
// standing in front of it.
|
|
256
|
+
const windows = mergeMaps([
|
|
257
|
+
this.demand.windowsOn(address).map((window) => ({
|
|
258
|
+
from: window.from,
|
|
259
|
+
// Half-open on the way in and back again: these are whole segment
|
|
260
|
+
// numbers, and #10..#20 next to #21..#30 must not be read as touching
|
|
261
|
+
// at 20 and 21 at once.
|
|
262
|
+
to: window.to + 1,
|
|
263
|
+
// A window stated without a number is still a want — one
|
|
264
|
+
// undifferentiated want, which is what a caller that knows only a
|
|
265
|
+
// position states. Zero would read as "nothing wanted here" and the
|
|
266
|
+
// merge would drop it.
|
|
267
|
+
priority: Number(window.priority) || 1
|
|
268
|
+
}))
|
|
269
|
+
]).map((zone) => ({ from: zone.from, to: zone.to - 1, priority: zone.priority }));
|
|
252
270
|
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
253
271
|
const actions = planEncoders({
|
|
254
272
|
coverage,
|
|
@@ -325,6 +343,10 @@ export class EncodeOrchestrator {
|
|
|
325
343
|
const onThisOutput = this.#runs.get(address) ?? [];
|
|
326
344
|
onThisOutput.push(run);
|
|
327
345
|
this.#runs.set(address, onThisOutput);
|
|
346
|
+
// This run rewrites everything from here on, so what was closed from here
|
|
347
|
+
// on is no longer closed. Without this a number closed by an earlier run
|
|
348
|
+
// stays servable while a later one is halfway through writing it again.
|
|
349
|
+
this.segmentStore?.forgetClosedFrom(address, from);
|
|
328
350
|
this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
|
|
329
351
|
run.start(because);
|
|
330
352
|
}
|