@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.
Files changed (146) hide show
  1. package/bin/cli.js +5 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/transport/tunnel-client.js +111 -20
  132. package/services/viewer/DownloadMaps.js +168 -0
  133. package/services/viewer/NativeSourceMap.js +33 -0
  134. package/services/viewer/PriorityMap.js +49 -40
  135. package/services/viewer/PriorityOrchestrator.js +42 -13
  136. package/services/viewer/SourcePreparation.js +278 -0
  137. package/services/viewer/Viewer.js +11 -0
  138. package/services/viewer/Viewers.js +109 -2
  139. package/services/viewer/playback-readiness.js +50 -63
  140. package/services/viewer/segment-demands.js +37 -0
  141. package/services/viewer/urgent-output-ready.js +26 -0
  142. package/services/encode/run-inputs.js +0 -53
  143. package/services/media/keyframe-probe.js +0 -157
  144. package/services/media/media-info-probe.js +0 -93
  145. package/services/torrent/worker/background-fill.js +0 -213
  146. 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
- if (!stopped) {
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
- // Everything the proxy has to say goes over the LIVE connection. `over` is
370
- // for the one thing that belongs to a particular socket rather than to the
371
- // tunnel — its own keepalive — which must not be sent over a replacement
372
- // that has already taken over.
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
- send({ type: "signal", sessionId, signal });
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
- * THE SCALE. Three bands, and the order between them is the whole of what the
91
- * numbers claim.
92
- *
93
- * 1. IN FRONT OF SOMEBODY WHO IS WATCHING — the top value at the second they
94
- * are about to see, falling as the film gets further from them;
95
- * 2. IN FRONT OF SOMEBODY WHO HAS STOPPED — the same shape, and every value of
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 > NOBODY_IS_COMING && priority <= AT_A_STOPPED_VIEWER;
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
- * **A pause removes the time, not the direction.** A stopped viewer is still
213
- * standing somewhere, and the film in front of them is still the film they will
214
- * watch, so their map keeps its shape and moves into the band below. Collapsed
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 mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, playing = true }) {
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
- : 1;
231
+ : map.durationSeconds;
241
232
  const watching = playing !== false;
242
- const top = watching ? AT_A_WATCHING_VIEWER : AT_A_STOPPED_VIEWER;
243
- const floor = watching ? WATCHING_FLOOR : STOPPED_FLOOR;
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
- map.priority[second] = Math.max(floor, top - stepsAway(distance, allowance));
252
- // A viewer who has stopped is on their way nowhere, so there is no second by
253
- // which any of this must exist. The direction survives the pause, in the
254
- // priority above; the time does not, because there is none to state.
255
- map.secondsUntilPlayed[second] = watching ? distance : Number.POSITIVE_INFINITY;
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;