@torrent-tv/proxy 2.91.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 (145) hide show
  1. package/bin/cli.js +3 -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/viewer/DownloadMaps.js +168 -0
  132. package/services/viewer/NativeSourceMap.js +33 -0
  133. package/services/viewer/PriorityMap.js +49 -40
  134. package/services/viewer/PriorityOrchestrator.js +42 -13
  135. package/services/viewer/SourcePreparation.js +278 -0
  136. package/services/viewer/Viewer.js +11 -0
  137. package/services/viewer/Viewers.js +109 -2
  138. package/services/viewer/playback-readiness.js +50 -63
  139. package/services/viewer/segment-demands.js +37 -0
  140. package/services/viewer/urgent-output-ready.js +26 -0
  141. package/services/encode/run-inputs.js +0 -53
  142. package/services/media/keyframe-probe.js +0 -157
  143. package/services/media/media-info-probe.js +0 -93
  144. package/services/torrent/worker/background-fill.js +0 -213
  145. package/services/torrent/worker/resume-warm.js +0 -107
package/server.js CHANGED
@@ -12,6 +12,12 @@ import fastifyHelmet from "@fastify/helmet";
12
12
  import getPort from "get-port";
13
13
  import { createRequire } from "node:module";
14
14
  import { handleHealthGet } from "./routes/health/get.js";
15
+ import { handleMediaGet } from "./routes/media/get.js";
16
+ import { ProbeRequests } from "./services/media/ProbeRequests.js";
17
+ import { IndexMemory } from "./services/storage/IndexMemory.js";
18
+ import { probePackets } from "./services/media/probe-packets.js";
19
+ import { segmentDemands, sourceDeadline } from "./services/viewer/segment-demands.js";
20
+ import { SourcePreparation } from "./services/viewer/SourcePreparation.js";
15
21
  import { handleHealthzGet } from "./routes/healthz/get.js";
16
22
  import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
17
23
  import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
@@ -19,11 +25,13 @@ import { handleApiSourcesPost } from "./routes/api/sources/post.js";
19
25
  import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
20
26
  import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
21
27
  import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
28
+ import { handleApiSourceViewerPost } from "./routes/api/sources/viewer/post.js";
22
29
  import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
23
30
  import { handleApiPlaybackPlanAudioTracksPost } from "./routes/api/playback-plan/audio-tracks/post.js";
24
31
  import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
25
32
  import { createClientLogConsole, createClientLogFiles } from "./utils/client-log-file.js";
26
33
  import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
34
+ import { handleApiSubtitlesPost } from "./routes/api/subtitles/post.js";
27
35
  import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
28
36
  import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
29
37
  import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
@@ -44,7 +52,12 @@ import { KeyframeTables } from "./services/media/KeyframeTables.js";
44
52
  import { contentsOf } from "./services/torrent/Contents.js";
45
53
  import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
46
54
  import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
47
- import { viewerStartsOn } from "./services/viewer/PriorityMap.js";
55
+ import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
56
+ import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
57
+ import { nativeSourceMap } from "./services/viewer/NativeSourceMap.js";
58
+ import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
59
+ import { SegmentInputs } from "./services/media/SegmentInputs.js";
60
+ import { pauseCoefficient, viewerStartsOn } from "./services/viewer/PriorityMap.js";
48
61
  import { coalescing } from "./utils/coalesce.js";
49
62
  import {
50
63
  warmSubtitleCues,
@@ -138,6 +151,17 @@ export async function startProxyServer({
138
151
  const torrentPool = new WorkerTorrentPool({
139
152
  memoryBytes,
140
153
  stateDir,
154
+ onSourceForgotten: ({ sourceKey }) => {
155
+ sourcePreparation?.forget(sourceKey);
156
+ mediaReads.forget(sourceKey);
157
+ probeReads.forget(sourceKey);
158
+ subtitles.forget(sourceKey);
159
+ keyframeTables.forget(sourceKey);
160
+ playbackPlanner.forget(sourceKey);
161
+ indexMemory.forget(sourceKey);
162
+ downloadMaps.retire(sourceKey);
163
+ outputParts.encodeInputs?.bytesChanged();
164
+ },
141
165
  // Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
142
166
  // done about it — walking a file's new subtitle clusters and pushing what
143
167
  // came out — happens here, because that is a reading of what the file says
@@ -148,11 +172,16 @@ export async function startProxyServer({
148
172
  // after the declarations below have run.
149
173
  onPiecesArrived: ({ sourceKey, fileIndexes }) => {
150
174
  for (const fileIndex of fileIndexes) {
175
+ mediaReads.bytesChanged(sourceKey, fileIndex);
176
+ probeReads.bytesChanged(sourceKey, fileIndex);
177
+ void sourcePreparation?.bytesChanged(sourceKey, fileIndex).catch(error =>
178
+ logger.warn(`source input readiness failed: ${error.message}`));
151
179
  void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
152
180
  // A keyframe table somebody asked for while its bytes had not arrived
153
181
  // is read again now; nothing was recorded for that wait.
154
182
  keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
155
183
  }
184
+ outputParts.encodeInputs?.bytesChanged();
156
185
  }
157
186
  });
158
187
  const selectedPort = await getPort({
@@ -275,11 +304,8 @@ export async function startProxyServer({
275
304
  const heldFileFor = async (sourceKey, fileIndex, known = null) => {
276
305
  let torrent = known;
277
306
  if (!torrent) {
278
- const record = sourceRegistry.get(sourceKey);
279
- if (!record) {
280
- return null;
281
- }
282
- torrent = await torrentPool.getTorrent(record.sourceType, record.source);
307
+ torrent = torrentPool.knownTorrent(sourceKey);
308
+ if (!torrent) return null;
283
309
  }
284
310
  const file = torrent?.files?.[fileIndex];
285
311
  if (!file || !(file.length > 0)) {
@@ -329,6 +355,7 @@ export async function startProxyServer({
329
355
  return;
330
356
  }
331
357
  for (const entry of await warmSubtitleCues(file)) {
358
+ if (subtitles.hasPacketCues(sourceKey, fileIndex, entry.trackIndex)) continue;
332
359
  const span = entry.spanStartSeconds === null
333
360
  ? "empty"
334
361
  : `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
@@ -345,6 +372,7 @@ export async function startProxyServer({
345
372
  }
346
373
  });
347
374
  const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
375
+ publish: entry => onSubtitleCues?.(entry),
348
376
  warm: async (torrent, fileIndex, sourceKey) => {
349
377
  const file = await heldFileFor(sourceKey, fileIndex, torrent);
350
378
  return file ? warmSubtitleCues(file) : [];
@@ -374,39 +402,132 @@ export async function startProxyServer({
374
402
  // every answer carried back across the channel — which is what made "where is
375
403
  // this fact kept" a question at all.
376
404
  //
377
- // The edges are fetched first because the file this is asked about is usually
378
- // one nobody has played: a sidecar soundtrack is asked about before anyone
379
- // has chosen it, and a Matroska file's Cues sit at the END behind a SeekHead
380
- // in the head, so a read that has neither waits for the swarm twice over.
381
- const containerOver = async ({ sourceKey, fileIndex, tailBytes = 0 }) => {
405
+ // Missing metadata bytes are demand in the same file map as playback.
406
+ // Readers never start their own downloads or wait for source bytes.
407
+ const downloadMaps = new DownloadMaps({
408
+ publish: (map) => torrentPool.setPriorityMap(map),
409
+ resolvePlayback: async (map) => {
410
+ if (map.zones.length === 0) return [];
411
+ const params = await containerOver(map);
412
+ if (!params) return [];
413
+ const tracks = await containerOrchestrator.inspect(params, "tracks");
414
+ if (tracks.kind !== "result") return [];
415
+ const container = containerOrchestrator.known(map.sourceKey, map.fileIndex);
416
+ if (typeof container?.readPacketIndex !== "function") return [];
417
+ const media = await containerOrchestrator.inspect(params, "media-info");
418
+ if (media.kind !== "result") return [];
419
+ const shift = Number(media.value?.startTimeSeconds) || 0;
420
+ const converted = [];
421
+ const demands = [];
422
+ for (const output of outputParts.outputs.values()) {
423
+ if (output.file.sourceKey !== map.sourceKey) continue;
424
+ const consumers = [...outputParts.viewers.forOutput(output).values()].filter(viewer =>
425
+ viewer.isPresent() && !outputParts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
426
+ if (consumers.length === 0) continue;
427
+ const inventory = outputParts.segmentStore.sizesOf(output.outputKey);
428
+ let bytes = 0, seconds = 0;
429
+ for (const [index, size] of inventory) {
430
+ const span = output.timeline.publishedStartOf(index + 1) - output.timeline.publishedStartOf(index);
431
+ if (span > 0) { bytes += size; seconds += span; }
432
+ }
433
+ const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
434
+ .filter(value => Number.isFinite(value) && value > 0);
435
+ const encodeSpeed = outputParts.encodeCost.latestSpeedReadingOf(output)?.speed;
436
+ for (const demand of segmentDemands(output, map.fileIndex, map.zones)) {
437
+ if (inventory.has(demand.index)) continue;
438
+ const deadlineAt = sourceDeadline(demand, { encodeSpeed,
439
+ outputBytes: seconds > 0 ? bytes / seconds * (demand.to - demand.from) : null,
440
+ linkMbps: links.length ? Math.min(...links) : null });
441
+ const leadSeconds = Number.isFinite(demand.deadlineAt) ? Math.max(0, (demand.deadlineAt - deadlineAt) / 1000) : 0;
442
+ demands.push({ ...demand, deadlineAt, leadSeconds, outputKey: output.outputKey,
443
+ sourceInterval: output.timeline.sourceInterval(demand.index, demand.index, shift) });
444
+ }
445
+ }
446
+ demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
447
+ for (const zone of demands) {
448
+ const selected = zone.tracks.map(choice => ({ choice, track: tracks.value.filter(track => track.type === choice.type)[choice.index] }));
449
+ if (selected.some(({ track }) => !track)) continue;
450
+ const wanted = selected.map(({ track }) => track);
451
+ const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
452
+ const interval = { ...zone.sourceInterval, trackIds: wanted.map(track => track.trackNumber),
453
+ modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
454
+ const intervalParams = await containerOver({ ...map, packetInterval: interval,
455
+ requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
456
+ demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
457
+ if (!intervalParams) continue;
458
+ const packets = await containerOrchestrator.inspect(intervalParams, "packets");
459
+ if (packets.kind !== "result") continue;
460
+ const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
461
+ if (input.kind !== "result") continue;
462
+ const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
463
+ for (const [byteStart, byteEnd] of input.ranges) converted.push({ ...demand,
464
+ downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
465
+ }
466
+ return converted;
467
+ }
468
+ });
469
+ const mediaReads = new MediaReadRequests({
470
+ read: (params, statement) => statement === "subtitle-file" ? subtitles.inspectFile(params)
471
+ : statement === "subtitle-cues" ? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, statement),
472
+ failed: (params, statement, error) => logger.warn(`media source=${params.sourceKey} file=${params.fileIndex} statement=${statement} retry failed: ${error?.message ?? error}`)
473
+ });
474
+ const probeReads = new ProbeRequests({
475
+ publish: async (result) => {
476
+ if (result.statement.startsWith("ffprobe:")) return;
477
+ await downloadMaps.metadata(result);
478
+ if (result.result.kind === "result") await downloadMaps.refresh(result.sourceKey, result.fileIndex);
479
+ },
480
+ failed: (request, error) => logger.warn(`media request=${request.key} probe failed: ${error?.message ?? error}`)
481
+ });
482
+ const indexMemory = new IndexMemory({
483
+ reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
484
+ changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); }
485
+ });
486
+ const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
382
487
  const record = sourceRegistry.get(sourceKey);
383
488
  if (!record) {
384
489
  return null;
385
490
  }
386
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
491
+ const torrent = torrentPool.knownTorrent(sourceKey);
387
492
  const file = torrent?.files?.[fileIndex];
388
493
  if (!file || !(file.length > 0)) {
389
494
  return null;
390
495
  }
391
- try {
392
- await torrentPool.prefetchFileEdges(torrent, fileIndex, {
393
- headBytes: CONTAINER_HEAD_BYTES,
394
- tailBytes,
395
- timeoutMs: 60_000
396
- });
397
- } catch {
398
- // A prefetch that failed is not a reason to skip the read: the read
399
- // fetches what it needs itself, only more slowly.
400
- }
401
- return {
496
+ const params = {
402
497
  sourceKey,
403
498
  fileIndex,
499
+ requestId,
500
+ packetInterval,
501
+ packetMemory: indexMemory.forFile(sourceKey, fileIndex),
502
+ downloadInterval: demand ? { from: demand.from, to: demand.to } : null,
404
503
  readRange: (start, end) =>
405
- torrentPool.readRangeOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
504
+ torrentPool.readHeldOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
406
505
  fileSize: file.length,
506
+ probe: (statement, onRecord) => probeReads.read({ sourceKey, fileIndex, statement: `ffprobe:${statement}`,
507
+ probe: ({ requestId, signal }) => {
508
+ const url = new URL("/media", outputParts.localBaseUrl);
509
+ url.searchParams.set("sourceKey", sourceKey);
510
+ url.searchParams.set("fileIndex", String(fileIndex));
511
+ url.searchParams.set("readId", requestId);
512
+ return probePackets({ url: url.toString(), statement, signal, onRecord });
513
+ } }),
407
514
  label: String(file.name ?? ""),
515
+ onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
516
+ onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
517
+ onReadResult: async (statement, result, revision) => {
518
+ if (revision.demand !== downloadMaps.epoch(sourceKey, fileIndex)) return;
519
+ if (demand && !downloadMaps.wantsInterval(sourceKey, fileIndex, demand)) return;
520
+ const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
521
+ await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
522
+ ...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}) });
523
+ if (finished && result.kind === "result") {
524
+ void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
525
+ logger.warn(`download map source=${sourceKey} file=${fileIndex} metadata refresh failed: ${error?.message ?? error}`));
526
+ }
527
+ },
408
528
  portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
409
529
  };
530
+ return params;
410
531
  };
411
532
  const keyframeTables = new KeyframeTables({
412
533
  // Read by the same container that answers the track table and the media
@@ -429,7 +550,68 @@ export async function startProxyServer({
429
550
  };
430
551
  }
431
552
  });
553
+ let sourcePreparation;
432
554
  const outputParts = wireOutputs({
555
+ indexMemory,
556
+ readMetadataActivity: () => containerOrchestrator.activity(),
557
+ onViewerChanged: () => { void sourcePreparation?.refresh(); },
558
+ sourceInputsFor: (output, index) => downloadMaps.inputsForOutput(output.file.sourceKey, output.outputKey, index),
559
+ readSourceMedia: async (params) => {
560
+ await playbackPlanner.getPlan(params);
561
+ const media = playbackPlanner.getCachedMediaInfo(params);
562
+ if (media) return media;
563
+ const error = new Error("Source media declarations require more available bytes.");
564
+ error.code = "MEDIA_BYTES_UNAVAILABLE";
565
+ error.canRetry = true;
566
+ throw error;
567
+ },
568
+ resolveEncodeInput: async (output, fromIndex, toIndex) => {
569
+ const grid = output.timeline?.published ?? output.timeline?.boundaries;
570
+ const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
571
+ if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
572
+ const selected = new Map();
573
+ for (const type of ["video", "audio"]) {
574
+ const spec = output.spec[type];
575
+ if (!spec) continue;
576
+ const list = selected.get(spec.fileIndex) ?? [];
577
+ list.push({ type, index: type === "video" ? 0 : spec.trackIndex,
578
+ mode: type === "video" ? spec.encode ? "transcode" : "copy" : spec.transcode ? "transcode" : "copy" });
579
+ selected.set(spec.fileIndex, list);
580
+ }
581
+ const sources = [];
582
+ for (const [fileIndex, choices] of selected) {
583
+ const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex });
584
+ if (!params) return { kind: "needs-source" };
585
+ const tracks = await containerOrchestrator.inspect(params, "tracks");
586
+ if (tracks.kind !== "result") return tracks;
587
+ const container = containerOrchestrator.known(params.sourceKey, fileIndex);
588
+ if (typeof container?.readPacketIndex !== "function") return { kind: "needs-index", reason: "packet-index-not-yet-available" };
589
+ const modes = new Map(), wanted = [];
590
+ for (const choice of choices) {
591
+ const track = tracks.value.filter(track => track.type === choice.type)[choice.index];
592
+ if (!track) return { kind: "terminal", reason: "selected-track-is-absent" };
593
+ wanted.push(track); modes.set(track, choice.mode);
594
+ }
595
+ const media = await containerOrchestrator.inspect(params, "media-info");
596
+ if (media.kind !== "result") return media;
597
+ const timeShiftSeconds = Number(media.value?.startTimeSeconds) || 0;
598
+ const sourceInterval = output.timeline.sourceInterval(fromIndex, toIndex, timeShiftSeconds);
599
+ params.packetInterval = { ...sourceInterval,
600
+ trackIds: wanted.map(track => track.trackNumber),
601
+ modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
602
+ const packets = await containerOrchestrator.inspect(params, "packets");
603
+ if (packets.kind !== "result") return packets;
604
+ const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({
605
+ ...sourceInterval, mode: track => modes.get(track) });
606
+ if (input.kind !== "result") return input;
607
+ sources.push({ sourceKey: params.sourceKey, fileIndex, input, timeShiftSeconds });
608
+ }
609
+ return { kind: "result", sources };
610
+ },
611
+ readEncodeRanges: async (source, ranges, maxBytes) => {
612
+ const torrent = torrentPool.knownTorrent(source.sourceKey);
613
+ return torrent ? torrentPool.readHeldRangesOf(torrent, source.fileIndex, ranges, maxBytes) : null;
614
+ },
433
615
  enabled: transcodeAudio,
434
616
  keyframeTables,
435
617
  ffmpegBin,
@@ -492,27 +674,36 @@ export async function startProxyServer({
492
674
  // seconds into bytes, what to ask the swarm for, what to keep in memory —
493
675
  // is its own business.
494
676
  setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
677
+ void sourcePreparation?.refresh();
495
678
  const record = sourceRegistry.get(sourceKey);
496
679
  if (!record) {
497
680
  return;
498
681
  }
499
682
  try {
500
- await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
501
- } catch {
502
- // Best effort: the map is republished on the next change, and the
503
- // downloading goes on serving reads meanwhile.
683
+ if (zones.length === 0) {
684
+ mediaReads.retain(sourceKey, fileIndex, params => params.requestId?.startsWith("prepare:"));
685
+ if (!outputParts.viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
686
+ await downloadMaps.forget(sourceKey, fileIndex, { keepPreparation: true });
687
+ return;
688
+ }
689
+ await downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones });
690
+ mediaReads.retain(sourceKey, fileIndex, params => !params.requestId?.startsWith("download:") ||
691
+ zones.some(zone => zone.from === params.downloadInterval?.from && zone.to === params.downloadInterval?.to));
692
+ } catch (error) {
693
+ logger.warn(`download map source=${sourceKey} file=${fileIndex} publication failed: ${error?.message ?? error}`);
504
694
  }
505
695
  },
506
- getSourceStats: async (sourceKey, fileIndex) => {
696
+ getSourceStats: async (sourceKey, fileIndex, options) => {
507
697
  const record = sourceRegistry.get(sourceKey);
508
698
  if (!record) {
509
699
  return null;
510
700
  }
511
701
  try {
512
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
702
+ const torrent = torrentPool.knownTorrent(sourceKey);
703
+ if (!torrent) return null;
513
704
  // Awaited for the same reason as the stats route: this now crosses a
514
705
  // thread boundary and returns a promise.
515
- return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
706
+ return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null, options);
516
707
  } catch {
517
708
  return null;
518
709
  }
@@ -567,12 +758,173 @@ export async function startProxyServer({
567
758
  // decides anything, keeps the segments whose closure is proven, and removes
568
759
  // the one piece per output that was being written when the process died.
569
760
  outputParts.lifecycle.adoptSegmentsLeftBehind();
761
+ const publishNative = work => {
762
+ if (!work.nativeInput || !sourcePreparation.accepts(work)) return Promise.resolve();
763
+ const viewers = outputParts.viewers.forSource(work.sourceKey)
764
+ .filter(viewer => viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0);
765
+ const zones = nativeSourceMap({ ...work.nativeInput, viewers,
766
+ allowanceSeconds: outputParts.segmentDurationSec,
767
+ urgentReadyFor: viewer => sourcePreparation.urgentReadyFor(viewer) });
768
+ return downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones });
769
+ };
770
+ sourcePreparation = new SourcePreparation({
771
+ viewers: outputParts.viewers,
772
+ candidatesFor: async sourceKey => {
773
+ const record = sourceRegistry.get(sourceKey);
774
+ if (!record) return [];
775
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
776
+ return contentsOf(torrent).items.map(item => item.fileIndex);
777
+ },
778
+ relatedFilesFor: (sourceKey, fileIndex) => {
779
+ const torrent = torrentPool.knownTorrent(sourceKey);
780
+ if (!torrent) return [];
781
+ const contents = contentsOf(torrent);
782
+ const sidecars = contents.sidecarsOf(fileIndex);
783
+ const items = contents.items;
784
+ const current = items.findIndex(item => item.fileIndex === fileIndex);
785
+ const next = current >= 0 ? items[current + 1] : null;
786
+ return [{ fileIndex, role: "source-rest" },
787
+ ...sidecars.audio.map(file => ({ fileIndex: file.fileIndex, role: "audio" })),
788
+ ...sidecars.subtitles.map(file => ({ fileIndex: file.fileIndex, role: "subtitle" })),
789
+ ...(next?.episode ? [{ fileIndex: next.fileIndex, role: "next-episode" }] : [])];
790
+ },
791
+ priorityFor: (viewer, priority, work) => {
792
+ if (!sourcePreparation.subtitleReadyFor(viewer)) return priority;
793
+ const fileIndex = work.ownerFileIndex ?? work.fileIndex;
794
+ const output = [...outputParts.outputs.values()].find(output => output.file?.sourceKey === work.sourceKey &&
795
+ output.file?.fileIndex === fileIndex && outputParts.viewers.forOutput(output).has(viewer.id) &&
796
+ !outputParts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
797
+ const viewerCount = outputParts.viewers.forSource(work.sourceKey)
798
+ .filter(person => person.source.selectedFileIndex === fileIndex).length;
799
+ if (!output) {
800
+ const coefficient = pauseCoefficient({ playing: viewer.playing || viewer.waiting, viewerCount,
801
+ pauseSeconds: viewer.pausedAt === null ? 0 : (Date.now() - viewer.pausedAt) / 1000,
802
+ allowanceSeconds: outputParts.segmentDurationSec,
803
+ urgentReady: sourcePreparation.urgentReadyFor(viewer) });
804
+ return Math.max(1, 1 + Math.floor((priority - 1) * coefficient));
805
+ }
806
+ return outputParts.priority.priorityFor(output, viewer, priority, viewerCount);
807
+ },
808
+ inputReady: async work => {
809
+ const torrent = torrentPool.knownTorrent(work.sourceKey);
810
+ if (!torrent || !work.inputRanges?.length) return false;
811
+ const held = await torrentPool.heldRangesOf(torrent, work.fileIndex);
812
+ return work.inputRanges.every(([start, end]) => held.some(([from, to]) => from <= start && to >= end));
813
+ },
814
+ readyChanged: (work, ready) => logger.info(`source urgent request=${work.requestId} viewer=${work.ownerId} ` +
815
+ `source=${work.sourceKey} file=${work.fileIndex} inputReady=${ready}`),
816
+ inspect: async work => {
817
+ const params = await containerOver(work);
818
+ if (!params) return { kind: "needs-source" };
819
+ params.isCurrent = () => sourcePreparation.accepts(work);
820
+ if (work.statement === "packets" && work.role === "source-rest" && sourcePreparation.accepts(work)) {
821
+ const file = torrentPool.knownTorrent(work.sourceKey)?.files?.[work.fileIndex];
822
+ if (file?.length > 0) {
823
+ const statement = `${work.statement}:${work.requestId}:input`;
824
+ await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, statement,
825
+ result: { kind: "needs-ranges", ranges: [[0, file.length - 1]], requestId: statement },
826
+ scope: "preparation", ...sourcePreparation.demandFor(work) });
827
+ }
828
+ }
829
+ if (["packets", "subtitle-cues"].includes(work.statement) && (work.selected || work.role === "subtitle-embedded")) {
830
+ const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
831
+ if (info.kind !== "result") return info;
832
+ const positions = outputParts.viewers.forSource(work.sourceKey)
833
+ .filter(viewer => viewer.source.selectedFileIndex === work.fileIndex &&
834
+ (work.ownerId === undefined || viewer.id === work.ownerId))
835
+ .map(viewer => viewer.positionSeconds() ?? 0);
836
+ const from = (positions.length ? Math.min(...positions) : 0) + (Number(info.value?.startTimeSeconds) || 0);
837
+ params.packetInterval = { from, to: from + outputParts.segmentDurationSec };
838
+ if (work.role === "subtitle-embedded") {
839
+ const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
840
+ const track = tracks.find(track => track.type === "subtitle" && track.declaredIndex === work.trackIndex);
841
+ if (!track || track.isTextBased?.() !== true) return { kind: "terminal", reason: "subtitle-track-not-supported" };
842
+ params.packetInterval.trackIds = [track.trackNumber];
843
+ params.subtitleTrackIndex = work.trackIndex;
844
+ }
845
+ }
846
+ params.onReadResult = async (statement, result, revision) => {
847
+ if (!sourcePreparation.accepts(work)) return;
848
+ mediaReads.record(params, statement, result, revision.storage, revision.memory);
849
+ const inputStatement = `${work.statement}:${work.requestId}:input`;
850
+ if (statement === "packets" && work.role === "source-rest" && result.kind === "result") {
851
+ const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
852
+ const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
853
+ if (!sourcePreparation.accepts(work)) return;
854
+ if (info.kind === "result") {
855
+ work.nativeInput = { index: result.value, tracks, durationSeconds: info.value.durationSeconds,
856
+ startTimeSeconds: info.value.startTimeSeconds ?? 0 };
857
+ await publishNative(work);
858
+ }
859
+ }
860
+ if (statement === "packets" && work.role !== "source-rest" && result.kind === "result" && (work.selected || work.ownerFileIndex !== undefined)) {
861
+ const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
862
+ if (!sourcePreparation.accepts(work)) return;
863
+ const bounds = tracks.filter(track => ["video", "audio"].includes(track.type))
864
+ .map(track => result.value.boundsOf(track.trackNumber)).filter(Boolean);
865
+ const interval = params.packetInterval ?? (bounds.length ? {
866
+ from: Math.max(0, Math.min(...bounds.map(bound => bound.start))), to: Math.max(...bounds.map(bound => bound.end))
867
+ } : null);
868
+ if (interval && interval.to > interval.from) {
869
+ const input = work.role === "subtitle-embedded"
870
+ ? result.value.inputFor({ trackId: params.packetInterval.trackIds[0], ...interval })
871
+ : new SegmentInputs({ index: result.value, tracks }).forInterval(interval);
872
+ if (work.selected) work.inputRanges = input.kind === "result" ? input.ranges : null;
873
+ await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
874
+ statement: inputStatement, result: input.kind === "result" ? { kind: "needs-ranges", ranges: input.ranges, requestId: inputStatement } : input,
875
+ scope: "preparation", ...sourcePreparation.demandFor(work) });
876
+ if (work.selected) await sourcePreparation.bytesChanged(work.sourceKey, work.fileIndex);
877
+ }
878
+ }
879
+ if (!sourcePreparation.accepts(work)) return;
880
+ await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
881
+ statement: `${work.statement}:${work.requestId}`, result, scope: "preparation", ...sourcePreparation.demandFor(work) });
882
+ sourcePreparation.result(work, result);
883
+ if (["subtitle-file", "subtitle-cues"].includes(statement)) outputParts.encodeRuns.planEncodersSoon();
884
+ };
885
+ return work.statement === "subtitle-file"
886
+ ? subtitles.inspectFile(params) : work.statement === "subtitle-cues"
887
+ ? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, work.statement);
888
+ },
889
+ withdraw: work => {
890
+ if (work.role === "source-rest" && work.statement === "packets") {
891
+ void downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones: [] })
892
+ .catch(error => logger.warn(`native source map withdrawal failed: ${error.message}`));
893
+ }
894
+ mediaReads.retain(work.sourceKey, work.fileIndex, params => params.requestId !== work.requestId);
895
+ void downloadMaps.withdrawMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}`)
896
+ .catch(error => logger.warn(`source preparation request=${work.requestId} withdrawal failed: ${error.message}`));
897
+ void downloadMaps.withdrawMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}:input`)
898
+ .catch(error => logger.warn(`source preparation request=${work.requestId} input withdrawal failed: ${error.message}`));
899
+ if (!sourcePreparation.ownsFile(work.sourceKey, work.fileIndex)) {
900
+ probeReads.withdraw(work.sourceKey, work.fileIndex);
901
+ void downloadMaps.withdrawMetadata(work.sourceKey, work.fileIndex, "codec-probe")
902
+ .catch(error => logger.warn(`source preparation request=${work.requestId} probe withdrawal failed: ${error.message}`));
903
+ }
904
+ },
905
+ reprice: (work, demand) => {
906
+ if (work.nativeInput) void publishNative(work)
907
+ .catch(error => logger.warn(`native source map repricing failed: ${error.message}`));
908
+ void downloadMaps.repriceMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}`, demand)
909
+ .catch(error => logger.warn(`source preparation request=${work.requestId} repricing failed: ${error.message}`));
910
+ void downloadMaps.repriceMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}:input`, demand)
911
+ .catch(error => logger.warn(`source preparation request=${work.requestId} input repricing failed: ${error.message}`));
912
+ },
913
+ failed: error => logger.warn(`source preparation failed: ${error?.message ?? error}`)
914
+ });
915
+ outputParts.subtitleReadyFor = viewer => sourcePreparation.subtitleReadyFor(viewer);
570
916
  const playbackPlanner = createPlaybackPlanner({
571
917
  ffmpegBin,
572
918
  transcodeAudioEnabled: transcodeAudio,
573
919
  localBaseUrl: outputParts.localBaseUrl,
574
920
  sourceRegistry,
575
921
  torrentPool,
922
+ subscribeSource: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener),
923
+ readDeclarations: async ({ sourceKey, fileIndex }) => {
924
+ const params = await containerOver({ sourceKey, fileIndex });
925
+ if (!params) return { kind: "terminal", reason: "source-forgotten" };
926
+ return readPlaybackDeclarations(containerOrchestrator, params);
927
+ },
576
928
  sidecarsFromTorrent: (torrent, fileIndex) => contentsOf(torrent).sidecarsOf(fileIndex),
577
929
  // What a file declares about its own tracks, parsed on this thread from the
578
930
  // header the swarm delivered. The planner used to ask the torrent pool for
@@ -593,6 +945,13 @@ export async function startProxyServer({
593
945
  });
594
946
 
595
947
  app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
948
+ app.get("/media", async (req, reply) => handleMediaGet(req, reply, {
949
+ torrentPool,
950
+ onMissing: ({ sourceKey, fileIndex, start, end, requestId }) => {
951
+ logger.info(`media request=${requestId} ${sourceKey}:${fileIndex} needs bytes ${start}-${end}`);
952
+ probeReads.needs(requestId, start, end);
953
+ }
954
+ }));
596
955
  app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
597
956
  // Off unless --delivery-sink was given; see the route for why it exists.
598
957
  app.get("/api/delivery-sink", async (req, reply) =>
@@ -601,28 +960,23 @@ export async function startProxyServer({
601
960
  // Bytes a browser can time its link with before any film has been chosen.
602
961
  app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
603
962
  app.post("/api/sources", async (req, reply) =>
604
- handleApiSourcesPost(req, reply, { sourceRegistry })
963
+ handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers })
605
964
  );
606
965
  app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
607
966
  handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
608
967
  );
609
968
  app.get("/api/sources/:sourceKey/files", async (req, reply) =>
610
- handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
969
+ handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers: outputParts.viewers })
611
970
  );
612
971
  app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
613
972
  handleApiSourceWarmPost(req, reply, {
614
973
  sourceRegistry,
615
- torrentPool,
616
- playbackPlanner,
617
- // How long the file runs, read on this thread from the header. The warm
618
- // turns a position in seconds into a byte offset and needs it; it used to
619
- // read the container itself, in the torrent thread, to find out.
620
- durationOf: async (params) => {
621
- const over = await containerOver(params);
622
- return over ? (await containerOrchestrator.getMediaInfo(over))?.durationSeconds ?? null : null;
623
- }
974
+ viewers: outputParts.viewers
624
975
  })
625
976
  );
977
+ app.post("/api/sources/:sourceKey/files/:fileIndex/viewer", async (req, reply) =>
978
+ handleApiSourceViewerPost(req, reply, { sourceRegistry, viewers: outputParts.viewers })
979
+ );
626
980
  // The browser's own log, kept beside the proxy's: a file per session when
627
981
  // the proxy writes its log to a file, its console otherwise. See the route's
628
982
  // own file for why it is here and not only on the registry server.
@@ -633,17 +987,25 @@ export async function startProxyServer({
633
987
  handleApiClientLogsPost(req, reply, { clientLogs })
634
988
  );
635
989
  app.post("/api/playback-plan", async (req, reply) =>
636
- handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
990
+ handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers })
637
991
  );
638
992
  app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
639
993
  handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
640
994
  );
995
+ app.post("/api/subtitles", async (req, reply) => handleApiSubtitlesPost(req, reply, {
996
+ viewers: outputParts.viewers, sourceRegistry,
997
+ subtitleTracksFor: (sourceKey, fileIndex) =>
998
+ (containerOrchestrator.tracks.get(`${sourceKey}:${fileIndex}`) ?? []).filter(track => track.type === "subtitle"),
999
+ subtitleFilesFor: (sourceKey, fileIndex) => {
1000
+ const torrent = torrentPool.knownTorrent(sourceKey);
1001
+ return torrent && Number.isSafeInteger(fileIndex)
1002
+ ? contentsOf(torrent).sidecarsOf(fileIndex).subtitles.map(file => file.fileIndex) : [];
1003
+ }
1004
+ }));
641
1005
  app.get("/api/subtitles", async (req, reply) =>
642
1006
  handleApiSubtitlesGet(req, reply, {
643
1007
  sourceRegistry,
644
1008
  torrentPool,
645
- ffmpegBin,
646
- localBaseUrl: outputParts.localBaseUrl,
647
1009
  viewers: outputParts.viewers,
648
1010
  subtitles
649
1011
  })
@@ -662,7 +1024,8 @@ export async function startProxyServer({
662
1024
  })
663
1025
  );
664
1026
  app.post("/api/transcode-sessions", async (req, reply) =>
665
- handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool })
1027
+ handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool,
1028
+ subscribeSource: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener) })
666
1029
  );
667
1030
  app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
668
1031
  handleApiTranscodeSessionReleasePost(req, reply, { lifecycle: outputParts.lifecycle })
@@ -724,6 +1087,7 @@ export async function startProxyServer({
724
1087
  // file: could THIS host sustain it? Answered from the startup benchmarks
725
1088
  // and a description, so it needs no torrent and costs milliseconds.
726
1089
  outputParts,
1090
+ mediaMemory: { packetIndexBytes: () => indexMemory.packetBytes() },
727
1091
  // The browser only ever knows a source by its REGISTRY key (a hash of the
728
1092
  // raw request bytes, scoped to one API session) — never the torrent
729
1093
  // pool's own key (the content's infohash, shared across a magnet and a