@torrent-tv/proxy 2.90.0 → 2.92.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/bin/cli.js +5 -2
- package/package.json +18 -2
- package/routes/api/playback-plan/post.js +21 -4
- package/routes/api/sources/files/get.js +20 -28
- package/routes/api/sources/post.js +4 -2
- package/routes/api/sources/stats/get.js +4 -11
- package/routes/api/sources/viewer/post.js +16 -0
- package/routes/api/sources/warm/post.js +6 -215
- package/routes/api/subtitles/get.js +22 -125
- package/routes/api/subtitles/post.js +26 -0
- package/routes/api/transcode-sessions/post.js +24 -2
- package/routes/media/get.js +80 -0
- package/routes/stream/get.js +26 -94
- package/routes/transcode/audio-warm/get.js +9 -21
- package/routes/transcode/variant-warm/get.js +8 -14
- package/server.js +410 -46
- package/services/encode/AdmittedInput.js +136 -0
- package/services/encode/CushionReport.js +1 -76
- package/services/encode/EncodeInputs.js +142 -0
- package/services/encode/EncodeOrchestrator.js +4 -1
- package/services/encode/EncodeRun.js +13 -4
- package/services/encode/EncodeRuns.js +132 -52
- package/services/encode/InputFailures.js +37 -0
- package/services/encode/MatroskaInput.js +158 -0
- package/services/encode/OutputOpening.js +16 -64
- package/services/encode/Renditions.js +1 -17
- package/services/encode/admitted-command.js +60 -0
- package/services/encode/annex-b-input.js +37 -0
- package/services/encode/bit-payload.js +29 -0
- package/services/encode/output/OutputCatalog.js +1 -1
- package/services/encode/output/Timeline.js +11 -0
- package/services/encode/pcm-normalization.js +30 -0
- package/services/encode/piece-completeness.js +54 -1
- package/services/encode/quality/HostLoad.js +19 -73
- package/services/encode/quality/QualityOffer.js +1 -1
- package/services/encode/run-command.js +1 -589
- package/services/encode/segment-formats/mp4-boxes.js +3 -1
- package/services/encode/segment-formats/presentation-segment.js +114 -0
- package/services/media/ContainerOrchestrator.js +118 -48
- package/services/media/KeyframeTables.js +16 -115
- package/services/media/MediaReadRequests.js +120 -0
- package/services/media/ProbeRequests.js +135 -0
- package/services/media/SegmentInputs.js +48 -0
- package/services/media/SourceFile.js +6 -0
- package/services/media/SubtitleOrchestrator.js +190 -4
- package/services/media/await-plan.js +47 -0
- package/services/media/container/AsfContainer.js +217 -0
- package/services/media/container/AviContainer.js +343 -34
- package/services/media/container/CadencedPictureOrder.js +45 -0
- package/services/media/container/Container.js +7 -3
- package/services/media/container/ContainerFactory.js +10 -3
- package/services/media/container/FfprobeContainer.js +222 -0
- package/services/media/container/MatroskaContainer.js +72 -46
- package/services/media/container/Mp4Container.js +472 -139
- package/services/media/container/MpegPsContainer.js +281 -0
- package/services/media/container/MpegTsContainer.js +277 -0
- package/services/media/container/PacketIndex.js +270 -0
- package/services/media/container/PacketRecords.js +237 -0
- package/services/media/container/RetainedBytes.js +39 -0
- package/services/media/container/RetainedReads.js +51 -0
- package/services/media/container/ac3-frame.js +46 -0
- package/services/media/container/asf-packets.js +153 -0
- package/services/media/container/avc-picture-order.js +50 -0
- package/services/media/container/avi-open-dml.js +47 -0
- package/services/media/container/dts-frame.js +51 -0
- package/services/media/container/ebml-stream.js +12 -1
- package/services/media/container/ffprobe-record.js +45 -0
- package/services/media/container/flv-packet-ranges.js +27 -0
- package/services/media/container/following-packet-time.js +37 -0
- package/services/media/container/h264-configuration.js +183 -0
- package/services/media/container/h264-elementary-index.js +245 -0
- package/services/media/container/hevc-configuration.js +165 -0
- package/services/media/container/hevc-elementary-index.js +132 -0
- package/services/media/container/hevc-picture-order.js +66 -0
- package/services/media/container/latm-frame.js +99 -0
- package/services/media/container/matroska-packets.js +207 -0
- package/services/media/container/memory-unavailable.js +8 -0
- package/services/media/container/mp3-packet-dependencies.js +41 -0
- package/services/media/container/mp4-fragments.js +148 -0
- package/services/media/container/mpeg-audio-frame.js +19 -0
- package/services/media/container/mpeg-audio-reservoir.js +27 -0
- package/services/media/container/mpeg-elementary-index.js +196 -0
- package/services/media/container/mpeg-sequence-reader.js +69 -0
- package/services/media/container/mpeg-ts-packets.js +93 -0
- package/services/media/container/mpeg-video-configuration.js +50 -0
- package/services/media/container/mpeg4-picture-timing.js +108 -0
- package/services/media/container/pes-header.js +34 -0
- package/services/media/playback-declarations.js +57 -0
- package/services/media/playback-planner.js +72 -230
- package/services/media/probe-packets.js +61 -0
- package/services/media/read-playback-declarations.js +18 -0
- package/services/media/tracks/AudioTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +4 -1
- package/services/server/OutputLifecycle.js +2 -0
- package/services/server/SegmentServing.js +40 -33
- package/services/server/ViewerRequests.js +22 -41
- package/services/server/controllers/PlaybackController.js +4 -0
- package/services/server/controllers/SubtitleController.js +8 -29
- package/services/server/transcode-session-files.js +58 -99
- package/services/server/wire-outputs.js +58 -15
- package/services/storage/IndexMemory.js +85 -0
- package/services/storage/MachineBudget.js +27 -3
- package/services/storage/allowance.js +8 -1
- package/services/storage/piece-store/piece-disk-store.js +18 -3
- package/services/storage/piece-store/piece-lru.js +8 -4
- package/services/storage/piece-store/shared-piece-store.js +26 -11
- package/services/storage/segment-store/SegmentStore.js +49 -18
- package/services/storage/wire.js +7 -0
- package/services/torrent/Contents.js +52 -20
- package/services/torrent/demand/DemandRegister.js +13 -4
- package/services/torrent/demand/Window.js +13 -2
- package/services/torrent/demand-progress.js +25 -0
- package/services/torrent/download/DeadlineScheduler.js +167 -0
- package/services/torrent/download/FutureDownload.js +166 -0
- package/services/torrent/download/SwarmSelection.js +30 -59
- package/services/torrent/download/registry.js +45 -5
- package/services/torrent/download/withdraw-claim.js +2 -0
- package/services/torrent/torrent-pool.js +39 -600
- package/services/torrent/worker/channel.js +8 -1
- package/services/torrent/worker/client.js +74 -72
- package/services/torrent/worker/files-in-use.js +9 -0
- package/services/torrent/worker/handle-state.js +12 -0
- package/services/torrent/worker/held-bytes.js +82 -99
- package/services/torrent/worker/held-file-bytes.js +17 -0
- package/services/torrent/worker/piece-reader.js +67 -683
- package/services/torrent/worker/pool-adapter.js +37 -123
- package/services/torrent/worker/protocol.js +6 -17
- package/services/torrent/worker/whole-file-stats.js +2 -0
- package/services/torrent/worker/worker.js +86 -156
- package/services/transport/data-channel-handler.js +56 -13
- package/services/transport/tunnel-client.js +111 -20
- package/services/viewer/DownloadMaps.js +168 -0
- package/services/viewer/NativeSourceMap.js +33 -0
- package/services/viewer/PriorityMap.js +49 -40
- package/services/viewer/PriorityOrchestrator.js +42 -13
- package/services/viewer/SourcePreparation.js +278 -0
- package/services/viewer/Viewer.js +11 -0
- package/services/viewer/Viewers.js +109 -2
- package/services/viewer/playback-readiness.js +50 -63
- package/services/viewer/segment-demands.js +37 -0
- package/services/viewer/urgent-output-ready.js +26 -0
- package/services/encode/run-inputs.js +0 -53
- package/services/media/keyframe-probe.js +0 -157
- package/services/media/media-info-probe.js +0 -93
- package/services/torrent/worker/background-fill.js +0 -213
- package/services/torrent/worker/resume-warm.js +0 -107
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The tunnel reconnects automatically with a fixed back-off after any
|
|
11
11
|
* unexpected close.
|
|
12
|
+
*
|
|
13
|
+
* A server being replaced by a release asks its proxies to move
|
|
14
|
+
* (`server-moving`). The proxy then opens a second connection, which reaches the
|
|
15
|
+
* new server, and keeps the old one until the old server closes it. While both
|
|
16
|
+
* are open each reply goes over the connection its request arrived on: a
|
|
17
|
+
* browser that signalled through the old server is answered there, because the
|
|
18
|
+
* new one does not know that browser.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
21
|
import { WebSocket } from "ws";
|
|
@@ -27,6 +34,11 @@ import { WebSocket } from "ws";
|
|
|
27
34
|
* Auth token sent as the `x-proxy-id` / `x-proxy-token` headers during the WS handshake.
|
|
28
35
|
* @property {number} proxyPort
|
|
29
36
|
* Local port the proxy's Fastify server is listening on.
|
|
37
|
+
* @property {string} [name]
|
|
38
|
+
* Display name, sent with every connection so that whichever server instance
|
|
39
|
+
* the connection reaches knows it without a separate registration request.
|
|
40
|
+
* @property {string} [baseUrl]
|
|
41
|
+
* Advertised direct URL, sent with every connection for the same reason.
|
|
30
42
|
* @property {(sessionId: string, signal: WebRtcSignal) => void} [onSignal]
|
|
31
43
|
* Called when the server forwards a WebRTC signal (SDP offer or ICE candidate)
|
|
32
44
|
* from a browser to this proxy. `sessionId` scopes the signal to a P2P session.
|
|
@@ -120,6 +132,8 @@ export function createTunnelClient({
|
|
|
120
132
|
proxyId,
|
|
121
133
|
token,
|
|
122
134
|
proxyPort,
|
|
135
|
+
name = "",
|
|
136
|
+
baseUrl = "",
|
|
123
137
|
onSignal,
|
|
124
138
|
onConnect,
|
|
125
139
|
onHealthRequest,
|
|
@@ -141,6 +155,21 @@ export function createTunnelClient({
|
|
|
141
155
|
*/
|
|
142
156
|
let renewalTimer = null;
|
|
143
157
|
let stopped = false;
|
|
158
|
+
/**
|
|
159
|
+
* Every connection that is open now. Usually one; two while a renewal or a
|
|
160
|
+
* move to a new server is under way.
|
|
161
|
+
*
|
|
162
|
+
* @type {Set<WebSocket>}
|
|
163
|
+
*/
|
|
164
|
+
const openConnections = new Set();
|
|
165
|
+
/**
|
|
166
|
+
* The connection each browser session signalled through. The proxy's answer
|
|
167
|
+
* and its ICE candidates go back the same way, because only the server
|
|
168
|
+
* instance that holds that browser's signalling socket can deliver them.
|
|
169
|
+
*
|
|
170
|
+
* @type {Map<string, WebSocket>}
|
|
171
|
+
*/
|
|
172
|
+
const signalRoutes = new Map();
|
|
144
173
|
|
|
145
174
|
/**
|
|
146
175
|
* Write a message to the log sink if one was provided.
|
|
@@ -174,6 +203,14 @@ export function createTunnelClient({
|
|
|
174
203
|
headers: {
|
|
175
204
|
"x-proxy-id": proxyId,
|
|
176
205
|
"x-proxy-token": token,
|
|
206
|
+
// Who this proxy is, on the connection itself: a separate registration
|
|
207
|
+
// request may reach a different server instance during a release.
|
|
208
|
+
"x-proxy-name": encodeURIComponent(name),
|
|
209
|
+
"x-proxy-base-url": baseUrl,
|
|
210
|
+
// This proxy answers on the connection a request arrived on and opens a
|
|
211
|
+
// new connection when the server says `server-moving`, so the server
|
|
212
|
+
// may wait for it to arrive at the new instance.
|
|
213
|
+
"x-proxy-follows-moves": "1",
|
|
177
214
|
"user-agent": "torrent-tv-proxy/1.0"
|
|
178
215
|
}
|
|
179
216
|
});
|
|
@@ -182,6 +219,7 @@ export function createTunnelClient({
|
|
|
182
219
|
socket = connection;
|
|
183
220
|
|
|
184
221
|
connection.addEventListener("open", () => {
|
|
222
|
+
openConnections.add(connection);
|
|
185
223
|
log("Tunnel connected.");
|
|
186
224
|
// Start keepalive pings to prevent Cloudflare's idle WebSocket timeout.
|
|
187
225
|
keepaliveTimer = setInterval(() => {
|
|
@@ -213,8 +251,19 @@ export function createTunnelClient({
|
|
|
213
251
|
return;
|
|
214
252
|
}
|
|
215
253
|
|
|
254
|
+
// The server is being replaced. A new connection reaches its successor;
|
|
255
|
+
// this one stays open until the old server has finished with the
|
|
256
|
+
// browsers it is still signalling for, and then it closes it.
|
|
257
|
+
if (message.type === "server-moving") {
|
|
258
|
+
if (connection === socket && !stopped) {
|
|
259
|
+
log("Tunnel: the server is being replaced; connecting to its successor before this connection closes.");
|
|
260
|
+
connect();
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
216
265
|
if (message.type === "request") {
|
|
217
|
-
void handleRelayRequest(message).catch((error) => {
|
|
266
|
+
void handleRelayRequest(message, connection).catch((error) => {
|
|
218
267
|
log(`Tunnel relay error: ${error?.message ?? error}`);
|
|
219
268
|
});
|
|
220
269
|
return;
|
|
@@ -223,6 +272,7 @@ export function createTunnelClient({
|
|
|
223
272
|
// WebRTC signalling: server forwards a signal from a browser session.
|
|
224
273
|
if (message.type === "signal") {
|
|
225
274
|
if (typeof message.sessionId === "string" && message.signal && typeof onSignal === "function") {
|
|
275
|
+
signalRoutes.set(message.sessionId, connection);
|
|
226
276
|
onSignal(message.sessionId, message.signal);
|
|
227
277
|
}
|
|
228
278
|
return;
|
|
@@ -244,7 +294,7 @@ export function createTunnelClient({
|
|
|
244
294
|
// silent-ok: an unanswerable question is answered "no", which is what
|
|
245
295
|
// a null offer means to the caller.
|
|
246
296
|
}
|
|
247
|
-
send({ type: "can-serve-response", requestId: message.requestId, offer });
|
|
297
|
+
send({ type: "can-serve-response", requestId: message.requestId, offer }, connection);
|
|
248
298
|
return;
|
|
249
299
|
}
|
|
250
300
|
|
|
@@ -266,7 +316,7 @@ export function createTunnelClient({
|
|
|
266
316
|
requestId: message.requestId,
|
|
267
317
|
metrics: answer?.metrics ?? answer ?? {},
|
|
268
318
|
holds: Array.isArray(answer?.holds) ? answer.holds : []
|
|
269
|
-
});
|
|
319
|
+
}, connection);
|
|
270
320
|
})();
|
|
271
321
|
return;
|
|
272
322
|
}
|
|
@@ -277,6 +327,12 @@ export function createTunnelClient({
|
|
|
277
327
|
clearInterval(keepaliveTimer);
|
|
278
328
|
keepaliveTimer = null;
|
|
279
329
|
}
|
|
330
|
+
openConnections.delete(connection);
|
|
331
|
+
for (const [sessionId, route] of signalRoutes) {
|
|
332
|
+
if (route === connection) {
|
|
333
|
+
signalRoutes.delete(sessionId);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
280
336
|
// A connection this one replaced. The server closes it as soon as the
|
|
281
337
|
// replacement registers, which is the whole point of renewing early —
|
|
282
338
|
// there is nothing to report and nothing to reconnect, because the tunnel
|
|
@@ -285,15 +341,24 @@ export function createTunnelClient({
|
|
|
285
341
|
log(`Tunnel handed over (code=${event.code}); the replacement is already carrying it.`);
|
|
286
342
|
return;
|
|
287
343
|
}
|
|
344
|
+
// The newest connection ended while an older one is still open: a move
|
|
345
|
+
// whose new connection did not get through. The older one goes on
|
|
346
|
+
// carrying the tunnel until its server closes it, and the move is tried
|
|
347
|
+
// again meanwhile.
|
|
348
|
+
const survivor = [...openConnections].at(-1);
|
|
349
|
+
if (survivor) {
|
|
350
|
+
socket = survivor;
|
|
351
|
+
log(`Tunnel connection ended (code=${event.code}) while the previous one is still open; it carries the tunnel, and a new connection is tried in ${RECONNECT_DELAY_MS}ms.`);
|
|
352
|
+
scheduleReconnect();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
288
355
|
log(`Tunnel disconnected (code=${event.code}). Reconnecting in ${RECONNECT_DELAY_MS}ms...`);
|
|
289
356
|
socket = null;
|
|
290
357
|
if (renewalTimer !== null) {
|
|
291
358
|
clearTimeout(renewalTimer);
|
|
292
359
|
renewalTimer = null;
|
|
293
360
|
}
|
|
294
|
-
|
|
295
|
-
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
|
|
296
|
-
}
|
|
361
|
+
scheduleReconnect();
|
|
297
362
|
});
|
|
298
363
|
|
|
299
364
|
connection.addEventListener("error", (event) => {
|
|
@@ -301,14 +366,31 @@ export function createTunnelClient({
|
|
|
301
366
|
});
|
|
302
367
|
}
|
|
303
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Connect again after a fixed back-off, unless a reconnect is already due.
|
|
371
|
+
*
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
function scheduleReconnect() {
|
|
375
|
+
if (stopped || reconnectTimer !== null) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
reconnectTimer = setTimeout(() => {
|
|
379
|
+
reconnectTimer = null;
|
|
380
|
+
connect();
|
|
381
|
+
}, RECONNECT_DELAY_MS);
|
|
382
|
+
}
|
|
383
|
+
|
|
304
384
|
/**
|
|
305
385
|
* Fetch a resource from the local Fastify server and stream the response
|
|
306
386
|
* back to the registry server chunk-by-chunk over the WebSocket.
|
|
307
387
|
*
|
|
308
388
|
* @param {TunnelRelayRequest} relayRequest
|
|
389
|
+
* @param {WebSocket} over - The connection the request arrived on, which
|
|
390
|
+
* the response goes back over.
|
|
309
391
|
* @returns {Promise<void>}
|
|
310
392
|
*/
|
|
311
|
-
async function handleRelayRequest(relayRequest) {
|
|
393
|
+
async function handleRelayRequest(relayRequest, over) {
|
|
312
394
|
const { requestId, method, path, query, headers: forwardedHeaders, body } = relayRequest;
|
|
313
395
|
const targetUrl = `http://127.0.0.1:${proxyPort}${path}` + (query ? `?${query}` : "");
|
|
314
396
|
const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
|
|
@@ -322,7 +404,7 @@ export function createTunnelClient({
|
|
|
322
404
|
redirect: "manual"
|
|
323
405
|
});
|
|
324
406
|
} catch (fetchError) {
|
|
325
|
-
sendError(requestId, fetchError?.message ?? String(fetchError));
|
|
407
|
+
sendError(requestId, fetchError?.message ?? String(fetchError), over);
|
|
326
408
|
return;
|
|
327
409
|
}
|
|
328
410
|
|
|
@@ -332,10 +414,10 @@ export function createTunnelClient({
|
|
|
332
414
|
responseHeaders[headerName] = headerValue;
|
|
333
415
|
}
|
|
334
416
|
|
|
335
|
-
send({ type: "response-start", requestId, status: response.status, headers: responseHeaders });
|
|
417
|
+
send({ type: "response-start", requestId, status: response.status, headers: responseHeaders }, over);
|
|
336
418
|
|
|
337
419
|
if (!response.body) {
|
|
338
|
-
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
420
|
+
send({ type: "response-chunk", requestId, data: "", done: true }, over);
|
|
339
421
|
return;
|
|
340
422
|
}
|
|
341
423
|
|
|
@@ -344,7 +426,7 @@ export function createTunnelClient({
|
|
|
344
426
|
while (true) {
|
|
345
427
|
const { done, value } = await reader.read();
|
|
346
428
|
if (done) {
|
|
347
|
-
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
429
|
+
send({ type: "response-chunk", requestId, data: "", done: true }, over);
|
|
348
430
|
break;
|
|
349
431
|
}
|
|
350
432
|
send({
|
|
@@ -352,10 +434,10 @@ export function createTunnelClient({
|
|
|
352
434
|
requestId,
|
|
353
435
|
data: Buffer.from(value).toString("base64"),
|
|
354
436
|
done: false
|
|
355
|
-
});
|
|
437
|
+
}, over);
|
|
356
438
|
}
|
|
357
439
|
} catch {
|
|
358
|
-
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
440
|
+
send({ type: "response-chunk", requestId, data: "", done: true }, over);
|
|
359
441
|
}
|
|
360
442
|
}
|
|
361
443
|
|
|
@@ -363,13 +445,15 @@ export function createTunnelClient({
|
|
|
363
445
|
* Serialise a message to JSON and send it through the WebSocket if open.
|
|
364
446
|
*
|
|
365
447
|
* @param {object} message
|
|
448
|
+
* @param {WebSocket | null} [over]
|
|
366
449
|
* @returns {void}
|
|
367
450
|
*/
|
|
368
451
|
function send(message, over = null) {
|
|
369
|
-
//
|
|
370
|
-
// for
|
|
371
|
-
//
|
|
372
|
-
// that
|
|
452
|
+
// What the proxy says on its own goes over the LIVE connection. `over` is
|
|
453
|
+
// for what belongs to a particular socket rather than to the tunnel: its
|
|
454
|
+
// own keepalive, and every reply, which goes back over the connection its
|
|
455
|
+
// request arrived on — during a move that is the old server, the only one
|
|
456
|
+
// still waiting for it.
|
|
373
457
|
const target = over ?? socket;
|
|
374
458
|
if (target && target.readyState === WebSocket.OPEN) {
|
|
375
459
|
target.send(JSON.stringify(message));
|
|
@@ -381,10 +465,11 @@ export function createTunnelClient({
|
|
|
381
465
|
*
|
|
382
466
|
* @param {string} requestId
|
|
383
467
|
* @param {string} errorMessage
|
|
468
|
+
* @param {WebSocket} over - The connection the request arrived on.
|
|
384
469
|
* @returns {void}
|
|
385
470
|
*/
|
|
386
|
-
function sendError(requestId, errorMessage) {
|
|
387
|
-
send({ type: "response-error", requestId, error: errorMessage });
|
|
471
|
+
function sendError(requestId, errorMessage, over) {
|
|
472
|
+
send({ type: "response-error", requestId, error: errorMessage }, over);
|
|
388
473
|
}
|
|
389
474
|
|
|
390
475
|
return {
|
|
@@ -414,6 +499,9 @@ export function createTunnelClient({
|
|
|
414
499
|
clearTimeout(reconnectTimer);
|
|
415
500
|
reconnectTimer = null;
|
|
416
501
|
}
|
|
502
|
+
for (const connection of openConnections) {
|
|
503
|
+
connection.close(1000, "shutdown");
|
|
504
|
+
}
|
|
417
505
|
if (socket) {
|
|
418
506
|
socket.close(1000, "shutdown");
|
|
419
507
|
socket = null;
|
|
@@ -429,7 +517,10 @@ export function createTunnelClient({
|
|
|
429
517
|
* @returns {void}
|
|
430
518
|
*/
|
|
431
519
|
sendSignal(sessionId, signal) {
|
|
432
|
-
|
|
520
|
+
// Back over the connection this session signalled through, while it is
|
|
521
|
+
// open; the server instance at the other end holds that browser.
|
|
522
|
+
const route = signalRoutes.get(sessionId);
|
|
523
|
+
send({ type: "signal", sessionId, signal }, route?.readyState === WebSocket.OPEN ? route : null);
|
|
433
524
|
},
|
|
434
525
|
|
|
435
526
|
/**
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** One published download map per file, including download-only metadata reads. */
|
|
2
|
+
export class DownloadMaps {
|
|
3
|
+
#files = new Map();
|
|
4
|
+
#publish;
|
|
5
|
+
#resolvePlayback;
|
|
6
|
+
#nextEpoch = 0;
|
|
7
|
+
|
|
8
|
+
constructor({ publish, resolvePlayback = async map => map.zones }) {
|
|
9
|
+
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
10
|
+
this.#publish = publish;
|
|
11
|
+
this.#resolvePlayback = resolvePlayback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
15
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
16
|
+
file.durationSeconds = durationSeconds;
|
|
17
|
+
const revision = ++file.revision;
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
file.sourceZones = zones.map(zone => Number.isFinite(zone.deadlineAt) || !Number.isFinite(zone.withinSeconds)
|
|
20
|
+
? zone : { ...zone, deadlineAt: now + Math.max(0, zone.withinSeconds) * 1000 });
|
|
21
|
+
let changed = false;
|
|
22
|
+
const retained = file.zones.flatMap(zone => {
|
|
23
|
+
const interval = zone.downloadInterval ?? zone;
|
|
24
|
+
const current = file.sourceZones.find(candidate => candidate.from === interval.from && candidate.to === interval.to);
|
|
25
|
+
const deadlineAt = current && Number.isFinite(current.deadlineAt)
|
|
26
|
+
? current.deadlineAt - (zone.leadSeconds ?? 0) * 1000 : current?.deadlineAt;
|
|
27
|
+
if (!current || Object.keys(current).some(key => key !== "from" && key !== "to" && !Object.is(zone[key], key === "deadlineAt" ? deadlineAt : current[key]))) changed = true;
|
|
28
|
+
return current ? [{ ...zone, ...current, from: zone.from, to: zone.to, deadlineAt }] : [];
|
|
29
|
+
});
|
|
30
|
+
file.zones = retained;
|
|
31
|
+
for (const [statement, metadata] of file.metadata) {
|
|
32
|
+
if (metadata.interval && !this.wantsInterval(sourceKey, fileIndex, metadata.interval)) {
|
|
33
|
+
file.metadata.delete(statement);
|
|
34
|
+
changed = true;
|
|
35
|
+
}
|
|
36
|
+
else if (metadata.interval) {
|
|
37
|
+
const current = file.sourceZones.find(zone => zone.from === metadata.interval.from && zone.to === metadata.interval.to);
|
|
38
|
+
const deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - metadata.leadSeconds * 1000 : current.deadlineAt;
|
|
39
|
+
if (metadata.priority !== current.priority || metadata.urgent !== current.urgent || metadata.deadlineAt !== deadlineAt) changed = true;
|
|
40
|
+
metadata.priority = current.priority;
|
|
41
|
+
metadata.urgent = current.urgent;
|
|
42
|
+
metadata.deadlineAt = deadlineAt;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (changed) await this.#emit(file);
|
|
46
|
+
if (revision !== file.revision) return;
|
|
47
|
+
const resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones });
|
|
48
|
+
if (revision !== file.revision) return;
|
|
49
|
+
file.zones = resolved;
|
|
50
|
+
return this.#emit(file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
refresh(sourceKey, fileIndex) {
|
|
54
|
+
const file = this.#files.get(`${sourceKey}:${fileIndex}`);
|
|
55
|
+
if (!file) return Promise.resolve();
|
|
56
|
+
return this.playback({ sourceKey, fileIndex, durationSeconds: file.durationSeconds, zones: file.sourceZones });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
epoch(sourceKey, fileIndex) {
|
|
60
|
+
return this.#files.get(`${sourceKey}:${fileIndex}`)?.epoch ?? 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
native({ sourceKey, fileIndex, zones }) {
|
|
64
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
65
|
+
file.nativeZones = zones;
|
|
66
|
+
return this.#emit(file);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
wantsInterval(sourceKey, fileIndex, { from, to }) {
|
|
70
|
+
return this.#files.get(`${sourceKey}:${fileIndex}`)?.sourceZones.some(zone => zone.from === from && zone.to === to) ?? false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Exact source ranges already resolved by the download map, without new reads. */
|
|
74
|
+
inputsForOutput(sourceKey, outputKey, index) {
|
|
75
|
+
const sources = [];
|
|
76
|
+
for (const file of this.#files.values()) {
|
|
77
|
+
if (file.sourceKey !== sourceKey) continue;
|
|
78
|
+
const zones = file.zones.filter(zone => zone.outputKey === outputKey && zone.index === index);
|
|
79
|
+
if (!zones.length) continue;
|
|
80
|
+
sources.push({ sourceId: `${sourceKey}:${file.fileIndex}`,
|
|
81
|
+
ranges: zones.map(zone => ({ start: zone.byteStart, end: zone.byteEnd + 1 })) });
|
|
82
|
+
}
|
|
83
|
+
return sources.length ? sources : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0 }) {
|
|
87
|
+
if (!Number.isFinite(leadSeconds) || leadSeconds < 0) throw new TypeError("Metadata preparation time must be finite and nonnegative.");
|
|
88
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
89
|
+
if (result.kind === "needs-ranges") {
|
|
90
|
+
if (interval && !this.wantsInterval(sourceKey, fileIndex, interval)) return Promise.resolve();
|
|
91
|
+
const current = interval && file.sourceZones.find(zone => zone.from === interval.from && zone.to === interval.to);
|
|
92
|
+
if (current) {
|
|
93
|
+
priority = current.priority;
|
|
94
|
+
urgent = current.urgent;
|
|
95
|
+
deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - leadSeconds * 1000 : current.deadlineAt;
|
|
96
|
+
}
|
|
97
|
+
file.metadata.set(statement, { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order });
|
|
98
|
+
} else {
|
|
99
|
+
file.metadata.delete(statement);
|
|
100
|
+
}
|
|
101
|
+
return this.#emit(file);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
withdrawMetadata(sourceKey, fileIndex, statement) {
|
|
105
|
+
const file = this.#files.get(`${sourceKey}:${fileIndex}`);
|
|
106
|
+
return file?.metadata.delete(statement) ? this.#emit(file) : Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
repriceMetadata(sourceKey, fileIndex, statement, demand) {
|
|
110
|
+
const file = this.#files.get(`${sourceKey}:${fileIndex}`);
|
|
111
|
+
const metadata = file?.metadata.get(statement);
|
|
112
|
+
if (!metadata || Object.keys(demand).every(key => Object.is(metadata[key], demand[key]))) return Promise.resolve();
|
|
113
|
+
Object.assign(metadata, demand);
|
|
114
|
+
return this.#emit(file);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
forget(sourceKey, fileIndex, { keepPreparation = false } = {}) {
|
|
118
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
119
|
+
file.zones = [];
|
|
120
|
+
file.sourceZones = [];
|
|
121
|
+
if (!keepPreparation) file.nativeZones = [];
|
|
122
|
+
file.revision++;
|
|
123
|
+
file.epoch = ++this.#nextEpoch;
|
|
124
|
+
for (const [statement, metadata] of file.metadata) {
|
|
125
|
+
if (!keepPreparation || metadata.scope !== "preparation") file.metadata.delete(statement);
|
|
126
|
+
}
|
|
127
|
+
// Keep the publication sequence when the same file is requested again.
|
|
128
|
+
return this.#emit(file);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Closing a source discards its maps and cancels queued publications. */
|
|
132
|
+
retire(sourceKey) {
|
|
133
|
+
for (const [key, file] of this.#files) {
|
|
134
|
+
if (file.sourceKey !== sourceKey) continue;
|
|
135
|
+
file.revision++;
|
|
136
|
+
this.#files.delete(key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#file(sourceKey, fileIndex) {
|
|
141
|
+
if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
|
|
142
|
+
throw new TypeError("A download map requires a source file address.");
|
|
143
|
+
}
|
|
144
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
145
|
+
let file = this.#files.get(key);
|
|
146
|
+
if (!file) {
|
|
147
|
+
file = { sourceKey, fileIndex, durationSeconds: 0, zones: [], nativeZones: [], sourceZones: [], revision: 0, epoch: ++this.#nextEpoch, metadata: new Map(), pending: Promise.resolve() };
|
|
148
|
+
this.#files.set(key, file);
|
|
149
|
+
}
|
|
150
|
+
return file;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#emit(file) {
|
|
154
|
+
const zones = [...file.zones, ...file.nativeZones];
|
|
155
|
+
for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
|
|
156
|
+
for (const [byteStart, byteEnd] of ranges) {
|
|
157
|
+
zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const publication = { sourceKey: file.sourceKey, fileIndex: file.fileIndex, durationSeconds: file.durationSeconds, zones };
|
|
161
|
+
const pending = file.pending.then(() => {
|
|
162
|
+
if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) === file) return this.#publish(publication);
|
|
163
|
+
});
|
|
164
|
+
// A failed publication must not prevent the next state change from being sent.
|
|
165
|
+
file.pending = pending.catch(() => undefined);
|
|
166
|
+
return pending;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
|
|
2
|
+
|
|
3
|
+
/** Price native source packets with the same viewer map as encoded outputs. */
|
|
4
|
+
export function nativeSourceMap({ index, tracks, viewers, durationSeconds, startTimeSeconds = 0,
|
|
5
|
+
allowanceSeconds, urgentReadyFor, now = Date.now() }) {
|
|
6
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return [];
|
|
7
|
+
const maps = viewers.map(viewer => mapForViewer({
|
|
8
|
+
atSeconds: viewer.positionSeconds(now) ?? 0, durationSeconds, allowanceSeconds,
|
|
9
|
+
playing: viewer.playing || viewer.waiting, viewerCount: viewers.length,
|
|
10
|
+
pauseSeconds: viewer.pausedAt === null ? 0 : (now - viewer.pausedAt) / 1000,
|
|
11
|
+
urgentReady: urgentReadyFor(viewer)
|
|
12
|
+
}));
|
|
13
|
+
const zones = [];
|
|
14
|
+
for (const zone of runsOf(mergeMaps(maps))) {
|
|
15
|
+
for (const track of tracks.filter(track => ["video", "audio"].includes(track.type))) {
|
|
16
|
+
const bounds = index.boundsOf(track.trackNumber);
|
|
17
|
+
if (!bounds) continue;
|
|
18
|
+
const from = Math.max(bounds.start, zone.from + startTimeSeconds);
|
|
19
|
+
const to = Math.min(bounds.end, durationSeconds + startTimeSeconds, zone.to + startTimeSeconds);
|
|
20
|
+
if (!(to > from)) continue;
|
|
21
|
+
const input = index.inputFor({ trackId: track.trackNumber, from, to });
|
|
22
|
+
if (input.kind === "needs-index") continue;
|
|
23
|
+
if (input.kind !== "result") throw new Error(`Native source input refused: ${input.reason}`);
|
|
24
|
+
const links = viewers.filter((_viewer, viewerIndex) => maps[viewerIndex].priority[zone.from] === zone.priority)
|
|
25
|
+
.map(viewer => viewer.linkReading()?.linkMbps).filter(rate => Number.isFinite(rate) && rate > 0);
|
|
26
|
+
const bytes = input.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
|
|
27
|
+
const deliverySeconds = links.length ? bytes * 8 / (Math.min(...links) * 1_000_000) : 0;
|
|
28
|
+
for (const [byteStart, byteEnd] of input.ranges) zones.push({ ...zone, byteStart, byteEnd,
|
|
29
|
+
deadlineAt: Number.isFinite(zone.withinSeconds) ? now + (zone.withinSeconds - deliverySeconds) * 1000 : Infinity });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return zones;
|
|
33
|
+
}
|
|
@@ -87,31 +87,14 @@
|
|
|
87
87
|
*/
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* it below every value of band 1. A stopped viewer will watch this film when
|
|
97
|
-
* they press play, so it is still in front of them; nobody is waiting for it
|
|
98
|
-
* now, so it yields to anybody who is;
|
|
99
|
-
* 3. BEHIND EVERYBODY — reachable only by a seek back, and nothing measures how
|
|
100
|
-
* likely that is, so it is one value rather than a shape.
|
|
101
|
-
*
|
|
102
|
-
* IN FRONT ALWAYS OUTRANKS BEHIND, at any distance and whoever is stopped. The
|
|
103
|
-
* bands make that true by construction rather than by a comparison somebody has
|
|
104
|
-
* to remember to write.
|
|
105
|
-
*
|
|
106
|
-
* The values inside a band are AN ENCODING OF AN ORDER, not a measurement:
|
|
107
|
-
* every consumer asks only which of two seconds comes first, so any strictly
|
|
108
|
-
* decreasing shape does the same work, and none of these numbers claims
|
|
109
|
-
* anything about the world.
|
|
90
|
+
* Wanted seconds use priorities 1..100. Distance lowers priority relative to
|
|
91
|
+
* the measured urgent allowance. Pause preserves the same priorities until
|
|
92
|
+
* urgent outputs are ready. With competing viewers, elapsed pause time then
|
|
93
|
+
* attenuates the excess above 1 before maps merge. Zero means no demand.
|
|
94
|
+
* Direction, urgent preparation and attenuation are separate map fields;
|
|
95
|
+
* priority alone cannot distinguish an attenuated second from a passed one.
|
|
110
96
|
*/
|
|
111
97
|
export const AT_A_WATCHING_VIEWER = 100;
|
|
112
|
-
const WATCHING_FLOOR = 70;
|
|
113
|
-
const AT_A_STOPPED_VIEWER = 69;
|
|
114
|
-
const STOPPED_FLOOR = 39;
|
|
115
98
|
|
|
116
99
|
/** Behind everybody. */
|
|
117
100
|
export const NOBODY_IS_COMING = 1;
|
|
@@ -159,7 +142,7 @@ export function isAtAWatchingViewer(priority) {
|
|
|
159
142
|
* @returns {boolean}
|
|
160
143
|
*/
|
|
161
144
|
export function isNobodyComingNow(priority) {
|
|
162
|
-
return priority
|
|
145
|
+
return priority === NOBODY_IS_COMING;
|
|
163
146
|
}
|
|
164
147
|
|
|
165
148
|
/**
|
|
@@ -197,7 +180,9 @@ export function emptyMap(durationSeconds) {
|
|
|
197
180
|
durationSeconds: seconds,
|
|
198
181
|
priority: new Uint8Array(seconds),
|
|
199
182
|
secondsUntilPlayed: new Float64Array(seconds).fill(Number.POSITIVE_INFINITY),
|
|
200
|
-
behind: new Uint8Array(seconds).fill(1)
|
|
183
|
+
behind: new Uint8Array(seconds).fill(1),
|
|
184
|
+
urgent: new Uint8Array(seconds),
|
|
185
|
+
deferred: new Uint8Array(seconds)
|
|
201
186
|
};
|
|
202
187
|
}
|
|
203
188
|
|
|
@@ -209,11 +194,9 @@ export function emptyMap(durationSeconds) {
|
|
|
209
194
|
* a clock time — is what the value is derived from, and it is why two viewers
|
|
210
195
|
* can be compared at all: the nearer one wins the second they both want.
|
|
211
196
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* to one flat value over the whole film, as it was, their position disappeared
|
|
216
|
-
* entirely — and with it the rule that what is in front is made first.
|
|
197
|
+
* Pause retains urgent preparation first. Once that work is ready, the
|
|
198
|
+
* allowance / (allowance + pauseSeconds) coefficient gradually reduces this
|
|
199
|
+
* viewer's priorities if others consume the file. Resume restores them.
|
|
217
200
|
*
|
|
218
201
|
* **What must NOT be here**, and the boundary is the point: how fast this
|
|
219
202
|
* machine encodes, how many encoders that takes, what a second weighs in bytes,
|
|
@@ -229,7 +212,15 @@ export function emptyMap(durationSeconds) {
|
|
|
229
212
|
* @param {boolean} [params.playing] - Whether the picture is moving.
|
|
230
213
|
* @returns {PriorityMap}
|
|
231
214
|
*/
|
|
232
|
-
export function
|
|
215
|
+
export function pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds, allowanceSeconds }) {
|
|
216
|
+
if (playing !== false || !urgentReady || viewerCount <= 1) return 1;
|
|
217
|
+
const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 1;
|
|
218
|
+
const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
|
|
219
|
+
return allowance / (allowance + elapsed);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, playing = true,
|
|
223
|
+
pauseSeconds = 0, urgentReady = false, viewerCount = 1 }) {
|
|
233
224
|
const map = emptyMap(durationSeconds);
|
|
234
225
|
if (map.durationSeconds === 0) {
|
|
235
226
|
return map;
|
|
@@ -237,10 +228,13 @@ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, pla
|
|
|
237
228
|
const at = Number.isFinite(atSeconds) && atSeconds > 0 ? Math.floor(atSeconds) : 0;
|
|
238
229
|
const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0
|
|
239
230
|
? allowanceSeconds
|
|
240
|
-
:
|
|
231
|
+
: map.durationSeconds;
|
|
241
232
|
const watching = playing !== false;
|
|
242
|
-
|
|
243
|
-
|
|
233
|
+
// The measured urgent span is the time scale. There is no chosen decay
|
|
234
|
+
// period: one span on pause halves the excess above minimum priority.
|
|
235
|
+
const attenuate = !watching && urgentReady && viewerCount > 1;
|
|
236
|
+
const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
|
|
237
|
+
const coefficient = pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds: elapsed, allowanceSeconds: allowance });
|
|
244
238
|
for (let second = 0; second < map.durationSeconds; second += 1) {
|
|
245
239
|
if (second < at) {
|
|
246
240
|
map.priority[second] = NOBODY_IS_COMING;
|
|
@@ -248,11 +242,13 @@ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, pla
|
|
|
248
242
|
}
|
|
249
243
|
map.behind[second] = 0;
|
|
250
244
|
const distance = second - at;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
245
|
+
const priority = Math.max(NOBODY_IS_COMING, AT_A_WATCHING_VIEWER - stepsAway(distance, allowance));
|
|
246
|
+
map.priority[second] = Math.max(NOBODY_IS_COMING, 1 + Math.floor((priority - 1) * coefficient));
|
|
247
|
+
map.urgent[second] = distance < allowance && !attenuate ? 1 : 0;
|
|
248
|
+
map.deferred[second] = attenuate ? 1 : 0;
|
|
249
|
+
// Urgent preparation keeps its deadline during pause. After completion,
|
|
250
|
+
// pause duration defers this viewer's work without altering other viewers.
|
|
251
|
+
map.secondsUntilPlayed[second] = distance + (attenuate ? elapsed : 0);
|
|
256
252
|
}
|
|
257
253
|
return map;
|
|
258
254
|
}
|
|
@@ -308,6 +304,7 @@ export function mergeMaps(maps) {
|
|
|
308
304
|
return emptyMap(0);
|
|
309
305
|
}
|
|
310
306
|
const merged = emptyMap(Math.max(...all.map((map) => map.durationSeconds)));
|
|
307
|
+
merged.deferred.fill(1);
|
|
311
308
|
for (const map of all) {
|
|
312
309
|
for (let second = 0; second < map.durationSeconds; second += 1) {
|
|
313
310
|
if (map.priority[second] > merged.priority[second]) {
|
|
@@ -321,8 +318,14 @@ export function mergeMaps(maps) {
|
|
|
321
318
|
if (map.behind[second] === 0) {
|
|
322
319
|
merged.behind[second] = 0;
|
|
323
320
|
}
|
|
321
|
+
if (map.urgent?.[second]) merged.urgent[second] = 1;
|
|
322
|
+
// Deferred only if every viewer needing this second has yielded it.
|
|
323
|
+
if (map.behind[second] === 0 && !map.deferred?.[second]) merged.deferred[second] = 0;
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
|
+
for (let second = 0; second < merged.durationSeconds; second += 1) {
|
|
327
|
+
if (merged.behind[second]) merged.deferred[second] = 0;
|
|
328
|
+
}
|
|
326
329
|
return merged;
|
|
327
330
|
}
|
|
328
331
|
|
|
@@ -349,12 +352,16 @@ export function runsOf(map) {
|
|
|
349
352
|
continue;
|
|
350
353
|
}
|
|
351
354
|
const behind = map.behind[second] === 1;
|
|
355
|
+
const urgent = map.urgent?.[second] === 1;
|
|
356
|
+
const deferred = map.deferred?.[second] === 1;
|
|
352
357
|
const previous = runs[runs.length - 1];
|
|
353
358
|
if (
|
|
354
359
|
previous
|
|
355
360
|
&& previous.to === second
|
|
356
361
|
&& previous.priority === priority
|
|
357
362
|
&& previous.behind === behind
|
|
363
|
+
&& previous.urgent === urgent
|
|
364
|
+
&& previous.deferred === deferred
|
|
358
365
|
) {
|
|
359
366
|
previous.to = second + 1;
|
|
360
367
|
continue;
|
|
@@ -373,7 +380,9 @@ export function runsOf(map) {
|
|
|
373
380
|
to: second + 1,
|
|
374
381
|
priority,
|
|
375
382
|
withinSeconds,
|
|
376
|
-
behind
|
|
383
|
+
behind,
|
|
384
|
+
urgent,
|
|
385
|
+
deferred
|
|
377
386
|
});
|
|
378
387
|
}
|
|
379
388
|
return runs;
|