@torrent-tv/proxy 2.86.0 → 2.87.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/CHANGELOG.md +2142 -1915
  2. package/CLAUDE.md +322 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/browser-proxy-contract.md +94 -0
  7. package/docs/container-architecture.md +218 -213
  8. package/docs/download-architecture.md +7 -7
  9. package/docs/encode-architecture.md +287 -11
  10. package/docs/encode-run-state.md +2 -2
  11. package/docs/logs.md +97 -97
  12. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  13. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  14. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  15. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  18. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  19. package/openspec/changes/cold-start/design.md +1 -1
  20. package/openspec/changes/disk-cap/proposal.md +1 -1
  21. package/openspec/changes/proxy-observability/proposal.md +2 -2
  22. package/openspec/changes/track-selection/proposal.md +1 -1
  23. package/openspec/changes/transcode-quality/proposal.md +1 -1
  24. package/package.json +2 -2
  25. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  26. package/research/double-spawn-2026-09-10.md +171 -171
  27. package/routes/api/playback-plan/post.js +2 -2
  28. package/routes/api/sources/files/get.js +1 -1
  29. package/routes/api/sources/stats/get.js +1 -1
  30. package/routes/api/sources/warm/post.js +10 -3
  31. package/routes/api/subtitles/get.js +230 -230
  32. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  33. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  34. package/routes/api/transcode-sessions/post.js +160 -139
  35. package/routes/api/transcode-sessions/progress/get.js +3 -3
  36. package/routes/api/transcode-sessions/release/post.js +3 -3
  37. package/routes/api/transcode-sessions/seek/post.js +12 -3
  38. package/routes/stream/get.js +2 -2
  39. package/routes/transcode/audio-file/get.js +15 -6
  40. package/routes/transcode/audio-warm/get.js +9 -6
  41. package/routes/transcode/session-file/get.js +52 -268
  42. package/routes/transcode/variant-file/get.js +46 -7
  43. package/routes/transcode/variant-warm/get.js +19 -6
  44. package/scripts/render-run-graph.js +1 -1
  45. package/server.js +720 -465
  46. package/services/encode/CoverageMap.js +428 -428
  47. package/services/encode/CushionReport.js +345 -0
  48. package/services/encode/EncodeAdmission.js +332 -0
  49. package/services/{orchestrators → encode}/EncodeOrchestrator.js +307 -29
  50. package/services/encode/EncodeRun.js +16 -9
  51. package/services/encode/EncodeRuns.js +1229 -0
  52. package/services/encode/Encoder.js +18 -2
  53. package/services/encode/EncoderSelection.js +38 -0
  54. package/services/encode/LocalObservations.js +359 -0
  55. package/services/encode/NvencEncoder.js +5 -0
  56. package/services/encode/OutputOpening.js +755 -0
  57. package/services/encode/OutputTimes.js +579 -0
  58. package/services/encode/Renditions.js +2162 -0
  59. package/services/encode/RunProgress.js +84 -0
  60. package/services/encode/SoftwareEncoder.js +12 -7
  61. package/services/encode/args.js +402 -186
  62. package/services/encode/calibration.js +361 -0
  63. package/services/encode/contention.js +128 -128
  64. package/services/encode/encode-run-state.js +16 -20
  65. package/services/encode/fingerprint.js +322 -0
  66. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  67. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  68. package/services/encode/output/EncodedOutput.js +57 -0
  69. package/services/encode/output/Output.js +131 -0
  70. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  71. package/services/encode/output/OutputSpec.js +390 -0
  72. package/services/{output → encode/output}/Timeline.js +12 -37
  73. package/services/encode/output/index.js +20 -0
  74. package/services/encode/output/ladder.js +32 -0
  75. package/services/{output → encode/output}/playlists.js +3 -0
  76. package/services/encode/output-key-format.js +21 -0
  77. package/services/encode/quality/EncodeCost.js +1083 -0
  78. package/services/encode/quality/HostLoad.js +525 -0
  79. package/services/encode/quality/HostTimings.js +249 -0
  80. package/services/encode/quality/OutputQualityState.js +28 -0
  81. package/services/encode/quality/QualityController.js +935 -0
  82. package/services/encode/quality/QualityOffer.js +416 -0
  83. package/services/encode/quality/drain-threat.js +47 -0
  84. package/services/encode/quality/link-budget.js +254 -0
  85. package/services/encode/quality/output-format.js +266 -0
  86. package/services/encode/quality/serving-output.js +216 -0
  87. package/services/encode/quality/visible-rung.js +57 -0
  88. package/services/encode/run-command.js +23 -20
  89. package/services/encode/run-inputs.js +42 -0
  90. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  91. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  92. package/services/encode/segment-formats/init-compat.js +324 -0
  93. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  94. package/services/encode/throughput.js +97 -0
  95. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  96. package/services/media/KeyframeTables.js +316 -0
  97. package/services/{source → media}/SourceFile.js +7 -55
  98. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  99. package/services/media/SubtitleOrchestrator.js +167 -0
  100. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  101. package/services/{container → media/container}/AviContainer.js +266 -266
  102. package/services/{container → media/container}/Container.js +596 -601
  103. package/services/media/container/ContainerFactory.js +56 -0
  104. package/services/media/container/KeyframeTable.js +272 -0
  105. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  106. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  107. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  108. package/services/media/keyframe-probe.js +157 -0
  109. package/services/media/media-info-probe.js +93 -0
  110. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  111. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  112. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  113. package/services/server/OutputLifecycle.js +418 -0
  114. package/services/server/SegmentServing.js +1146 -0
  115. package/services/server/ViewerRequests.js +522 -0
  116. package/services/server/controllers/PlaybackController.js +36 -0
  117. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  118. package/services/server/http-responses.js +60 -0
  119. package/services/server/transcode-session-files.js +325 -0
  120. package/services/server/wire-outputs.js +983 -0
  121. package/services/storage/Diagnostics.js +202 -0
  122. package/services/storage/MachineBudget.js +235 -0
  123. package/services/{piece-store → storage}/allowance.js +107 -107
  124. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  125. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  126. package/services/{disk → storage}/free.js +33 -0
  127. package/services/storage/machine-memory.js +56 -0
  128. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  129. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  130. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  131. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  132. package/services/storage/wire.js +175 -0
  133. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  134. package/services/{download → torrent/download}/registry.js +2 -2
  135. package/services/torrent/files.js +1 -1
  136. package/services/torrent/piece-store-of.js +29 -0
  137. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  138. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  139. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  140. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  141. package/services/torrent/worker/held-bytes.js +155 -0
  142. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  143. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  144. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  145. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  146. package/services/torrent/worker/resume-warm.js +107 -0
  147. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  148. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  149. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  150. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  151. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  152. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  153. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  154. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  155. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  156. package/services/viewer/Assignments.js +340 -0
  157. package/services/{priority → viewer}/PriorityMap.js +390 -390
  158. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  159. package/services/viewer/Viewer.js +657 -483
  160. package/services/viewer/Viewers.js +368 -257
  161. package/services/viewer/active-output.js +37 -54
  162. package/services/viewer/audio-start.js +4 -3
  163. package/services/viewer/buffer-trend.js +81 -0
  164. package/services/viewer/choices.js +647 -0
  165. package/services/viewer/positions.js +126 -120
  166. package/services/viewer/report-intake.js +46 -45
  167. package/test/asset-paths.test.js +28 -0
  168. package/test/audio-inventory.test.js +3 -3
  169. package/test/audio-track-bitrate.test.js +40 -0
  170. package/test/auto-quality-step.test.js +707 -515
  171. package/test/available-share.test.js +1 -1
  172. package/test/background-fill.test.js +1 -1
  173. package/test/client-log-file.test.js +110 -110
  174. package/test/completed-files.test.js +1 -1
  175. package/test/concurrent-cost.test.js +13 -3
  176. package/test/container-media-info.test.js +3 -3
  177. package/test/core-dumps.test.js +1 -1
  178. package/test/coverage-follows-the-disk.test.js +46 -9
  179. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  180. package/test/cut-times-timeline.test.js +2 -2
  181. package/test/cuts-follow-published-grid.test.js +9 -8
  182. package/test/data-channel-frame.test.js +1 -1
  183. package/test/dead-torrent-handle.test.js +1 -1
  184. package/test/declared-rates.test.js +2 -2
  185. package/test/declared-tracks.test.js +2 -2
  186. package/test/decode-cost-fit.test.js +1 -1
  187. package/test/decode-cost.test.js +34 -25
  188. package/test/delivery-probe.test.js +1 -1
  189. package/test/demand-register.test.js +4 -4
  190. package/test/dht-bootstrap.test.js +1 -1
  191. package/test/drain-threat.test.js +65 -0
  192. package/test/duplicate-add.test.js +1 -1
  193. package/test/encode-admission.test.js +146 -0
  194. package/test/encode-cost.test.js +199 -10
  195. package/test/encode-orchestrator.test.js +39 -1
  196. package/test/encode-plan-viewers.test.js +2 -2
  197. package/test/encode-run.test.js +2 -2
  198. package/test/encode-slope.test.js +1 -1
  199. package/test/encoded-output.test.js +39 -0
  200. package/test/encoder-readings.test.js +1 -1
  201. package/test/every-claimant-is-told-its-share.test.js +152 -0
  202. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  203. package/test/failed-start-breaker.test.js +81 -30
  204. package/test/fastest-wires.test.js +1 -1
  205. package/test/ffmpeg-stream-counts.test.js +1 -1
  206. package/test/file-edges.test.js +3 -3
  207. package/test/fmp4-self-contained.test.js +1 -1
  208. package/test/fmp4-timestamps.test.js +1 -1
  209. package/test/fmp4-track-completeness.test.js +1 -1
  210. package/test/format-keyed-outputs.test.js +78 -0
  211. package/test/health-metrics.test.js +3 -2
  212. package/test/held-request-width.test.js +22 -23
  213. package/test/helpers/encode-run.js +136 -136
  214. package/test/helpers/held-file.js +29 -0
  215. package/test/helpers/manager.js +57 -0
  216. package/test/helpers/output-spec.js +63 -0
  217. package/test/host-load.test.js +1 -1
  218. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  219. package/test/init-compat.test.js +219 -0
  220. package/test/input-lost-quiets-the-plan.test.js +1 -1
  221. package/test/input-unavailable.test.js +1 -1
  222. package/test/keeping-period.test.js +4 -4
  223. package/test/keyframe-index-accuracy.test.js +97 -45
  224. package/test/keyframe-table-budget.test.js +28 -33
  225. package/test/keyframes-belong-to-the-file.test.js +174 -57
  226. package/test/learned-median.test.js +1 -1
  227. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  228. package/test/limit-row-by-area.test.js +60 -0
  229. package/test/link-budget.test.js +111 -0
  230. package/test/link-probe.test.js +56 -56
  231. package/test/local-encoding-data.test.js +273 -0
  232. package/test/look-ahead-coverage.test.js +1 -1
  233. package/test/manager-surface.test.js +294 -0
  234. package/test/matroska-blocks.test.js +0 -0
  235. package/test/matroska-cues-track.test.js +1 -1
  236. package/test/memory-budget.test.js +2 -2
  237. package/test/mp4-composition-times.test.js +0 -0
  238. package/test/mp4-subtitles.test.js +1 -1
  239. package/test/one-authority.test.js +45 -41
  240. package/test/one-budget.test.js +149 -0
  241. package/test/one-output-one-session.test.js +204 -0
  242. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  243. package/test/one-serving-output-per-height.test.js +458 -0
  244. package/test/orchestrator-wired.test.js +46 -45
  245. package/test/output-catalog.test.js +39 -0
  246. package/test/output-shape.test.js +4 -51
  247. package/test/output-spec.test.js +251 -157
  248. package/test/output-speed.test.js +17 -15
  249. package/test/outputs-being-prepared.test.js +40 -0
  250. package/test/packet-witness-ring.test.js +2 -2
  251. package/test/packet-witness.test.js +1 -1
  252. package/test/piece-disk-store.test.js +2 -2
  253. package/test/piece-from-whole-file.test.js +1 -1
  254. package/test/piece-lru.test.js +1 -1
  255. package/test/piece-reader.test.js +2 -2
  256. package/test/piece-store-eviction.test.js +1 -1
  257. package/test/piece-store-never-refuses.test.js +1 -1
  258. package/test/piece-store-reservations.test.js +1 -1
  259. package/test/piece-store-slow-disk.test.js +1 -1
  260. package/test/piece-tail.test.js +1 -1
  261. package/test/plan-host-timings.test.js +1 -1
  262. package/test/plan-is-steady.test.js +225 -225
  263. package/test/priority-map-download.test.js +4 -4
  264. package/test/priority-map-emptied.test.js +204 -205
  265. package/test/priority-map-per-output.test.js +215 -209
  266. package/test/priority-map.test.js +1 -1
  267. package/test/probe-wedge-certainty.test.js +1 -1
  268. package/test/produced-copy-choice.test.js +15 -60
  269. package/test/quality-offer.test.js +162 -0
  270. package/test/quality-variants.test.js +1229 -1167
  271. package/test/rate-control-in-the-output.test.js +180 -0
  272. package/test/read-survives-withdrawal.test.js +2 -2
  273. package/test/read-window.test.js +10 -3
  274. package/test/resume-warm.test.js +1 -1
  275. package/test/run-inputs.test.js +80 -0
  276. package/test/run-position-follows-published-grid.test.js +13 -11
  277. package/test/run-progress.test.js +65 -0
  278. package/test/same-height-switch.test.js +664 -0
  279. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  280. package/test/seek-landing.test.js +13 -13
  281. package/test/seek-target-not-superseded.test.js +28 -29
  282. package/test/segment-closed-on-both-branches.test.js +96 -0
  283. package/test/segment-cut-times.test.js +1 -1
  284. package/test/segment-hold-supersede.test.js +28 -35
  285. package/test/segment-judged-by-its-own-header.test.js +221 -0
  286. package/test/segment-serve-wiring.test.js +130 -39
  287. package/test/segment-store-eviction.test.js +22 -7
  288. package/test/segment-store.test.js +98 -14
  289. package/test/segment-true-start.test.js +1 -1
  290. package/test/segments-are-shared.test.js +17 -16
  291. package/test/send-chunk-bytes.test.js +1 -1
  292. package/test/serving-existing-output.test.js +102 -0
  293. package/test/serving-output.test.js +102 -0
  294. package/test/shared-piece-store.test.js +1 -1
  295. package/test/source-file.test.js +1 -1
  296. package/test/stale-request-after-seek.test.js +17 -21
  297. package/test/startup-readings.test.js +10 -5
  298. package/test/subtitle-cue-framing.test.js +5 -5
  299. package/test/subtitle-cue-source.test.js +36 -38
  300. package/test/subtitle-cue-walk.test.js +11 -10
  301. package/test/subtitle-defaults.test.js +1 -1
  302. package/test/subtitle-language.test.js +4 -4
  303. package/test/subtitle-track-numbering.test.js +5 -4
  304. package/test/supply-margin.test.js +1 -1
  305. package/test/swarm-follows-readers.test.js +3 -3
  306. package/test/swarm-reach.test.js +1 -1
  307. package/test/swarm-selection.test.js +3 -3
  308. package/test/tail-duplication.test.js +1 -1
  309. package/test/the-channel-has-both-ends.test.js +91 -0
  310. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  311. package/test/thread-pool.test.js +3 -3
  312. package/test/timeline.test.js +1 -12
  313. package/test/torrent-cost.test.js +1 -1
  314. package/test/tracks-begin-together.test.js +38 -24
  315. package/test/tunnel-renewal.test.js +1 -1
  316. package/test/two-viewers-one-picture.test.js +120 -87
  317. package/test/upload-hurry.test.js +3 -3
  318. package/test/usrsctp-state.test.js +1 -1
  319. package/test/video-facts.test.js +1 -1
  320. package/test/viewer-assignments.test.js +143 -0
  321. package/test/viewer-outputs.test.js +60 -52
  322. package/test/viewer-presence.test.js +22 -25
  323. package/test/viewer-requests-admission.test.js +89 -0
  324. package/test/viewer-states.test.js +189 -202
  325. package/test/viewer-subtitle-subscription.test.js +1 -1
  326. package/test/viewer.test.js +139 -136
  327. package/test/viewing-generations.test.js +390 -0
  328. package/test/visible-rung.test.js +37 -0
  329. package/test/wait-ledger.test.js +1 -1
  330. package/test/webrtc-shim.test.js +3 -3
  331. package/test/wedge-certainty.test.js +2 -2
  332. package/test/withdraw-piece-claim.test.js +2 -2
  333. package/test/worker-channel.test.js +1 -31
  334. package/utils/client-log-file.js +151 -151
  335. package/scripts/read-container-index.mjs +0 -108
  336. package/services/container/ContainerFactory.js +0 -122
  337. package/services/controllers/PlaybackController.js +0 -33
  338. package/services/disk/DiskSpace.js +0 -150
  339. package/services/disk/wire.js +0 -61
  340. package/services/hls-session-manager.js +0 -9608
  341. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  342. package/services/output/Output.js +0 -94
  343. package/services/output/OutputSpec.js +0 -202
  344. package/services/output/index.js +0 -1
  345. package/services/output/ladder.js +0 -26
  346. package/services/produced-index.js +0 -222
  347. package/services/quality/EncodeCost.js +0 -555
  348. package/services/torrent-worker/container-tracks.js +0 -414
  349. package/services/viewer/link-readings.js +0 -56
  350. package/test/produced-index.test.js +0 -142
  351. /package/{services → bin}/thread-pool.js +0 -0
  352. /package/services/{available-share.js → encode/available-share.js} +0 -0
  353. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  354. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  355. /package/services/{host-load.js → encode/host-load.js} +0 -0
  356. /package/services/{output → encode/output}/cut-grid.js +0 -0
  357. /package/services/{output → encode/output}/rates.js +0 -0
  358. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  359. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  360. /package/services/{container → media/container}/ebml-reader.js +0 -0
  361. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  362. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  365. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  366. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  367. /package/services/{tracks → media/tracks}/index.js +0 -0
  368. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  369. /package/services/{disk → storage}/keep.js +0 -0
  370. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  371. /package/services/{disk → storage}/returns.js +0 -0
  372. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  373. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  374. /package/services/{demand → torrent/demand}/Window.js +0 -0
  375. /package/services/{demand → torrent/demand}/index.js +0 -0
  376. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  377. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  378. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  379. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  380. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  381. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  382. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  383. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  384. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  385. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @file Where a file's keyframes are, found by DECODING it rather than by
3
+ * reading what its container states.
4
+ *
5
+ * The SECOND reader of one fact, and the weaker of the two: the container's own
6
+ * index is two point reads of 16 KB and names every keyframe, while this scans
7
+ * the media over a torrent-backed input and on a large file often does not
8
+ * finish — measured 2026-08-02, 77 keyframes in 45 s against all 570 in 0.8 s
9
+ * from the index. So it is never waited for and never displaces an index that
10
+ * answered; `KeyframeTable.learn` keeps the fuller answer whichever arrives
11
+ * second.
12
+ *
13
+ * It exists because some containers state no index at all, and a keyframe is
14
+ * still needed for something both encode branches need: choosing a SOURCE seek
15
+ * position ffmpeg can land on. `-ss` before `-i` trusts the container's
16
+ * on-the-fly seek, which for some containers (observed: AVI with VBR MP3 audio)
17
+ * can point at a position with no valid frame boundary at all — ffmpeg then
18
+ * fails outright ("Seek failed" / "Header missing"), not merely imprecisely.
19
+ *
20
+ * **Why it is here and not in `container/`.** It runs a process and reads the
21
+ * file over the address this proxy serves it at. A container is built from four
22
+ * functions and takes byte ranges; it spawns nothing and knows no URL. Both are
23
+ * readings of the same file, so both are this layer's — and they are separate
24
+ * files because one of them needs a machine and the other needs sixteen
25
+ * kilobytes.
26
+ */
27
+
28
+ import { spawn } from "node:child_process";
29
+
30
+ /**
31
+ * The ffprobe that sits beside a given ffmpeg.
32
+ *
33
+ * @param {string} ffmpegBin
34
+ * @returns {string}
35
+ */
36
+ export function ffprobeBinFor(ffmpegBin) {
37
+ if (typeof ffmpegBin !== "string" || ffmpegBin.length === 0) {
38
+ return "ffprobe";
39
+ }
40
+ if (/ffmpeg(\.exe)?$/i.test(ffmpegBin)) {
41
+ return ffmpegBin.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
42
+ }
43
+ return "ffprobe";
44
+ }
45
+
46
+ /**
47
+ * Where this file's keyframes are, found by DECODING it rather than by reading
48
+ * what its container states.
49
+ *
50
+ * The second reader of one fact, and the weaker of the two: the container's own
51
+ * index is two point reads of 16 KB and names every keyframe, while this scans
52
+ * the media over a torrent-backed input and on a large file often does not
53
+ * finish — measured 2026-08-02, 77 keyframes in 45 s against all 570 in 0.8 s
54
+ * from the index. So it is never waited for and never displaces an index that
55
+ * answered; `KeyframeTable.learn` keeps the fuller answer whichever arrives
56
+ * second.
57
+ *
58
+ * It exists because some containers state no index at all, and a keyframe is
59
+ * still needed for something both branches need: choosing a SOURCE seek
60
+ * position ffmpeg can land on. `-ss` before `-i` trusts the container's
61
+ * on-the-fly seek, which for some containers (observed: AVI with VBR MP3 audio)
62
+ * can point at a position with no valid frame boundary at all — ffmpeg then
63
+ * fails outright ("Seek failed" / "Header missing"), not merely imprecisely.
64
+ *
65
+ * It lives here, beside the codec probe, because it is the same kind of thing:
66
+ * a reading of the file made by running ffmpeg's own tools over the address
67
+ * this proxy serves it at. The container layer takes byte ranges and spawns
68
+ * nothing.
69
+ *
70
+ * @param {string} ffmpegBin
71
+ * @param {string | URL} inputUrl
72
+ * @param {number} [timeoutMs]
73
+ * @returns {Promise<number[] | null>} Sorted keyframe times, or null.
74
+ */
75
+ export async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000) {
76
+ return new Promise((resolve) => {
77
+ let proc;
78
+ try {
79
+ proc = spawn(
80
+ ffprobeBinFor(ffmpegBin),
81
+ [
82
+ "-v", "error",
83
+ // `-skip_frame nokey` makes the decoder discard non-keyframes, so the
84
+ // probe reads only what it needs. Without it a full packet scan of a
85
+ // ~5 GB MKV cannot finish inside any sane budget over a torrent-backed
86
+ // input, the probe returns nothing, and the playlist falls back to a
87
+ // uniform grid — which on the COPY path is a lie: cuts land on the
88
+ // source's real keyframes, not on a 4 s ruler. The player then finds
89
+ // the declared times do not match the media, stops trusting the
90
+ // playlist and walks the file from segment #1 to locate the seek
91
+ // position by hand (field 2026-08-02: a seek to 1:30 produced requests
92
+ // #1, #2, #45, #86, #123 … #1187, taking minutes and never arriving).
93
+ "-skip_frame", "nokey",
94
+ "-select_streams", "v:0",
95
+ "-show_entries", "packet=pts_time,flags",
96
+ "-of", "csv=p=0",
97
+ String(inputUrl)
98
+ ],
99
+ { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
100
+ );
101
+ } catch {
102
+ resolve(null);
103
+ return;
104
+ }
105
+ let stdout = "";
106
+ let settled = false;
107
+ const finish = (value) => {
108
+ if (settled) {
109
+ return;
110
+ }
111
+ settled = true;
112
+ resolve(value);
113
+ };
114
+ const timer = setTimeout(() => {
115
+ try {
116
+ if (!proc.killed) {
117
+ proc.kill("SIGTERM");
118
+ }
119
+ } catch {
120
+ // ignore
121
+ }
122
+ finish(null);
123
+ }, timeoutMs);
124
+ proc.stdout.on("data", (chunk) => {
125
+ stdout += String(chunk);
126
+ });
127
+ proc.on("error", () => {
128
+ clearTimeout(timer);
129
+ finish(null);
130
+ });
131
+ proc.on("exit", (code) => {
132
+ clearTimeout(timer);
133
+ if (code !== 0) {
134
+ finish(null);
135
+ return;
136
+ }
137
+ const times = [];
138
+ for (const line of stdout.split("\n")) {
139
+ // Each line: "<pts_time>,<flags>" e.g. "12.345000,K__"
140
+ const comma = line.indexOf(",");
141
+ if (comma < 0) {
142
+ continue;
143
+ }
144
+ const flags = line.slice(comma + 1);
145
+ if (!flags.includes("K")) {
146
+ continue;
147
+ }
148
+ const t = Number(line.slice(0, comma));
149
+ if (Number.isFinite(t)) {
150
+ times.push(t);
151
+ }
152
+ }
153
+ times.sort((a, b) => a - b);
154
+ finish(times.length > 0 ? times : null);
155
+ });
156
+ });
157
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @file What a stream's own header says: duration, picture size, frame rate,
3
+ * start time, stream counts and HDR — from one short ffmpeg run that stops as
4
+ * soon as the header has been printed.
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import {
9
+ parseFfmpegBitrateKbps,
10
+ parseFfmpegDurationSeconds,
11
+ parseFfmpegHdr,
12
+ parseFfmpegStartTimeSeconds,
13
+ parseFfmpegStreamCounts,
14
+ parseFfmpegVideoDimensions,
15
+ parseFfmpegVideoFps
16
+ } from "./ffmpeg-banner.js";
17
+
18
+ /**
19
+ * Run a short ffmpeg probe to extract the total duration AND video resolution
20
+ * of a stream from the container header. Both are printed almost immediately
21
+ * (before any decoding), so this returns as soon as they are seen; an 8 s
22
+ * timeout guards the rest.
23
+ *
24
+ * @param {string} ffmpegBin - Path to the ffmpeg executable.
25
+ * @param {string | URL} inputUrl - URL of the stream to probe.
26
+ * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
27
+ */
28
+ export async function probeInputMediaInfo(ffmpegBin, inputUrl) {
29
+ return new Promise((resolve) => {
30
+ const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
31
+ stdio: ["ignore", "ignore", "pipe"],
32
+ windowsHide: true
33
+ });
34
+ let stderr = "";
35
+ let settled = false;
36
+ const finish = () => {
37
+ if (settled) {
38
+ return;
39
+ }
40
+ settled = true;
41
+ const dims = parseFfmpegVideoDimensions(stderr);
42
+ resolve({
43
+ durationSeconds: parseFfmpegDurationSeconds(stderr),
44
+ bitrateKbps: parseFfmpegBitrateKbps(stderr),
45
+ width: dims.width,
46
+ height: dims.height,
47
+ fps: parseFfmpegVideoFps(stderr),
48
+ startTime: parseFfmpegStartTimeSeconds(stderr),
49
+ // Only ever read when a run fails, and read HERE because by then the
50
+ // banner is long gone: this probe is the one place the source says what
51
+ // it holds.
52
+ streamCounts: parseFfmpegStreamCounts(stderr),
53
+ isHdr: parseFfmpegHdr(stderr)
54
+ });
55
+ };
56
+ const timeoutId = setTimeout(() => {
57
+ if (!ffmpeg.killed) {
58
+ ffmpeg.kill("SIGTERM");
59
+ }
60
+ finish();
61
+ }, 8_000);
62
+ ffmpeg.stderr.on("data", (chunk) => {
63
+ stderr += String(chunk);
64
+ // The header ("Duration:" then the "Video: … WxH" stream line) is printed
65
+ // before any decoding. Bail as soon as both are present instead of letting
66
+ // `-f null -` decode the whole stream until the 8 s timeout.
67
+ //
68
+ // This probe asks about the PICTURE and nothing else now: a file with no
69
+ // video track is read by the container layer, which answers from 64 KB of
70
+ // header. It used to take an `expectVideo: false` for exactly that case,
71
+ // and the branch cost 8121 ms of every cold start (2026-09-03) because
72
+ // the exit still waited for a parsed DURATION, which a partly downloaded
73
+ // file prints as `N/A`.
74
+ const duration = parseFfmpegDurationSeconds(stderr);
75
+ const dims = parseFfmpegVideoDimensions(stderr);
76
+ if (duration != null && dims.width != null) {
77
+ clearTimeout(timeoutId);
78
+ if (!ffmpeg.killed) {
79
+ ffmpeg.kill("SIGTERM");
80
+ }
81
+ finish();
82
+ }
83
+ });
84
+ ffmpeg.on("error", () => {
85
+ clearTimeout(timeoutId);
86
+ finish();
87
+ });
88
+ ffmpeg.on("exit", () => {
89
+ clearTimeout(timeoutId);
90
+ finish();
91
+ });
92
+ });
93
+ }
@@ -7,10 +7,9 @@
7
7
  */
8
8
 
9
9
  import { spawn } from "node:child_process";
10
- import { logger } from "../utils/logger.js";
10
+ import { logger } from "../../utils/logger.js";
11
11
  import { Container } from "./container/Container.js";
12
12
  import { buildAudioInventory } from "./audio-inventory.js";
13
- import { contentsOf } from "./torrent/Contents.js";
14
13
  import {
15
14
  parseFfmpegDurationSeconds,
16
15
  parseFfmpegStartTimeSeconds,
@@ -50,6 +49,18 @@ const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "
50
49
  * @param {string} ffmpegOutput
51
50
  * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
52
51
  */
52
+ /**
53
+ * The bitrate a stream line states for that stream, in kbit/s, or null.
54
+ *
55
+ * @param {string} line
56
+ * @returns {number | null}
57
+ */
58
+ function kbpsFromStreamLine(line) {
59
+ const match = line.match(/,\s*(\d+)\s*kb\/s/);
60
+ const kbps = match ? Number(match[1]) : Number.NaN;
61
+ return Number.isFinite(kbps) && kbps > 0 ? kbps : null;
62
+ }
63
+
53
64
  function parseStreams(ffmpegOutput) {
54
65
  // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
55
66
  // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
@@ -68,12 +79,21 @@ function parseStreams(ffmpegOutput) {
68
79
  codec: String(streamMatch[4]).toLowerCase(),
69
80
  language: (streamMatch[2] ?? "").toLowerCase(),
70
81
  title: "",
71
- isDefault: /\(default\)/.test(line)
82
+ isDefault: /\(default\)/.test(line),
83
+ // What the stream line states for THIS stream, e.g. "…, 128 kb/s". A
84
+ // Matroska stream usually states none here and carries its rate as a
85
+ // statistics tag instead (`BPS`, read below). Never the file's total.
86
+ bitrateKbps: kbpsFromStreamLine(line)
72
87
  };
73
88
  streams.push(current);
74
89
  continue;
75
90
  }
76
91
  if (current) {
92
+ const bpsMatch = line.match(/^\s+BPS(?:-[A-Za-z]+)?\s*:\s*(\d+)\s*$/);
93
+ if (bpsMatch && current.bitrateKbps === null) {
94
+ current.bitrateKbps = Math.round(Number(bpsMatch[1]) / 1000);
95
+ continue;
96
+ }
77
97
  const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
78
98
  if (titleMatch && current.title.length === 0) {
79
99
  current.title = titleMatch[1].trim();
@@ -94,7 +114,7 @@ function parseStreams(ffmpegOutput) {
94
114
  * @param {string} ffmpegOutput
95
115
  * @returns {{ audioCodec: string, videoCodec: string }}
96
116
  */
97
- function parseStreamCodecs(ffmpegOutput) {
117
+ export function parseStreamCodecs(ffmpegOutput) {
98
118
  const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
99
119
  const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
100
120
  // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
@@ -127,7 +147,8 @@ function parseStreamCodecs(ffmpegOutput) {
127
147
  codec: s.codec,
128
148
  language: s.language,
129
149
  title: s.title,
130
- isDefault: s.isDefault
150
+ isDefault: s.isDefault,
151
+ bitrateKbps: s.bitrateKbps
131
152
  }));
132
153
  const subtitleTracks = streams
133
154
  .filter((s) => s.type === "subtitle")
@@ -262,8 +283,8 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
262
283
  * @property {string} ffmpegBin
263
284
  * @property {boolean} transcodeAudioEnabled
264
285
  * @property {string} localBaseUrl
265
- * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
266
- * @property {import("./torrent-pool.js").TorrentPool} torrentPool
286
+ * @property {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
287
+ * @property {import("../torrent/torrent-pool.js").TorrentPool} torrentPool
267
288
  */
268
289
 
269
290
  /**
@@ -285,10 +306,22 @@ export function createPlaybackPlanner({
285
306
  // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
286
307
  expectedFirstSegmentMs,
287
308
  expectedSessionCreateMs,
309
+ // What the torrent holds beside this picture — its dubs, its subtitle files,
310
+ // its contact sheets — grouped by whoever knows what a torrent contains. This
311
+ // layer answers about ONE file and must not read a torrent's file list.
312
+ sidecarsFromTorrent = null,
313
+ // What a file DECLARES about its own tracks, read from its header. Handed in
314
+ // rather than asked of the torrent pool: this layer used to call
315
+ // `torrentPool.getDeclaredAudioTracks` and friends, which is the media layer
316
+ // asking the torrent layer to parse a container on its behalf — the parse
317
+ // happened in the torrent thread and came back over the channel. It happens
318
+ // on this thread now, and what is passed here is the ordinary container read.
319
+ declaredTracksOf = null,
288
320
  // Optional. Called once the file's edges are downloaded, so the keyframe
289
321
  // index — which reads the same tail of the file — is fetched alongside the
290
- // codec probe instead of after it. Late-bound to the HLS session manager,
291
- // which owns the cache both of them share.
322
+ // codec probe instead of after it. It goes straight to `KeyframeTables`,
323
+ // which owns that table: it used to be routed through the session manager,
324
+ // which held a second copy of it and a second reader for it.
292
325
  warmKeyframeIndex,
293
326
  // Optional. The heights this host could actually serve this source at, for
294
327
  // both playback branches, so the quality menu is right from the moment the
@@ -339,27 +372,30 @@ export function createPlaybackPlanner({
339
372
  /**
340
373
  * The files beside this picture that belong to it, in three groups.
341
374
  *
342
- * Asked of what the torrent says about itself, which works the grouping out
343
- * once and keeps it. It used to be worked out here on every call, and again
344
- * on the warm-up's own path over the same list, so one opened film paired the
345
- * same files several times and neither side could be sure of the other's
346
- * answer.
375
+ * HANDED IN, because what a torrent contains is the torrent layer's to say
376
+ * and this is the media layer. It works the grouping out once and keeps it;
377
+ * it used to be worked out here on every call, and again on the warm-up's own
378
+ * path over the same list, so one opened film paired the same files several
379
+ * times and neither side could be sure of the other's answer.
380
+ *
381
+ * Absent, a file has no companions — which is what a proxy with no torrent
382
+ * behind it should answer, rather than failing.
347
383
  *
348
384
  * @param {object} torrent
349
385
  * @param {number} fileIndex
350
386
  * @returns {{ audio: object[], subtitles: object[], images: object[] }}
351
387
  */
352
388
  function sidecarsOf(torrent, fileIndex) {
353
- return contentsOf(torrent ?? {}).sidecarsOf(fileIndex);
389
+ return sidecarsFromTorrent?.(torrent ?? {}, fileIndex) ?? { audio: [], subtitles: [], images: [] };
354
390
  }
355
391
 
356
- async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
357
- if (subtitleTracks.length === 0 || typeof torrentPool?.getDeclaredSubtitleTracks !== "function") {
392
+ async function withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks) {
393
+ if (subtitleTracks.length === 0 || typeof declaredTracksOf !== "function") {
358
394
  return subtitleTracks.map((track) => ({ ...track, declaresDefault: false }));
359
395
  }
360
396
  let declared = [];
361
397
  try {
362
- declared = await torrentPool.getDeclaredSubtitleTracks(torrent, fileIndex);
398
+ declared = (await declaredTracksOf({ sourceKey, fileIndex })).filter((track) => track?.type === "subtitle");
363
399
  } catch (error) {
364
400
  logger.info(`subtitle defaults: the container could not be read (${error?.message ?? error})`);
365
401
  }
@@ -385,12 +421,13 @@ export function createPlaybackPlanner({
385
421
  * menu and the number in the `a/<n>/` address are the same number by
386
422
  * construction rather than by agreement.
387
423
  *
424
+ * @param {string} sourceKey
388
425
  * @param {object} torrent
389
426
  * @param {number} fileIndex
390
427
  * @param {object[]} bannerAudioTracks - The probe's own audio streams.
391
428
  * @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
392
429
  */
393
- async function buildInventory(torrent, fileIndex, bannerAudioTracks) {
430
+ async function buildInventory(sourceKey, torrent, fileIndex, bannerAudioTracks) {
394
431
  const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
395
432
  /**
396
433
  * Read a file's declared audio tracks, or give up quickly.
@@ -410,13 +447,15 @@ export function createPlaybackPlanner({
410
447
  * @returns {Promise<object[]>}
411
448
  */
412
449
  const declaredAudioOf = async (wantedFileIndex, label) => {
413
- if (typeof torrentPool?.getDeclaredAudioTracks !== "function") {
450
+ if (typeof declaredTracksOf !== "function") {
414
451
  return [];
415
452
  }
416
453
  let timer = null;
417
454
  try {
418
455
  return await Promise.race([
419
- torrentPool.getDeclaredAudioTracks(torrent, wantedFileIndex),
456
+ declaredTracksOf({ sourceKey, fileIndex: wantedFileIndex }).then((tracks) => tracks
457
+ .filter((track) => track?.type === "audio")
458
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))),
420
459
  new Promise((resolve) => {
421
460
  timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
422
461
  timer.unref?.();
@@ -499,7 +538,12 @@ export function createPlaybackPlanner({
499
538
  offeredHeights: plan.mediaInfoForOffer
500
539
  ? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
501
540
  : null,
502
- mediaInfoForOffer: undefined
541
+ // The description the rest of the pool answers by arithmetic. Carried
542
+ // on every plan, not only on a refusal here: the output opened next can
543
+ // still be refused for want of a place on this machine (roadmap item 97,
544
+ // step 14), and the page then asks the pool the same question before
545
+ // anything plays — which it can only do with this in hand.
546
+ mediaInfoForOffer: plan.mediaInfoForOffer
503
547
  };
504
548
  // Refused rather than served badly. Both lists empty means this machine
505
549
  // cannot sustain this file at ANY height — not even by copying the picture,
@@ -513,13 +557,12 @@ export function createPlaybackPlanner({
513
557
  if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
514
558
  withOffer.cannotServe =
515
559
  "This proxy cannot keep up with this file at any quality right now.";
516
- // The description travels with the refusal, and only with it. It is what
517
- // lets the browser ask the rest of the pool the same question without
518
- // anybody else adding the torrent, fetching a byte or running ffmpeg —
519
- // the expensive half of finding out what this file IS has been paid here,
560
+ // The description travels with the plan (above). It is what lets the
561
+ // browser ask the rest of the pool the same question without anybody
562
+ // else adding the torrent, fetching a byte or running ffmpeg — the
563
+ // expensive half of finding out what this file IS has been paid here,
520
564
  // once. Everyone else answers by arithmetic against their own startup
521
565
  // benchmarks.
522
- withOffer.mediaInfoForOffer = plan.mediaInfoForOffer;
523
566
  }
524
567
  return withOffer;
525
568
  }
@@ -634,14 +677,9 @@ export function createPlaybackPlanner({
634
677
  // The keyframe index reads the tail of the file, which the probe has just
635
678
  // waited for as well. Started here it overlaps the probe instead of
636
679
  // following the whole plan — worth 311-430 ms of the time before the
637
- // first segment. Fire and forget: the session reads it itself if this has
638
- // not finished, and both share one cache entry.
639
- warmKeyframeIndex?.({
640
- sourceKey,
641
- fileIndex,
642
- inputUrl: new URL(directUrl),
643
- logName: file.name
644
- });
680
+ // first segment. Fire and forget: a session that finds the table
681
+ // unanswered joins this very read rather than starting a second one.
682
+ warmKeyframeIndex?.({ sourceKey, fileIndex, logName: file.name });
645
683
  let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
646
684
  const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
647
685
  let attempt = 0;
@@ -669,9 +707,14 @@ export function createPlaybackPlanner({
669
707
  // banner, while the `VideoTrack` the container declares was read and used
670
708
  // for nothing but a line in the log.
671
709
  let declaredVideo = null;
672
- if (typeof torrentPool?.getDeclaredVideoTrack === "function") {
710
+ if (typeof declaredTracksOf === "function") {
673
711
  try {
674
- declaredVideo = await torrentPool.getDeclaredVideoTrack(torrent, fileIndex);
712
+ // One, because ffmpeg's `0:v:0` is what everything downstream is built
713
+ // on and a second video stream is a cover image far more often than a
714
+ // second film.
715
+ declaredVideo = (await declaredTracksOf({ sourceKey, fileIndex }))
716
+ .filter((track) => track?.type === "video")
717
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))[0] ?? null;
675
718
  } catch (error) {
676
719
  logger.info(`video track: could not be read (${error?.message ?? error})`);
677
720
  }
@@ -714,8 +757,8 @@ export function createPlaybackPlanner({
714
757
  // Full track inventory for the browser's audio/subtitle menus. The audio
715
758
  // half spans the picture's own tracks AND the soundtracks shipped as
716
759
  // files beside it, under one numbering — see `buildInventory`.
717
- audioTracks: await buildInventory(torrent, fileIndex, audioTracks ?? []),
718
- subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
760
+ audioTracks: await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []),
761
+ subtitleTracks: await withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks ?? []),
719
762
  // The files BESIDE this picture that belong to it, and what each one's
720
763
  // own path says about the track in it. Both answers are made here, by
721
764
  // one grammar, because the browser used to make them again: it paired