@torrent-tv/proxy 2.88.4 → 2.88.6

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 (489) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2269 -2258
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -0
  19. package/assets/calibration/audio/aac.m4a +0 -0
  20. package/assets/calibration/audio/ac3.ac3 +0 -0
  21. package/assets/calibration/audio/alac.m4a +0 -0
  22. package/assets/calibration/audio/dts.dts +0 -0
  23. package/assets/calibration/audio/eac3.eac3 +0 -0
  24. package/assets/calibration/audio/flac.flac +0 -0
  25. package/assets/calibration/audio/manifest.json +98 -0
  26. package/assets/calibration/audio/mp2.mp2 +0 -0
  27. package/assets/calibration/audio/mp3.mp3 +0 -0
  28. package/assets/calibration/audio/opus.opus +0 -0
  29. package/assets/calibration/audio/pcm.wav +0 -0
  30. package/assets/calibration/audio/truehd.thd +0 -0
  31. package/assets/calibration/audio/vorbis.ogg +0 -0
  32. package/assets/diagnostics/sctpstate.gdb +31 -31
  33. package/bin/cli.js +684 -684
  34. package/bin/thread-pool.js +30 -30
  35. package/biome.json +412 -411
  36. package/docs/browser-proxy-contract.md +155 -152
  37. package/docs/container-architecture.md +218 -218
  38. package/docs/download-architecture.md +261 -261
  39. package/docs/encode-architecture.md +729 -729
  40. package/docs/encode-run-state.md +96 -96
  41. package/docs/logs.md +97 -97
  42. package/docs/playback-start-forecast.md +56 -0
  43. package/docs/socks5-egress-research.md +114 -114
  44. package/docs/storage-architecture.md +267 -267
  45. package/knip.json +14 -14
  46. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  47. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  48. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  49. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  50. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  51. package/openspec/changes/binary-distribution/proposal.md +53 -53
  52. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  53. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  54. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  55. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  56. package/openspec/changes/cold-start/design.md +98 -98
  57. package/openspec/changes/cold-start/proposal.md +62 -62
  58. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  59. package/openspec/changes/cold-start/tasks.md +48 -48
  60. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  61. package/openspec/changes/disk-cap/proposal.md +37 -37
  62. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  63. package/openspec/changes/disk-cap/tasks.md +19 -19
  64. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  65. package/openspec/changes/proxy-observability/design.md +38 -38
  66. package/openspec/changes/proxy-observability/proposal.md +49 -49
  67. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  68. package/openspec/changes/proxy-observability/tasks.md +21 -21
  69. package/openspec/changes/seek-debounce/design.md +118 -118
  70. package/openspec/changes/seek-debounce/proposal.md +59 -59
  71. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  72. package/openspec/changes/seek-debounce/tasks.md +33 -33
  73. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  74. package/openspec/changes/subtitle-language/proposal.md +50 -50
  75. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  76. package/openspec/changes/subtitle-language/tasks.md +26 -26
  77. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  78. package/openspec/changes/track-selection/design.md +47 -47
  79. package/openspec/changes/track-selection/proposal.md +45 -45
  80. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  81. package/openspec/changes/track-selection/tasks.md +20 -20
  82. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  83. package/openspec/changes/transcode-quality/proposal.md +51 -51
  84. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  85. package/openspec/changes/transcode-quality/tasks.md +63 -63
  86. package/openspec/config.yaml +38 -38
  87. package/package.json +1 -1
  88. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  89. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  90. package/research/double-spawn-2026-09-10.md +171 -171
  91. package/research/handover-2026-09-13.md +209 -209
  92. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  93. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  94. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  95. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  96. package/routes/api/client-logs/post.js +86 -86
  97. package/routes/api/delivery-sink/get.js +72 -72
  98. package/routes/api/link-probe/get.js +51 -51
  99. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  100. package/routes/api/playback-plan/post.js +53 -53
  101. package/routes/api/sources/post.js +36 -36
  102. package/routes/api/sources/stats/get.js +132 -132
  103. package/routes/api/sources/warm/post.js +217 -217
  104. package/routes/api/subtitles/get.js +230 -230
  105. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  106. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  107. package/routes/api/transcode-sessions/post.js +178 -178
  108. package/routes/api/transcode-sessions/progress/get.js +27 -27
  109. package/routes/api/transcode-sessions/release/post.js +41 -41
  110. package/routes/api/transcode-sessions/seek/post.js +63 -63
  111. package/routes/health/get.js +13 -13
  112. package/routes/healthz/get.js +13 -13
  113. package/routes/stream/get.js +380 -380
  114. package/routes/transcode/audio-file/get.js +64 -64
  115. package/routes/transcode/audio-warm/get.js +95 -95
  116. package/routes/transcode/session-file/get.js +52 -52
  117. package/routes/transcode/variant-file/get.js +95 -95
  118. package/routes/transcode/variant-warm/get.js +84 -84
  119. package/scripts/build-audio-samples.mjs +31 -0
  120. package/scripts/render-run-graph.js +173 -173
  121. package/server.js +730 -725
  122. package/services/encode/CoverageMap.js +428 -428
  123. package/services/encode/CushionReport.js +345 -345
  124. package/services/encode/EncodeAdmission.js +376 -376
  125. package/services/encode/EncodeOrchestrator.js +1217 -1217
  126. package/services/encode/EncodePlan.js +1366 -1366
  127. package/services/encode/EncodeRun.js +813 -813
  128. package/services/encode/EncodeRuns.js +1239 -1235
  129. package/services/encode/Encoder.js +142 -142
  130. package/services/encode/EncoderSelection.js +38 -38
  131. package/services/encode/LocalObservations.js +359 -359
  132. package/services/encode/NvencEncoder.js +50 -50
  133. package/services/encode/OutputOpening.js +783 -783
  134. package/services/encode/OutputTimes.js +579 -579
  135. package/services/encode/QsvEncoder.js +58 -58
  136. package/services/encode/Renditions.js +2282 -2282
  137. package/services/encode/RunProgress.js +84 -84
  138. package/services/encode/SegmentDemand.js +95 -95
  139. package/services/encode/SoftwareEncoder.js +121 -121
  140. package/services/encode/V4l2m2mEncoder.js +53 -53
  141. package/services/encode/VaapiEncoder.js +66 -66
  142. package/services/encode/args.js +418 -418
  143. package/services/encode/audio-calibration.js +67 -0
  144. package/services/encode/audio-work-rate.js +15 -0
  145. package/services/encode/available-share.js +89 -89
  146. package/services/encode/calibration.js +361 -361
  147. package/services/encode/contention.js +128 -128
  148. package/services/encode/decode-cost-fit.js +363 -363
  149. package/services/encode/encode-exit.js +114 -114
  150. package/services/encode/encode-run-state.js +551 -551
  151. package/services/encode/encoder-readings.js +45 -45
  152. package/services/encode/fingerprint.js +322 -322
  153. package/services/encode/host-load.js +174 -174
  154. package/services/encode/hwaccel.js +1894 -1894
  155. package/services/encode/index.js +9 -9
  156. package/services/encode/learned-median.js +90 -90
  157. package/services/encode/output/EncodedOutput.js +57 -57
  158. package/services/encode/output/Output.js +131 -131
  159. package/services/encode/output/OutputCatalog.js +414 -414
  160. package/services/encode/output/OutputSpec.js +390 -390
  161. package/services/encode/output/Timeline.js +308 -308
  162. package/services/encode/output/cut-grid.js +107 -107
  163. package/services/encode/output/index.js +24 -20
  164. package/services/encode/output/ladder.js +32 -32
  165. package/services/encode/output/playlists.js +284 -284
  166. package/services/encode/output/rates.js +125 -125
  167. package/services/encode/output-key-format.js +21 -21
  168. package/services/encode/quality/EncodeCost.js +1167 -1150
  169. package/services/encode/quality/HostLoad.js +525 -525
  170. package/services/encode/quality/HostTimings.js +249 -249
  171. package/services/encode/quality/OutputQualityState.js +28 -28
  172. package/services/encode/quality/QualityController.js +935 -935
  173. package/services/encode/quality/QualityOffer.js +416 -416
  174. package/services/encode/quality/drain-threat.js +47 -47
  175. package/services/encode/quality/link-budget.js +349 -349
  176. package/services/encode/quality/output-format.js +319 -319
  177. package/services/encode/quality/serving-output.js +216 -216
  178. package/services/encode/quality/visible-rung.js +57 -57
  179. package/services/encode/run-budget.js +97 -97
  180. package/services/encode/run-command.js +755 -754
  181. package/services/encode/run-costs.js +155 -155
  182. package/services/encode/run-inputs.js +42 -42
  183. package/services/encode/segment-formats/fmp4.js +458 -456
  184. package/services/encode/segment-formats/index.js +85 -85
  185. package/services/encode/segment-formats/init-compat.js +324 -324
  186. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  187. package/services/encode/segment-formats/mpegts.js +134 -134
  188. package/services/encode/start-stop-cost.js +178 -178
  189. package/services/encode/throughput.js +97 -97
  190. package/services/encode/torrent-cost.js +88 -88
  191. package/services/identity/installation-id.js +38 -38
  192. package/services/media/ContainerOrchestrator.js +149 -149
  193. package/services/media/KeyframeTables.js +316 -316
  194. package/services/media/SourceFile.js +298 -298
  195. package/services/media/SubtitleCues.js +520 -520
  196. package/services/media/SubtitleOrchestrator.js +167 -167
  197. package/services/media/audio-inventory.js +299 -298
  198. package/services/media/container/AviContainer.js +266 -266
  199. package/services/media/container/Container.js +609 -609
  200. package/services/media/container/ContainerFactory.js +56 -56
  201. package/services/media/container/KeyframeTable.js +272 -272
  202. package/services/media/container/MatroskaContainer.js +1494 -1494
  203. package/services/media/container/Mp4Container.js +1344 -1344
  204. package/services/media/container/ebml-reader.js +159 -159
  205. package/services/media/ffmpeg-banner.js +260 -260
  206. package/services/media/keyframe-probe.js +157 -157
  207. package/services/media/media-info-probe.js +93 -93
  208. package/services/media/playback-planner.js +931 -931
  209. package/services/media/tracks/AudioTrack.js +143 -143
  210. package/services/media/tracks/ContainerTrack.js +75 -75
  211. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  212. package/services/media/tracks/SubtitleTrack.js +38 -38
  213. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  214. package/services/media/tracks/VideoTrack.js +33 -33
  215. package/services/media/tracks/audio-bound.js +233 -233
  216. package/services/media/tracks/index.js +15 -15
  217. package/services/media/tracks/language-detect.js +174 -174
  218. package/services/server/OutputLifecycle.js +418 -418
  219. package/services/server/SegmentServing.js +1150 -1150
  220. package/services/server/ViewerRequests.js +786 -760
  221. package/services/server/WaitLedger.js +142 -142
  222. package/services/server/controllers/PlaybackController.js +40 -40
  223. package/services/server/controllers/SubtitleController.js +109 -109
  224. package/services/server/http-responses.js +60 -60
  225. package/services/server/playback-readiness.js +516 -708
  226. package/services/server/transcode-session-files.js +325 -325
  227. package/services/server/wire-outputs.js +1020 -995
  228. package/services/storage/Diagnostics.js +202 -202
  229. package/services/storage/MachineBudget.js +235 -235
  230. package/services/storage/allowance.js +107 -107
  231. package/services/storage/core-dumps.js +106 -106
  232. package/services/storage/files/CompletedFiles.js +391 -391
  233. package/services/storage/files/piece-from-whole-file.js +118 -118
  234. package/services/storage/free.js +71 -71
  235. package/services/storage/keep.js +48 -48
  236. package/services/storage/machine-memory.js +56 -56
  237. package/services/storage/memory-report.js +575 -575
  238. package/services/storage/piece-store/piece-disk-store.js +539 -539
  239. package/services/storage/piece-store/piece-lru.js +495 -495
  240. package/services/storage/piece-store/shared-piece-store.js +1937 -1931
  241. package/services/storage/returns.js +103 -103
  242. package/services/storage/segment-store/SegmentStore.js +1255 -1240
  243. package/services/storage/wire.js +175 -175
  244. package/services/torrent/demand/DemandRegister.js +202 -202
  245. package/services/torrent/demand/Urgency.js +137 -137
  246. package/services/torrent/demand/Window.js +118 -118
  247. package/services/torrent/demand/index.js +8 -8
  248. package/services/torrent/demand/pieces.js +140 -140
  249. package/services/torrent/download/SwarmSelection.js +386 -386
  250. package/services/torrent/download/registry.js +102 -102
  251. package/services/torrent/download/withdraw-claim.js +80 -80
  252. package/services/torrent/files.js +455 -455
  253. package/services/torrent/naming.js +619 -619
  254. package/services/torrent/piece-store-of.js +29 -29
  255. package/services/torrent/supply-margin.js +262 -262
  256. package/services/torrent/torrent-pool.js +2688 -2675
  257. package/services/torrent/worker/background-fill.js +213 -213
  258. package/services/torrent/worker/channel.js +271 -271
  259. package/services/torrent/worker/client.js +765 -765
  260. package/services/torrent/worker/fastest-wires.js +342 -342
  261. package/services/torrent/worker/handle-state.js +23 -23
  262. package/services/torrent/worker/held-bytes.js +155 -155
  263. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  264. package/services/torrent/worker/piece-reader.js +1078 -1078
  265. package/services/torrent/worker/pool-adapter.js +371 -371
  266. package/services/torrent/worker/protocol.js +184 -184
  267. package/services/torrent/worker/resume-warm.js +107 -107
  268. package/services/torrent/worker/webrtc-shim.js +219 -219
  269. package/services/torrent/worker/worker.js +1129 -1129
  270. package/services/transport/data-channel-handler.js +1369 -1369
  271. package/services/transport/delivery-probe.js +842 -842
  272. package/services/transport/health-collector.js +53 -53
  273. package/services/transport/nat-classifier.js +231 -231
  274. package/services/transport/packet-witness.js +808 -808
  275. package/services/transport/port-mapper.js +235 -235
  276. package/services/transport/registry-api.js +81 -81
  277. package/services/transport/tunnel-client.js +447 -447
  278. package/services/transport/usrsctp-state.js +175 -175
  279. package/services/transport/webrtc-manager.js +484 -484
  280. package/services/viewer/Assignments.js +340 -340
  281. package/services/viewer/PriorityMap.js +390 -390
  282. package/services/viewer/PriorityOrchestrator.js +325 -325
  283. package/services/viewer/Viewer.js +691 -691
  284. package/services/viewer/Viewers.js +368 -368
  285. package/services/viewer/active-output.js +37 -37
  286. package/services/viewer/audio-start.js +46 -46
  287. package/services/viewer/buffer-trend.js +81 -81
  288. package/services/viewer/choices.js +647 -647
  289. package/services/viewer/positions.js +126 -126
  290. package/services/viewer/report-intake.js +46 -46
  291. package/store/source-registry.js +71 -71
  292. package/test/asset-paths.test.js +28 -28
  293. package/test/audio-calibration.test.js +23 -0
  294. package/test/audio-inventory.test.js +212 -212
  295. package/test/audio-track-bitrate.test.js +40 -40
  296. package/test/auto-quality-step.test.js +707 -707
  297. package/test/available-share.test.js +76 -76
  298. package/test/background-fill.test.js +190 -190
  299. package/test/client-log-file.test.js +110 -110
  300. package/test/completed-files.test.js +115 -115
  301. package/test/concurrent-cost.test.js +148 -148
  302. package/test/container-media-info.test.js +228 -228
  303. package/test/contention.test.js +76 -76
  304. package/test/core-dumps.test.js +57 -57
  305. package/test/coverage-follows-the-disk.test.js +228 -228
  306. package/test/coverage-map.test.js +195 -195
  307. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  308. package/test/cut-times-timeline.test.js +65 -65
  309. package/test/cuts-follow-published-grid.test.js +109 -109
  310. package/test/data-channel-frame.test.js +63 -63
  311. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  312. package/test/dead-torrent-handle.test.js +52 -52
  313. package/test/declared-rates.test.js +116 -116
  314. package/test/declared-tracks.test.js +43 -43
  315. package/test/decode-cost-fit.test.js +202 -202
  316. package/test/decode-cost.test.js +462 -462
  317. package/test/delivery-probe.test.js +393 -393
  318. package/test/demand-register.test.js +206 -206
  319. package/test/dht-bootstrap.test.js +72 -72
  320. package/test/drain-threat.test.js +65 -65
  321. package/test/duplicate-add.test.js +48 -48
  322. package/test/encode-admission.test.js +146 -146
  323. package/test/encode-cost.test.js +283 -283
  324. package/test/encode-exit.test.js +111 -111
  325. package/test/encode-orchestrator.test.js +419 -419
  326. package/test/encode-plan-viewers.test.js +718 -718
  327. package/test/encode-plan.test.js +545 -545
  328. package/test/encode-run-state.test.js +314 -314
  329. package/test/encode-run.test.js +162 -162
  330. package/test/encode-slope.test.js +46 -46
  331. package/test/encoded-output.test.js +39 -39
  332. package/test/encoder-kinds.test.js +122 -122
  333. package/test/encoder-readings.test.js +46 -46
  334. package/test/every-claimant-is-told-its-share.test.js +152 -152
  335. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  336. package/test/failed-start-breaker.test.js +202 -202
  337. package/test/fastest-wires.test.js +143 -143
  338. package/test/ffmpeg-stream-counts.test.js +78 -78
  339. package/test/file-edges.test.js +191 -191
  340. package/test/fmp4-presentation-clock.test.js +162 -147
  341. package/test/fmp4-self-contained.test.js +80 -80
  342. package/test/fmp4-timestamps.test.js +140 -140
  343. package/test/fmp4-track-completeness.test.js +41 -41
  344. package/test/format-keyed-outputs.test.js +78 -78
  345. package/test/health-metrics.test.js +38 -38
  346. package/test/held-request-width.test.js +157 -157
  347. package/test/helpers/encode-run.js +136 -136
  348. package/test/helpers/held-file.js +29 -29
  349. package/test/helpers/manager.js +57 -57
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +102 -102
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +112 -112
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +292 -292
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/memory-budget.test.js +390 -390
  372. package/test/move-cost.test.js +216 -216
  373. package/test/mp4-subtitles.test.js +173 -173
  374. package/test/one-authority.test.js +285 -285
  375. package/test/one-budget.test.js +149 -149
  376. package/test/one-output-one-session.test.js +273 -270
  377. package/test/one-resource-divided.test.js +185 -185
  378. package/test/one-serving-output-per-height.test.js +458 -458
  379. package/test/orchestrator-wired.test.js +206 -206
  380. package/test/output-catalog.test.js +39 -39
  381. package/test/output-shape.test.js +21 -21
  382. package/test/output-spec.test.js +251 -251
  383. package/test/output-speed.test.js +88 -88
  384. package/test/outputs-being-prepared.test.js +40 -40
  385. package/test/packet-witness-ring.test.js +236 -236
  386. package/test/packet-witness.test.js +148 -148
  387. package/test/piece-disk-store.test.js +401 -401
  388. package/test/piece-from-whole-file.test.js +129 -129
  389. package/test/piece-lru.test.js +319 -319
  390. package/test/piece-reader.test.js +162 -162
  391. package/test/piece-selection-offset.test.js +85 -85
  392. package/test/piece-store-eviction.test.js +595 -595
  393. package/test/piece-store-never-refuses.test.js +153 -153
  394. package/test/piece-store-reservations.test.js +403 -403
  395. package/test/piece-store-slow-disk.test.js +159 -159
  396. package/test/piece-tail.test.js +97 -97
  397. package/test/plan-host-timings.test.js +68 -68
  398. package/test/plan-is-steady.test.js +225 -225
  399. package/test/playback-readiness.test.js +483 -453
  400. package/test/playback-start-regressions.test.js +99 -99
  401. package/test/priority-map-download.test.js +174 -174
  402. package/test/priority-map-emptied.test.js +204 -204
  403. package/test/priority-map-per-output.test.js +215 -215
  404. package/test/priority-map.test.js +163 -163
  405. package/test/probe-wedge-certainty.test.js +60 -60
  406. package/test/produced-copy-choice.test.js +213 -213
  407. package/test/published-timeline.test.js +83 -83
  408. package/test/quality-offer.test.js +162 -162
  409. package/test/quality-variants.test.js +1257 -1257
  410. package/test/rate-control-in-the-output.test.js +180 -180
  411. package/test/read-survives-withdrawal.test.js +164 -164
  412. package/test/read-window.test.js +365 -365
  413. package/test/resume-warm.test.js +39 -39
  414. package/test/run-graph-drift.test.js +37 -37
  415. package/test/run-inputs.test.js +80 -80
  416. package/test/run-intervals.test.js +100 -100
  417. package/test/run-position-follows-published-grid.test.js +89 -89
  418. package/test/run-progress.test.js +65 -65
  419. package/test/same-height-switch.test.js +664 -664
  420. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  421. package/test/seek-landing.test.js +109 -109
  422. package/test/seek-target-not-superseded.test.js +195 -195
  423. package/test/segment-closed-on-both-branches.test.js +96 -96
  424. package/test/segment-cut-times.test.js +63 -63
  425. package/test/segment-demand.test.js +80 -80
  426. package/test/segment-hold-supersede.test.js +91 -91
  427. package/test/segment-judged-by-its-own-header.test.js +221 -221
  428. package/test/segment-serve-wiring.test.js +475 -475
  429. package/test/segment-store-eviction.test.js +247 -247
  430. package/test/segment-store.test.js +352 -323
  431. package/test/segment-true-start.test.js +86 -86
  432. package/test/segments-are-shared.test.js +176 -176
  433. package/test/send-chunk-bytes.test.js +86 -86
  434. package/test/serving-existing-output.test.js +105 -105
  435. package/test/serving-output.test.js +102 -102
  436. package/test/shared-piece-store.test.js +310 -310
  437. package/test/sidecar-naming.test.js +142 -142
  438. package/test/soundtrack-rate-bound.test.js +289 -289
  439. package/test/source-file.test.js +133 -133
  440. package/test/source-registry.test.js +18 -18
  441. package/test/split-a-stretch.test.js +139 -139
  442. package/test/stale-request-after-seek.test.js +158 -158
  443. package/test/startup-readings.test.js +124 -124
  444. package/test/stream-route.test.js +209 -209
  445. package/test/subtitle-cue-framing.test.js +200 -200
  446. package/test/subtitle-cue-source.test.js +102 -102
  447. package/test/subtitle-cue-walk.test.js +370 -370
  448. package/test/subtitle-cursor.test.js +63 -63
  449. package/test/subtitle-defaults.test.js +118 -118
  450. package/test/subtitle-language.test.js +252 -252
  451. package/test/subtitle-track-numbering.test.js +386 -386
  452. package/test/supply-margin.test.js +177 -177
  453. package/test/swarm-follows-readers.test.js +174 -174
  454. package/test/swarm-reach.test.js +130 -130
  455. package/test/swarm-selection.test.js +220 -220
  456. package/test/tail-duplication.test.js +167 -167
  457. package/test/the-channel-has-both-ends.test.js +91 -91
  458. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  459. package/test/thread-pool.test.js +53 -53
  460. package/test/timeline.test.js +84 -84
  461. package/test/torrent-contents.test.js +235 -235
  462. package/test/torrent-cost.test.js +71 -71
  463. package/test/torrent-files.test.js +221 -221
  464. package/test/torrent-naming.test.js +255 -255
  465. package/test/tracks-begin-together.test.js +209 -209
  466. package/test/tunnel-renewal.test.js +167 -167
  467. package/test/two-viewers-one-picture.test.js +504 -504
  468. package/test/upload-hurry.test.js +186 -186
  469. package/test/usrsctp-state.test.js +131 -131
  470. package/test/video-facts.test.js +102 -102
  471. package/test/viewer-assignments.test.js +143 -143
  472. package/test/viewer-outputs.test.js +285 -285
  473. package/test/viewer-presence.test.js +116 -116
  474. package/test/viewer-requests-admission.test.js +89 -89
  475. package/test/viewer-states.test.js +189 -189
  476. package/test/viewer-subtitle-subscription.test.js +96 -96
  477. package/test/viewer.test.js +139 -139
  478. package/test/viewing-generations.test.js +390 -390
  479. package/test/visible-rung.test.js +37 -37
  480. package/test/wait-ledger.test.js +100 -100
  481. package/test/webrtc-shim.test.js +149 -149
  482. package/test/wedge-certainty.test.js +131 -131
  483. package/test/withdraw-piece-claim.test.js +212 -212
  484. package/test/worker-channel.test.js +158 -158
  485. package/utils/client-log-file.js +151 -151
  486. package/utils/logger.js +312 -312
  487. package/utils/parse-range.js +25 -25
  488. package/utils/perf.js +59 -59
  489. package/utils/torrent-source-key.js +38 -38
@@ -1,931 +1,931 @@
1
- /**
2
- * @file Playback planner service.
3
- *
4
- * Determines whether a torrent file can be served directly or requires
5
- * HLS audio transcoding by probing the stream codecs with ffmpeg.
6
- * Results are cached indefinitely (keyed by source + file index).
7
- */
8
-
9
- import { spawn } from "node:child_process";
10
- import { logger } from "../../utils/logger.js";
11
- import { Container } from "./container/Container.js";
12
- import { buildAudioInventory, enrichAudioInventoryFromSidecar } from "./audio-inventory.js";
13
- import {
14
- parseFfmpegDurationSeconds,
15
- parseFfmpegStartTimeSeconds,
16
- parseFfmpegBitDepth,
17
- parseFfmpegBitrateKbps,
18
- parseFfmpegVideoFps,
19
- parseFfmpegHdr
20
- } from "./ffmpeg-banner.js";
21
-
22
- /** Audio codecs that browsers can decode natively without transcoding. */
23
- const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
24
-
25
- // Once the plan probe succeeds, warm the START of the file body so the
26
- // transcode session's ffmpeg reads hit downloaded data instead of paying
27
- // piece latency at encode time (the edge prefetch only covers head+tail for
28
- // the codec probe). ~16 MB ≈ the first segments of typical media.
29
- const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
30
-
31
- /**
32
- * How long the plan waits for a file's own header before offering its
33
- * soundtrack without what that header would have said.
34
- *
35
- * Not a measurement, and nothing is derived from it: it is the point past which
36
- * holding the viewer costs more than the language and flags being waited for —
37
- * which the folder name supplies anyway, from the torrent's file list, at no
38
- * cost. The read continues asynchronously after this response and is shared
39
- * with the refresh request, so a later answer can use the same container read.
40
- */
41
- const SIDECAR_HEADER_WAIT_MS = 3_000;
42
-
43
- /** Subtitle codecs that can be converted to WebVTT (text-based). */
44
- const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
45
-
46
- /**
47
- * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
48
- * default disposition and (when present) the stream's `title` metadata line.
49
- *
50
- * @param {string} ffmpegOutput
51
- * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
52
- */
53
- /**
54
- * The bitrate a stream line states for that stream, in kbit/s, or null.
55
- *
56
- * @param {string} line
57
- * @returns {number | null}
58
- */
59
- function kbpsFromStreamLine(line) {
60
- const match = line.match(/,\s*(\d+)\s*kb\/s/);
61
- const kbps = match ? Number(match[1]) : Number.NaN;
62
- return Number.isFinite(kbps) && kbps > 0 ? kbps : null;
63
- }
64
-
65
- function parseStreams(ffmpegOutput) {
66
- // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
67
- // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
68
- const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
69
- const lines = inputSection.split(/\r?\n/);
70
- const streams = [];
71
- let current = null;
72
- for (const line of lines) {
73
- const streamMatch = line.match(
74
- /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
75
- );
76
- if (streamMatch) {
77
- current = {
78
- streamIndex: Number(streamMatch[1]),
79
- type: streamMatch[3].toLowerCase(),
80
- codec: String(streamMatch[4]).toLowerCase(),
81
- language: (streamMatch[2] ?? "").toLowerCase(),
82
- title: "",
83
- isDefault: /\(default\)/.test(line),
84
- // What the stream line states for THIS stream, e.g. "…, 128 kb/s". A
85
- // Matroska stream usually states none here and carries its rate as a
86
- // statistics tag instead (`BPS`, read below). Never the file's total.
87
- bitrateKbps: kbpsFromStreamLine(line)
88
- };
89
- streams.push(current);
90
- continue;
91
- }
92
- if (current) {
93
- const bpsMatch = line.match(/^\s+BPS(?:-[A-Za-z]+)?\s*:\s*(\d+)\s*$/);
94
- if (bpsMatch && current.bitrateKbps === null) {
95
- current.bitrateKbps = Math.round(Number(bpsMatch[1]) / 1000);
96
- continue;
97
- }
98
- const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
99
- if (titleMatch && current.title.length === 0) {
100
- current.title = titleMatch[1].trim();
101
- continue;
102
- }
103
- // A new top-level section (non-indented line) ends the stream's block.
104
- if (!/^\s/.test(line)) {
105
- current = null;
106
- }
107
- }
108
- }
109
- return streams;
110
- }
111
-
112
- /**
113
- * Parse audio and video codec names from ffmpeg stderr output.
114
- *
115
- * @param {string} ffmpegOutput
116
- * @returns {{ audioCodec: string, videoCodec: string }}
117
- */
118
- export function parseStreamCodecs(ffmpegOutput) {
119
- const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
120
- const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
121
- // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
122
- // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
123
- const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
124
- let videoWidth = 0;
125
- let videoHeight = 0;
126
- if (videoLineMatch) {
127
- const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
128
- if (dim) {
129
- videoWidth = Number(dim[1]);
130
- videoHeight = Number(dim[2]);
131
- }
132
- }
133
- const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
134
- const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
135
- let durationSeconds = 0;
136
- if (durationMatch) {
137
- const value =
138
- Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
139
- durationSeconds = Number.isFinite(value) ? value : 0;
140
- }
141
- const streams = parseStreams(ffmpegOutput);
142
- const audioTracks = streams
143
- .filter((s) => s.type === "audio")
144
- .map((s, i) => ({
145
- // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
146
- index: i,
147
- streamIndex: s.streamIndex,
148
- codec: s.codec,
149
- language: s.language,
150
- title: s.title,
151
- isDefault: s.isDefault,
152
- bitrateKbps: s.bitrateKbps
153
- }));
154
- const subtitleTracks = streams
155
- .filter((s) => s.type === "subtitle")
156
- .map((s, i) => ({
157
- // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
158
- index: i,
159
- streamIndex: s.streamIndex,
160
- codec: s.codec,
161
- language: s.language,
162
- title: s.title,
163
- isDefault: s.isDefault,
164
- // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
165
- textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
166
- }));
167
- return {
168
- audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
169
- videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
170
- container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
171
- durationSeconds,
172
- videoWidth,
173
- videoHeight,
174
- audioTracks,
175
- subtitleTracks
176
- };
177
- }
178
-
179
- /**
180
- * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
181
- * Times out after `timeoutMs` and returns empty strings on failure.
182
- *
183
- * @param {object} options
184
- * @param {string} options.ffmpegBin
185
- * @param {string} options.inputUrl
186
- * @param {string} [options.userAgent=""]
187
- * @param {number} [options.timeoutMs=8000]
188
- * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
189
- * Parsed banner fields plus the raw `stderr`, so the caller can derive the
190
- * full media info (fps/startTime/HDR) without a second ffmpeg scan.
191
- */
192
- function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
193
- return new Promise((resolve) => {
194
- const args = ["-hide_banner", "-loglevel", "info"];
195
- if (typeof userAgent === "string" && userAgent.trim().length > 0) {
196
- args.push("-user_agent", userAgent.trim());
197
- }
198
- // Decode a tiny slice of all streams (no per-stream -map, so video-only
199
- // files probe correctly too). The ffmpeg banner that precedes decoding
200
- // gives us audio/video codecs, the container format and the duration in a
201
- // single pass.
202
- args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
203
-
204
- const ffmpeg = spawn(ffmpegBin, args, {
205
- stdio: ["ignore", "ignore", "pipe"],
206
- windowsHide: true
207
- });
208
- let stderr = "";
209
- let settled = false;
210
-
211
- const finish = (codecs) => {
212
- if (settled) {
213
- return;
214
- }
215
- settled = true;
216
- resolve(codecs);
217
- };
218
-
219
- const timeoutId = setTimeout(() => {
220
- if (!ffmpeg.killed) {
221
- ffmpeg.kill("SIGTERM");
222
- }
223
- finish({ ...parseStreamCodecs(stderr), stderr });
224
- }, timeoutMs);
225
-
226
- ffmpeg.stderr.on("data", (chunk) => {
227
- stderr += String(chunk);
228
- });
229
-
230
- ffmpeg.on("error", () => {
231
- clearTimeout(timeoutId);
232
- finish({ audioCodec: "", videoCodec: "", stderr: "" });
233
- });
234
-
235
- ffmpeg.on("exit", () => {
236
- clearTimeout(timeoutId);
237
- finish({ ...parseStreamCodecs(stderr), stderr });
238
- });
239
- });
240
- }
241
-
242
- /**
243
- * Resolve after a given number of milliseconds.
244
- *
245
- * @param {number} ms
246
- * @returns {Promise<void>}
247
- */
248
- function delay(ms) {
249
- return new Promise((resolve) => {
250
- setTimeout(resolve, ms);
251
- });
252
- }
253
-
254
- /**
255
- * Build the direct stream URL for a source file served by the local proxy.
256
- *
257
- * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
258
- * @param {string} sourceKey
259
- * @param {number} fileIndex
260
- * @returns {string}
261
- */
262
- function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
263
- const directUrl = new URL("/stream", `${localBaseUrl}/`);
264
- directUrl.searchParams.set("sourceKey", sourceKey);
265
- directUrl.searchParams.set("fileIndex", String(fileIndex));
266
- return directUrl.toString();
267
- }
268
-
269
- /**
270
- * @typedef {Object} PlaybackPlan
271
- * @property {"direct" | "hls"} mode
272
- * @property {string} directUrl
273
- * @property {string} reason - Human-readable explanation of the chosen mode.
274
- * @property {string} audioCodec
275
- * @property {string} videoCodec
276
- * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
277
- * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
278
- * @property {number} videoWidth - Source coded width (0 if unknown).
279
- * @property {number} videoHeight - Source coded height (0 if unknown).
280
- * @property {boolean} audioTracksPending - At least one sidecar header timed out and can be refreshed later.
281
- */
282
-
283
- /**
284
- * @typedef {Object} PlaybackPlannerOptions
285
- * @property {string} ffmpegBin
286
- * @property {boolean} transcodeAudioEnabled
287
- * @property {string} localBaseUrl
288
- * @property {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
289
- * @property {import("../torrent/torrent-pool.js").TorrentPool} torrentPool
290
- */
291
-
292
- /**
293
- * Create a playback planner that decides the optimal streaming mode for
294
- * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
295
- *
296
- * @param {PlaybackPlannerOptions} options
297
- * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan>, refreshAudioTracks: (params: { sourceKey: string, fileIndex: number }) => Promise<{ audioTracks: object[], pending: boolean }> }}
298
- */
299
- export function createPlaybackPlanner({
300
- ffmpegBin,
301
- transcodeAudioEnabled,
302
- localBaseUrl,
303
- sourceRegistry,
304
- torrentPool,
305
- // Optional. Reports what this host typically takes to produce a session's
306
- // first segment. The browser needs it for the gap between "the file is
307
- // downloaded" and "a segment exists": until now it assumed the pipeline
308
- // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
309
- expectedFirstSegmentMs,
310
- expectedSessionCreateMs,
311
- // What the torrent holds beside this picture — its dubs, its subtitle files,
312
- // its contact sheets — grouped by whoever knows what a torrent contains. This
313
- // layer answers about ONE file and must not read a torrent's file list.
314
- sidecarsFromTorrent = null,
315
- // What a file DECLARES about its own tracks, read from its header. Handed in
316
- // rather than asked of the torrent pool: this layer used to call
317
- // `torrentPool.getDeclaredAudioTracks` and friends, which is the media layer
318
- // asking the torrent layer to parse a container on its behalf — the parse
319
- // happened in the torrent thread and came back over the channel. It happens
320
- // on this thread now, and what is passed here is the ordinary container read.
321
- declaredTracksOf = null,
322
- // Optional. Called once the file's edges are downloaded, so the keyframe
323
- // index — which reads the same tail of the file — is fetched alongside the
324
- // codec probe instead of after it. It goes straight to `KeyframeTables`,
325
- // which owns that table: it used to be routed through the session manager,
326
- // which held a second copy of it and a second reader for it.
327
- warmKeyframeIndex,
328
- // Optional. The heights this host could actually serve this source at, for
329
- // both playback branches, so the quality menu is right from the moment the
330
- // file is opened rather than from the moment an encoder exists.
331
- predictOfferedHeights
332
- }) {
333
- /** @type {Map<string, PlaybackPlan>} */
334
- const cache = new Map();
335
- const pendingSidecarHeaders = new Map();
336
- const declaredAudioReads = new Map();
337
- /**
338
- * Full media info parsed from the SAME probe that produced the plan, cached
339
- * under the same key so a transcode session can reuse it instead of running
340
- * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
341
- * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
342
- */
343
- const mediaInfoCache = new Map();
344
-
345
- /**
346
- * Attach what this host currently measures itself taking to create a session
347
- * and to produce a first segment.
348
- *
349
- * Read at RESPONSE time, deliberately. Both are medians of sessions that have
350
- * already finished on this host, so at the moment a plan is BUILT the very
351
- * first file opened after a restart has none and gets `null` — and the plan
352
- * is then cached, so that file kept answering `null` for the life of the
353
- * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
354
- * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
355
- * first segment in 21 479 ms. The figures existed; the plan could not carry
356
- * them, and the browser's estimate fell back to its own guess in exactly the
357
- * cold-start case the feature was built for.
358
- *
359
- * @param {PlaybackPlan} plan
360
- * @returns {PlaybackPlan}
361
- */
362
- /**
363
- * The probe's subtitle tracks, with `FlagDefault` read from the container
364
- * instead of inferred from ffmpeg's banner.
365
- *
366
- * Best-effort by construction: a container that cannot be read this way, or a
367
- * reading that does not line up with the probe, leaves the tracks as they
368
- * were with `declaresDefault: false` — which the browser reads as "the file
369
- * has no opinion", and then nothing is shown unasked.
370
- *
371
- * @param {object} torrent
372
- * @param {number} fileIndex
373
- * @param {object[]} subtitleTracks
374
- * @returns {Promise<object[]>}
375
- */
376
- /**
377
- * The files beside this picture that belong to it, in three groups.
378
- *
379
- * HANDED IN, because what a torrent contains is the torrent layer's to say
380
- * and this is the media layer. It works the grouping out once and keeps it;
381
- * it used to be worked out here on every call, and again on the warm-up's own
382
- * path over the same list, so one opened film paired the same files several
383
- * times and neither side could be sure of the other's answer.
384
- *
385
- * Absent, a file has no companions — which is what a proxy with no torrent
386
- * behind it should answer, rather than failing.
387
- *
388
- * @param {object} torrent
389
- * @param {number} fileIndex
390
- * @returns {{ audio: object[], subtitles: object[], images: object[] }}
391
- */
392
- function sidecarsOf(torrent, fileIndex) {
393
- return sidecarsFromTorrent?.(torrent ?? {}, fileIndex) ?? { audio: [], subtitles: [], images: [] };
394
- }
395
-
396
- async function withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks) {
397
- if (subtitleTracks.length === 0 || typeof declaredTracksOf !== "function") {
398
- return subtitleTracks.map((track) => ({ ...track, declaresDefault: false }));
399
- }
400
- let declared = [];
401
- try {
402
- declared = (await declaredTracksOf({ sourceKey, fileIndex })).filter((track) => track?.type === "subtitle");
403
- } catch (error) {
404
- logger.info(`subtitle defaults: the container could not be read (${error?.message ?? error})`);
405
- }
406
- const merged = Container.mergeSubtitleFlags(subtitleTracks, declared);
407
- logger.info(
408
- merged.aligned
409
- ? "subtitle defaults: the container wrote FlagDefault on " +
410
- `${merged.tracks.filter((track) => track.declaresDefault).length} of ${merged.tracks.length} ` +
411
- `subtitle tracks, marking ${merged.tracks.filter((track) => track.declaresDefault && track.isDefault).length}`
412
- : `subtitle defaults: using the probe's own flags — ${merged.reason}`
413
- );
414
- return merged.tracks;
415
- }
416
-
417
- /**
418
- * Every soundtrack this file can be watched with, as one numbered list: its
419
- * own tracks and the ones shipped as separate files beside it.
420
- *
421
- * Built here, in the plan, because the plan is what the viewer's menu is drawn
422
- * from — so every playable track gets a stable number before the file opens.
423
- * Sidecar header fields may arrive later; they enrich an existing numbered
424
- * entry without changing the inventory length. It is also what
425
- * the master playlist's rendition group is built from, so the number in the
426
- * menu and the number in the `a/<n>/` address are the same number by
427
- * construction rather than by agreement.
428
- *
429
- * @param {string} sourceKey
430
- * @param {object} torrent
431
- * @param {number} fileIndex
432
- * @param {object[]} bannerAudioTracks - The probe's own audio streams.
433
- * @returns {Promise<{ audioTracks: import("./audio-inventory.js").AudioInventoryEntry[], pendingFileIndexes: number[] }>}
434
- */
435
- async function declaredAudioOf(sourceKey, wantedFileIndex, label) {
436
- if (typeof declaredTracksOf !== "function") {
437
- return { tracks: [], complete: false, timedOut: false };
438
- }
439
- const readKey = `${sourceKey}:${wantedFileIndex}`;
440
- let readPromise = declaredAudioReads.get(readKey);
441
- if (!readPromise) {
442
- readPromise = Promise.resolve()
443
- .then(() => declaredTracksOf({ sourceKey, fileIndex: wantedFileIndex }))
444
- .then((tracks) => ({
445
- tracks: tracks
446
- .filter((track) => track?.type === "audio")
447
- .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0)),
448
- // ContainerOrchestrator leaves empty reads uncached because the file
449
- // header may not have arrived yet. Only a non-empty table proves the
450
- // read reached the container's track list.
451
- complete: Array.isArray(tracks) && tracks.length > 0
452
- }))
453
- .catch((error) => {
454
- logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
455
- return { tracks: [], complete: false };
456
- });
457
- declaredAudioReads.set(readKey, readPromise);
458
- readPromise.then(
459
- () => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); },
460
- () => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); }
461
- );
462
- }
463
- let timer = null;
464
- try {
465
- return await Promise.race([
466
- readPromise,
467
- new Promise((resolve) => {
468
- timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
469
- timer.unref?.();
470
- })
471
- ]).then((tracks) => {
472
- if (tracks === null) {
473
- logger.info(
474
- `audio tracks: "${label}" did not answer within ${SIDECAR_HEADER_WAIT_MS / 1000}s — ` +
475
- "offered without what its header would say"
476
- );
477
- return { tracks: [], complete: false, timedOut: true };
478
- }
479
- return {
480
- tracks: Array.isArray(tracks?.tracks) ? tracks.tracks : [],
481
- complete: tracks?.complete === true,
482
- timedOut: false
483
- };
484
- });
485
- } catch (error) {
486
- logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
487
- return { tracks: [], complete: false, timedOut: false };
488
- } finally {
489
- if (timer !== null) clearTimeout(timer);
490
- }
491
- }
492
-
493
- async function buildInventory(sourceKey, torrent, fileIndex, bannerAudioTracks) {
494
- const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
495
- // The picture's own tracks: ffmpeg numbers them, the container declares what
496
- // they are. Both readings, lined up and checked — see `audio-inventory.js`.
497
- let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
498
- if (banner.length > 0) {
499
- // The picture's head is already downloaded — the codec probe just read it
500
- // — so this is a parse and not a wait, but it is bounded like the rest.
501
- const { tracks: declared } = await declaredAudioOf(sourceKey, fileIndex, "the picture");
502
- const merged = Container.mergeAudioFlags(banner, declared);
503
- embedded = merged.tracks;
504
- logger.info(
505
- merged.aligned
506
- ? `audio tracks: the container describes all ${merged.tracks.length}` +
507
- `${merged.tracks.some((track) => track.isCommentary) ? ", one of them commentary" : ""}` +
508
- `${merged.tracks.some((track) => track.isVisualImpaired) ? ", one of them described" : ""}`
509
- : `audio tracks: using the probe's own fields — ${merged.reason}`
510
- );
511
- }
512
-
513
- const sidecarFiles = sidecarsOf(torrent, fileIndex);
514
- // All of them at once. They are separate files with separate headers, and
515
- // read one after another the waits add up on the path to the first frame.
516
- const sidecars = await Promise.all(
517
- sidecarFiles.audio.map(async (file) => ({
518
- file,
519
- // A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
520
- // read, so nothing is asked of the swarm for it at all.
521
- ...(file.declaresTracks
522
- ? await declaredAudioOf(sourceKey, file.fileIndex, file.name)
523
- : { tracks: [], timedOut: false })
524
- }))
525
- );
526
- const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
527
- if (sidecars.length > 0) {
528
- logger.info(
529
- `audio tracks: ${sidecars.length} file(s) beside the picture carry sound — ` +
530
- inventory
531
- .filter((entry) => entry.kind === "sidecar")
532
- .map((entry) =>
533
- `a:${entry.index}=${entry.folders.join("/") || "."}/${entry.fileName}` +
534
- `#${entry.sourceTrackIndex}${entry.codec ? `(${entry.codec})` : ""}`
535
- )
536
- .join(" ")
537
- );
538
- }
539
- return {
540
- audioTracks: inventory,
541
- pendingFileIndexes: sidecars.filter((sidecar) => sidecar.timedOut).map((sidecar) => sidecar.file.fileIndex)
542
- };
543
- }
544
-
545
- async function refreshAudioTracks({ sourceKey, fileIndex }) {
546
- const cacheKey = `${sourceKey}:${fileIndex}`;
547
- const plan = cache.get(cacheKey);
548
- if (!plan) {
549
- return { audioTracks: Array.isArray(plan?.audioTracks) ? plan.audioTracks : [], pending: false };
550
- }
551
- const pendingIndexes = new Set(pendingSidecarHeaders.get(cacheKey) ?? []);
552
- if (pendingIndexes.size === 0 || typeof declaredTracksOf !== "function") {
553
- return { audioTracks: plan.audioTracks, pending: false };
554
- }
555
- const sourceRecord = sourceRegistry.get(sourceKey);
556
- if (!sourceRecord) {
557
- const error = new Error("Source key was not found.");
558
- error.code = "SOURCE_NOT_FOUND";
559
- throw error;
560
- }
561
- const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
562
- const sidecarFiles = sidecarsOf(torrent, fileIndex).audio;
563
- for (const sidecar of sidecarFiles) {
564
- if (!pendingIndexes.has(sidecar.fileIndex)) continue;
565
- const read = await declaredAudioOf(sourceKey, sidecar.fileIndex, sidecar.name);
566
- if (read.timedOut) continue;
567
- if (read.tracks.length === 0) {
568
- if (read.complete) pendingIndexes.delete(sidecar.fileIndex);
569
- continue;
570
- }
571
- const audio = read.tracks
572
- .filter((track) => track?.type === "audio")
573
- .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0));
574
- if (audio.length === 0) continue;
575
- if (!enrichAudioInventoryFromSidecar(plan.audioTracks, sidecar, audio[0])) {
576
- pendingIndexes.delete(sidecar.fileIndex);
577
- continue;
578
- }
579
- // Keep the published inventory length and every address stable for this
580
- // session. A late header enriches the offered first track; adding tracks
581
- // would require replacing the HLS master playlist already loaded by the
582
- // browser.
583
- pendingIndexes.delete(sidecar.fileIndex);
584
- if (audio.length > 1) {
585
- logger.info(
586
- `audio tracks: late header for "${sidecar.name}" describes ${audio.length} tracks; ` +
587
- "the active inventory keeps its published track count"
588
- );
589
- }
590
- }
591
- pendingSidecarHeaders.set(cacheKey, [...pendingIndexes]);
592
- plan.audioTracksPending = pendingIndexes.size > 0;
593
- return { audioTracks: plan.audioTracks, pending: pendingIndexes.size > 0 };
594
- }
595
-
596
- function withHostTimings(plan) {
597
- const withOffer = {
598
- ...plan,
599
- expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
600
- expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
601
- // Answered here for the same reason as the two above: a plan is cached for
602
- // the life of the process, and what this host will serve a file at is not.
603
- // It starts as a prediction from the startup benchmarks and is replaced by
604
- // what an encoder running on this very source turns out to cost — frozen
605
- // into the cache, every later open of the file would hand the browser the
606
- // first guess again and undo that. This is the 2.9.106 defect exactly.
607
- offeredHeights: plan.mediaInfoForOffer
608
- ? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
609
- : null,
610
- // The description the rest of the pool answers by arithmetic. Carried
611
- // on every plan, not only on a refusal here: the output opened next can
612
- // still be refused for want of a place on this machine (roadmap item 97,
613
- // step 14), and the page then asks the pool the same question before
614
- // anything plays — which it can only do with this in hand.
615
- mediaInfoForOffer: plan.mediaInfoForOffer
616
- };
617
- // Refused rather than served badly. Both lists empty means this machine
618
- // cannot sustain this file at ANY height — not even by copying the picture,
619
- // which costs no encoder at all — so a session made here would produce a
620
- // slideshow and take the swarm and the processor from whoever is already
621
- // watching. Field 2026-08-28: five sessions on one file put every rung at
622
- // 0.04x of realtime and the viewer watched one before the process was
623
- // killed. The viewer is told why, which is a different thing from a spinner
624
- // that never ends.
625
- const offer = withOffer.offeredHeights;
626
- if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
627
- withOffer.cannotServe =
628
- "This proxy cannot keep up with this file at any quality right now.";
629
- // The description travels with the plan (above). It is what lets the
630
- // browser ask the rest of the pool the same question without anybody
631
- // else adding the torrent, fetching a byte or running ffmpeg — the
632
- // expensive half of finding out what this file IS has been paid here,
633
- // once. Everyone else answers by arithmetic against their own startup
634
- // benchmarks.
635
- }
636
- return withOffer;
637
- }
638
-
639
- return {
640
- /**
641
- * Media info the planner already probed for this file, or `null`. Lets the
642
- * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
643
- *
644
- * @param {{ sourceKey: string, fileIndex: number }} params
645
- * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
646
- */
647
- /**
648
- * The audio tracks this file was probed to have, or an empty list. The
649
- * master playlist publishes one rendition per track, and the inventory is
650
- * already here — probing again for it would be a second scan of a file the
651
- * proxy is in the middle of serving.
652
- *
653
- * @param {{ sourceKey: string, fileIndex: number }} params
654
- * @returns {object[]}
655
- */
656
- getCachedAudioTracks({ sourceKey, fileIndex }) {
657
- const plan = cache.get(`${sourceKey}:${fileIndex}`);
658
- return Array.isArray(plan?.audioTracks) ? plan.audioTracks : [];
659
- },
660
-
661
- refreshAudioTracks,
662
-
663
- getCachedMediaInfo({ sourceKey, fileIndex }) {
664
- return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
665
- },
666
-
667
- /**
668
- * Return the playback plan for the given source file.
669
- * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
670
- * when the source or file cannot be located.
671
- *
672
- * When the file header has not downloaded yet (cold torrent, peers still
673
- * connecting) the codec probe cannot succeed. Rather than block the HTTP
674
- * response until it can, the planner prioritises the header, probes for at
675
- * most `maxWaitMs`, and if still undetectable returns a plan flagged
676
- * `pending: true` (NOT cached). The caller polls again — each call keeps the
677
- * header prioritised and downloading — until a real plan comes back. This
678
- * avoids a single long request racing the transport's request timeout.
679
- *
680
- * @param {object} params
681
- * @param {string} params.sourceKey
682
- * @param {number} params.fileIndex
683
- * @param {string} [params.userAgent=""]
684
- * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
685
- * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
686
- */
687
- async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000, background = false }) {
688
- const cacheKey = `${sourceKey}:${fileIndex}`;
689
- const cached = cache.get(cacheKey);
690
- if (cached) {
691
- return withHostTimings(cached);
692
- }
693
- // Where the time before playback goes. `cold-start` already breaks down
694
- // everything from the transcode-session request onwards, but the plan
695
- // runs BEFORE that and was a single opaque wait: a field session spent
696
- // 5.7 s here on a torrent already in the store, with the codec probe
697
- // cached, and nothing said which part of it was slow.
698
- const planEntryMs = Date.now();
699
- let torrentReadyMs = 0;
700
- let edgesReadyMs = 0;
701
-
702
- const sourceRecord = sourceRegistry.get(sourceKey);
703
- if (!sourceRecord) {
704
- const error = new Error("Source key was not found.");
705
- error.code = "SOURCE_NOT_FOUND";
706
- throw error;
707
- }
708
-
709
- const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
710
- torrentReadyMs = Date.now() - planEntryMs;
711
- const file = torrent.files[fileIndex];
712
- if (!file) {
713
- const error = new Error("File index was not found in torrent.");
714
- error.code = "FILE_NOT_FOUND";
715
- throw error;
716
- }
717
-
718
- const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
719
- if (!transcodeAudioEnabled) {
720
- const plan = {
721
- mode: "direct",
722
- directUrl,
723
- reason: "transcode-disabled",
724
- audioCodec: "",
725
- videoCodec: "",
726
- container: "",
727
- durationSeconds: 0,
728
- videoWidth: 0,
729
- videoHeight: 0,
730
- audioTracks: [],
731
- audioTracksPending: false,
732
- subtitleTracks: []
733
- };
734
- cache.set(cacheKey, plan);
735
- return withHostTimings(plan);
736
- }
737
-
738
- // Pre-fetch file edges (head + tail), then probe — retrying while the
739
- // file header is still downloading. In a multi-file torrent the pieces
740
- // for a given file arrive unevenly, so the first probe can return empty
741
- // codecs. A transient empty probe must NOT be cached: otherwise the wrong
742
- // plan (file treated as directly playable) sticks permanently for this
743
- // file, and an unsupported codec like xvid gets copied → black video.
744
- // Awaited in the literal sense: this answer cannot be given until the
745
- // file has said what is in it, and a person is watching a loading screen
746
- // for as long as that takes.
747
- await torrentPool.prefetchFileEdges(torrent, fileIndex, {
748
- awaited: !background,
749
- timeoutMs: background ? 10_000 : 300_000
750
- });
751
- edgesReadyMs = Date.now() - planEntryMs;
752
- // The keyframe index reads the tail of the file, which the probe has just
753
- // waited for as well. Started here it overlaps the probe instead of
754
- // following the whole plan — worth 311-430 ms of the time before the
755
- // first segment. Fire and forget: a session that finds the table
756
- // unanswered joins this very read rather than starting a second one.
757
- if (!background) {
758
- warmKeyframeIndex?.({ sourceKey, fileIndex, logName: file.name });
759
- }
760
- let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
761
- const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
762
- let attempt = 0;
763
- while (
764
- probe.audioCodec.length === 0 &&
765
- probe.videoCodec.length === 0 &&
766
- Date.now() < probeDeadline
767
- ) {
768
- attempt += 1;
769
- await delay(Math.min(3_000, 500 + attempt * 250));
770
- await torrentPool.prefetchFileEdges(torrent, fileIndex, {
771
- awaited: !background,
772
- timeoutMs: background ? 10_000 : 300_000
773
- });
774
- probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
775
- }
776
- const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
777
- const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
778
- logger.info(
779
- `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
780
- `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
781
- `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
782
- `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
783
- );
784
-
785
- // The picture's own facts come from two readings and only one was ever
786
- // used: every figure the encode is planned from came from ffmpeg's
787
- // banner, while the `VideoTrack` the container declares was read and used
788
- // for nothing but a line in the log.
789
- let declaredVideo = null;
790
- if (typeof declaredTracksOf === "function") {
791
- try {
792
- // One, because ffmpeg's `0:v:0` is what everything downstream is built
793
- // on and a second video stream is a cover image far more often than a
794
- // second film.
795
- declaredVideo = (await declaredTracksOf({ sourceKey, fileIndex }))
796
- .filter((track) => track?.type === "video")
797
- .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))[0] ?? null;
798
- } catch (error) {
799
- logger.info(`video track: could not be read (${error?.message ?? error})`);
800
- }
801
- }
802
- // `mode` is advisory only (audio-codec based). The browser makes the
803
- // authoritative decision independently per stream via canPlayType /
804
- // mediaCapabilities, transcoding only what it cannot play.
805
- const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
806
- // The two readings of the picture, lined up. Which one answers is decided
807
- // per field by what each IS — see `Container.mergeVideoFacts`.
808
- const videoFacts = Container.mergeVideoFacts(
809
- {
810
- width: videoWidth,
811
- height: videoHeight,
812
- fps: parseFfmpegVideoFps(probe.stderr),
813
- isHdr: parseFfmpegHdr(probe.stderr),
814
- bitDepth: parseFfmpegBitDepth(probe.stderr)
815
- },
816
- declaredVideo
817
- );
818
- if (videoFacts.disagreements.length > 0) {
819
- logger.info(
820
- `video track: the file and the probe disagree — ${videoFacts.disagreements.join("; ")}; ` +
821
- "the size and frame rate are the probe's, the bit depth and HDR the file's"
822
- );
823
- }
824
- const sidecars = sidecarsOf(torrent, fileIndex);
825
- const inventory = await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []);
826
- const plan = {
827
- mode: requiresTranscode ? "hls" : "direct",
828
- directUrl,
829
- reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
830
- audioCodec,
831
- videoCodec,
832
- container,
833
- durationSeconds,
834
- // Source coded resolution — drives the browser's manual quality menu
835
- // (list of forced resolutions <= source). 0 when unknown.
836
- videoWidth: videoFacts.width ?? 0,
837
- videoHeight: videoFacts.height ?? 0,
838
- // Full track inventory for the browser's audio/subtitle menus. The audio
839
- // half spans the picture's own tracks AND the soundtracks shipped as
840
- // files beside it, under one numbering — see `buildInventory`.
841
- audioTracks: inventory.audioTracks,
842
- audioTracksPending: inventory.pendingFileIndexes.length > 0,
843
- subtitleTracks: await withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks ?? []),
844
- // The files BESIDE this picture that belong to it, and what each one's
845
- // own path says about the track in it. Both answers are made here, by
846
- // one grammar, because the browser used to make them again: it paired
847
- // with a looser rule and read the names with a stricter one, and nothing
848
- // compared the two. Measured 2026-09-04 over 115 real torrents — 1249
849
- // video files, ten pairings differing — and the difference reached the
850
- // viewer as a subtitle track offered but never warmed.
851
- //
852
- // The soundtracks are NOT repeated here: they are already in
853
- // `audioTracks`, under the one flat numbering the browser addresses them
854
- // by. What this adds is the two groups that had no place in the plan at
855
- // all.
856
- sidecarSubtitles: sidecars.subtitles,
857
- sidecarImages: sidecars.images,
858
- // Both host timings are filled in by `withHostTimings` on the way out,
859
- // never here: read at build time they would be frozen into the cached
860
- // plan, which is the bug fixed in 2.9.106.
861
- expectedFirstSegmentMs: null,
862
- expectedSessionCreateMs: null,
863
- offeredHeights: null,
864
- // What the offer is computed FROM, kept on the cached plan so the offer
865
- // itself can be recomputed on every response. The figures are the
866
- // probe's own and never change for a file; the answer derived from them
867
- // does, as the host learns what this source costs. Stripped on the way
868
- // out — it is not part of the plan the browser is given.
869
- mediaInfoForOffer: {
870
- width: videoFacts.width,
871
- height: videoFacts.height,
872
- fps: videoFacts.fps,
873
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
874
- // Which family of the decode measurement prices this source. A video
875
- // that has to be re-encoded is one the browser could not play, so it
876
- // is usually NOT H.264, and H.264 constants are wrong for it.
877
- codec: videoCodec,
878
- bitDepth: videoFacts.bitDepth,
879
- // Which file this is, so the offer can be answered from what an
880
- // encoder has already learned about THIS source rather than from the
881
- // startup clips — the same correction a live session applies.
882
- sourceKey,
883
- fileIndex
884
- }
885
- };
886
- // Only cache a plan whose codecs were actually detected. An empty probe is
887
- // a "header not downloaded yet" signal, not a valid result — caching it
888
- // would permanently mis-plan the file. In that case flag the plan
889
- // `pending` so the caller polls again (the header keeps downloading,
890
- // prioritised by the prefetch above).
891
- if (codecsDetected) {
892
- cache.set(cacheKey, plan);
893
- pendingSidecarHeaders.set(cacheKey, inventory.pendingFileIndexes);
894
- // Cache the full media info from THIS probe's banner (same helpers the
895
- // session manager uses) so createSession can skip its own probe.
896
- mediaInfoCache.set(cacheKey, {
897
- // The codecs, because the session manager asks this cache which
898
- // tracks the output will carry — and they were never stored here. It
899
- // read `videoCodec`/`audioCodec` off an object that has only ever had
900
- // dimensions and duration, got `undefined` for both, and declared
901
- // `{video: false, audio: false}` for EVERY session since the check was
902
- // written. Measured 2026-08-11: `declared tracks video=false
903
- // audio=false`, which left the browser unable to tell "this file has
904
- // no video" from "the video was lost on the way", and left the init
905
- // guard expecting zero tracks and therefore accepting any header.
906
- videoCodec: plan.videoCodec,
907
- audioCodec: plan.audioCodec,
908
- durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
909
- width: videoFacts.width,
910
- height: videoFacts.height,
911
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
912
- fps: videoFacts.fps,
913
- startTime: parseFfmpegStartTimeSeconds(probe.stderr),
914
- isHdr: videoFacts.isHdr,
915
- bitDepth: videoFacts.bitDepth
916
- });
917
- // Warm the file-body start for the transcode session that follows.
918
- // Fire-and-forget: never delays the plan response.
919
- void torrentPool
920
- .prefetchFileEdges(torrent, fileIndex, {
921
- headBytes: BODY_PREFETCH_BYTES,
922
- tailBytes: 0,
923
- timeoutMs: 60_000
924
- })
925
- .catch(() => {});
926
- return withHostTimings(plan);
927
- }
928
- return withHostTimings({ ...plan, pending: true });
929
- }
930
- };
931
- }
1
+ /**
2
+ * @file Playback planner service.
3
+ *
4
+ * Determines whether a torrent file can be served directly or requires
5
+ * HLS audio transcoding by probing the stream codecs with ffmpeg.
6
+ * Results are cached indefinitely (keyed by source + file index).
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { logger } from "../../utils/logger.js";
11
+ import { Container } from "./container/Container.js";
12
+ import { buildAudioInventory, enrichAudioInventoryFromSidecar } from "./audio-inventory.js";
13
+ import {
14
+ parseFfmpegDurationSeconds,
15
+ parseFfmpegStartTimeSeconds,
16
+ parseFfmpegBitDepth,
17
+ parseFfmpegBitrateKbps,
18
+ parseFfmpegVideoFps,
19
+ parseFfmpegHdr
20
+ } from "./ffmpeg-banner.js";
21
+
22
+ /** Audio codecs that browsers can decode natively without transcoding. */
23
+ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
24
+
25
+ // Once the plan probe succeeds, warm the START of the file body so the
26
+ // transcode session's ffmpeg reads hit downloaded data instead of paying
27
+ // piece latency at encode time (the edge prefetch only covers head+tail for
28
+ // the codec probe). ~16 MB ≈ the first segments of typical media.
29
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
30
+
31
+ /**
32
+ * How long the plan waits for a file's own header before offering its
33
+ * soundtrack without what that header would have said.
34
+ *
35
+ * Not a measurement, and nothing is derived from it: it is the point past which
36
+ * holding the viewer costs more than the language and flags being waited for —
37
+ * which the folder name supplies anyway, from the torrent's file list, at no
38
+ * cost. The read continues asynchronously after this response and is shared
39
+ * with the refresh request, so a later answer can use the same container read.
40
+ */
41
+ const SIDECAR_HEADER_WAIT_MS = 3_000;
42
+
43
+ /** Subtitle codecs that can be converted to WebVTT (text-based). */
44
+ const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
45
+
46
+ /**
47
+ * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
48
+ * default disposition and (when present) the stream's `title` metadata line.
49
+ *
50
+ * @param {string} ffmpegOutput
51
+ * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
52
+ */
53
+ /**
54
+ * The bitrate a stream line states for that stream, in kbit/s, or null.
55
+ *
56
+ * @param {string} line
57
+ * @returns {number | null}
58
+ */
59
+ function kbpsFromStreamLine(line) {
60
+ const match = line.match(/,\s*(\d+)\s*kb\/s/);
61
+ const kbps = match ? Number(match[1]) : Number.NaN;
62
+ return Number.isFinite(kbps) && kbps > 0 ? kbps : null;
63
+ }
64
+
65
+ function parseStreams(ffmpegOutput) {
66
+ // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
67
+ // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
68
+ const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
69
+ const lines = inputSection.split(/\r?\n/);
70
+ const streams = [];
71
+ let current = null;
72
+ for (const line of lines) {
73
+ const streamMatch = line.match(
74
+ /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
75
+ );
76
+ if (streamMatch) {
77
+ current = {
78
+ streamIndex: Number(streamMatch[1]),
79
+ type: streamMatch[3].toLowerCase(),
80
+ codec: String(streamMatch[4]).toLowerCase(),
81
+ language: (streamMatch[2] ?? "").toLowerCase(),
82
+ title: "",
83
+ isDefault: /\(default\)/.test(line),
84
+ // What the stream line states for THIS stream, e.g. "…, 128 kb/s". A
85
+ // Matroska stream usually states none here and carries its rate as a
86
+ // statistics tag instead (`BPS`, read below). Never the file's total.
87
+ bitrateKbps: kbpsFromStreamLine(line)
88
+ };
89
+ streams.push(current);
90
+ continue;
91
+ }
92
+ if (current) {
93
+ const bpsMatch = line.match(/^\s+BPS(?:-[A-Za-z]+)?\s*:\s*(\d+)\s*$/);
94
+ if (bpsMatch && current.bitrateKbps === null) {
95
+ current.bitrateKbps = Math.round(Number(bpsMatch[1]) / 1000);
96
+ continue;
97
+ }
98
+ const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
99
+ if (titleMatch && current.title.length === 0) {
100
+ current.title = titleMatch[1].trim();
101
+ continue;
102
+ }
103
+ // A new top-level section (non-indented line) ends the stream's block.
104
+ if (!/^\s/.test(line)) {
105
+ current = null;
106
+ }
107
+ }
108
+ }
109
+ return streams;
110
+ }
111
+
112
+ /**
113
+ * Parse audio and video codec names from ffmpeg stderr output.
114
+ *
115
+ * @param {string} ffmpegOutput
116
+ * @returns {{ audioCodec: string, videoCodec: string }}
117
+ */
118
+ export function parseStreamCodecs(ffmpegOutput) {
119
+ const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
120
+ const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
121
+ // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
122
+ // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
123
+ const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
124
+ let videoWidth = 0;
125
+ let videoHeight = 0;
126
+ if (videoLineMatch) {
127
+ const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
128
+ if (dim) {
129
+ videoWidth = Number(dim[1]);
130
+ videoHeight = Number(dim[2]);
131
+ }
132
+ }
133
+ const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
134
+ const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
135
+ let durationSeconds = 0;
136
+ if (durationMatch) {
137
+ const value =
138
+ Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
139
+ durationSeconds = Number.isFinite(value) ? value : 0;
140
+ }
141
+ const streams = parseStreams(ffmpegOutput);
142
+ const audioTracks = streams
143
+ .filter((s) => s.type === "audio")
144
+ .map((s, i) => ({
145
+ // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
146
+ index: i,
147
+ streamIndex: s.streamIndex,
148
+ codec: s.codec,
149
+ language: s.language,
150
+ title: s.title,
151
+ isDefault: s.isDefault,
152
+ bitrateKbps: s.bitrateKbps
153
+ }));
154
+ const subtitleTracks = streams
155
+ .filter((s) => s.type === "subtitle")
156
+ .map((s, i) => ({
157
+ // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
158
+ index: i,
159
+ streamIndex: s.streamIndex,
160
+ codec: s.codec,
161
+ language: s.language,
162
+ title: s.title,
163
+ isDefault: s.isDefault,
164
+ // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
165
+ textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
166
+ }));
167
+ return {
168
+ audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
169
+ videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
170
+ container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
171
+ durationSeconds,
172
+ videoWidth,
173
+ videoHeight,
174
+ audioTracks,
175
+ subtitleTracks
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
181
+ * Times out after `timeoutMs` and returns empty strings on failure.
182
+ *
183
+ * @param {object} options
184
+ * @param {string} options.ffmpegBin
185
+ * @param {string} options.inputUrl
186
+ * @param {string} [options.userAgent=""]
187
+ * @param {number} [options.timeoutMs=8000]
188
+ * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
189
+ * Parsed banner fields plus the raw `stderr`, so the caller can derive the
190
+ * full media info (fps/startTime/HDR) without a second ffmpeg scan.
191
+ */
192
+ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
193
+ return new Promise((resolve) => {
194
+ const args = ["-hide_banner", "-loglevel", "info"];
195
+ if (typeof userAgent === "string" && userAgent.trim().length > 0) {
196
+ args.push("-user_agent", userAgent.trim());
197
+ }
198
+ // Decode a tiny slice of all streams (no per-stream -map, so video-only
199
+ // files probe correctly too). The ffmpeg banner that precedes decoding
200
+ // gives us audio/video codecs, the container format and the duration in a
201
+ // single pass.
202
+ args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
203
+
204
+ const ffmpeg = spawn(ffmpegBin, args, {
205
+ stdio: ["ignore", "ignore", "pipe"],
206
+ windowsHide: true
207
+ });
208
+ let stderr = "";
209
+ let settled = false;
210
+
211
+ const finish = (codecs) => {
212
+ if (settled) {
213
+ return;
214
+ }
215
+ settled = true;
216
+ resolve(codecs);
217
+ };
218
+
219
+ const timeoutId = setTimeout(() => {
220
+ if (!ffmpeg.killed) {
221
+ ffmpeg.kill("SIGTERM");
222
+ }
223
+ finish({ ...parseStreamCodecs(stderr), stderr });
224
+ }, timeoutMs);
225
+
226
+ ffmpeg.stderr.on("data", (chunk) => {
227
+ stderr += String(chunk);
228
+ });
229
+
230
+ ffmpeg.on("error", () => {
231
+ clearTimeout(timeoutId);
232
+ finish({ audioCodec: "", videoCodec: "", stderr: "" });
233
+ });
234
+
235
+ ffmpeg.on("exit", () => {
236
+ clearTimeout(timeoutId);
237
+ finish({ ...parseStreamCodecs(stderr), stderr });
238
+ });
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Resolve after a given number of milliseconds.
244
+ *
245
+ * @param {number} ms
246
+ * @returns {Promise<void>}
247
+ */
248
+ function delay(ms) {
249
+ return new Promise((resolve) => {
250
+ setTimeout(resolve, ms);
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Build the direct stream URL for a source file served by the local proxy.
256
+ *
257
+ * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
258
+ * @param {string} sourceKey
259
+ * @param {number} fileIndex
260
+ * @returns {string}
261
+ */
262
+ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
263
+ const directUrl = new URL("/stream", `${localBaseUrl}/`);
264
+ directUrl.searchParams.set("sourceKey", sourceKey);
265
+ directUrl.searchParams.set("fileIndex", String(fileIndex));
266
+ return directUrl.toString();
267
+ }
268
+
269
+ /**
270
+ * @typedef {Object} PlaybackPlan
271
+ * @property {"direct" | "hls"} mode
272
+ * @property {string} directUrl
273
+ * @property {string} reason - Human-readable explanation of the chosen mode.
274
+ * @property {string} audioCodec
275
+ * @property {string} videoCodec
276
+ * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
277
+ * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
278
+ * @property {number} videoWidth - Source coded width (0 if unknown).
279
+ * @property {number} videoHeight - Source coded height (0 if unknown).
280
+ * @property {boolean} audioTracksPending - At least one sidecar header timed out and can be refreshed later.
281
+ */
282
+
283
+ /**
284
+ * @typedef {Object} PlaybackPlannerOptions
285
+ * @property {string} ffmpegBin
286
+ * @property {boolean} transcodeAudioEnabled
287
+ * @property {string} localBaseUrl
288
+ * @property {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
289
+ * @property {import("../torrent/torrent-pool.js").TorrentPool} torrentPool
290
+ */
291
+
292
+ /**
293
+ * Create a playback planner that decides the optimal streaming mode for
294
+ * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
295
+ *
296
+ * @param {PlaybackPlannerOptions} options
297
+ * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan>, refreshAudioTracks: (params: { sourceKey: string, fileIndex: number }) => Promise<{ audioTracks: object[], pending: boolean }> }}
298
+ */
299
+ export function createPlaybackPlanner({
300
+ ffmpegBin,
301
+ transcodeAudioEnabled,
302
+ localBaseUrl,
303
+ sourceRegistry,
304
+ torrentPool,
305
+ // Optional. Reports what this host typically takes to produce a session's
306
+ // first segment. The browser needs it for the gap between "the file is
307
+ // downloaded" and "a segment exists": until now it assumed the pipeline
308
+ // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
309
+ expectedFirstSegmentMs,
310
+ expectedSessionCreateMs,
311
+ // What the torrent holds beside this picture — its dubs, its subtitle files,
312
+ // its contact sheets — grouped by whoever knows what a torrent contains. This
313
+ // layer answers about ONE file and must not read a torrent's file list.
314
+ sidecarsFromTorrent = null,
315
+ // What a file DECLARES about its own tracks, read from its header. Handed in
316
+ // rather than asked of the torrent pool: this layer used to call
317
+ // `torrentPool.getDeclaredAudioTracks` and friends, which is the media layer
318
+ // asking the torrent layer to parse a container on its behalf — the parse
319
+ // happened in the torrent thread and came back over the channel. It happens
320
+ // on this thread now, and what is passed here is the ordinary container read.
321
+ declaredTracksOf = null,
322
+ // Optional. Called once the file's edges are downloaded, so the keyframe
323
+ // index — which reads the same tail of the file — is fetched alongside the
324
+ // codec probe instead of after it. It goes straight to `KeyframeTables`,
325
+ // which owns that table: it used to be routed through the session manager,
326
+ // which held a second copy of it and a second reader for it.
327
+ warmKeyframeIndex,
328
+ // Optional. The heights this host could actually serve this source at, for
329
+ // both playback branches, so the quality menu is right from the moment the
330
+ // file is opened rather than from the moment an encoder exists.
331
+ predictOfferedHeights
332
+ }) {
333
+ /** @type {Map<string, PlaybackPlan>} */
334
+ const cache = new Map();
335
+ const pendingSidecarHeaders = new Map();
336
+ const declaredAudioReads = new Map();
337
+ /**
338
+ * Full media info parsed from the SAME probe that produced the plan, cached
339
+ * under the same key so a transcode session can reuse it instead of running
340
+ * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
341
+ * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
342
+ */
343
+ const mediaInfoCache = new Map();
344
+
345
+ /**
346
+ * Attach what this host currently measures itself taking to create a session
347
+ * and to produce a first segment.
348
+ *
349
+ * Read at RESPONSE time, deliberately. Both are medians of sessions that have
350
+ * already finished on this host, so at the moment a plan is BUILT the very
351
+ * first file opened after a restart has none and gets `null` — and the plan
352
+ * is then cached, so that file kept answering `null` for the life of the
353
+ * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
354
+ * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
355
+ * first segment in 21 479 ms. The figures existed; the plan could not carry
356
+ * them, and the browser's estimate fell back to its own guess in exactly the
357
+ * cold-start case the feature was built for.
358
+ *
359
+ * @param {PlaybackPlan} plan
360
+ * @returns {PlaybackPlan}
361
+ */
362
+ /**
363
+ * The probe's subtitle tracks, with `FlagDefault` read from the container
364
+ * instead of inferred from ffmpeg's banner.
365
+ *
366
+ * Best-effort by construction: a container that cannot be read this way, or a
367
+ * reading that does not line up with the probe, leaves the tracks as they
368
+ * were with `declaresDefault: false` — which the browser reads as "the file
369
+ * has no opinion", and then nothing is shown unasked.
370
+ *
371
+ * @param {object} torrent
372
+ * @param {number} fileIndex
373
+ * @param {object[]} subtitleTracks
374
+ * @returns {Promise<object[]>}
375
+ */
376
+ /**
377
+ * The files beside this picture that belong to it, in three groups.
378
+ *
379
+ * HANDED IN, because what a torrent contains is the torrent layer's to say
380
+ * and this is the media layer. It works the grouping out once and keeps it;
381
+ * it used to be worked out here on every call, and again on the warm-up's own
382
+ * path over the same list, so one opened film paired the same files several
383
+ * times and neither side could be sure of the other's answer.
384
+ *
385
+ * Absent, a file has no companions — which is what a proxy with no torrent
386
+ * behind it should answer, rather than failing.
387
+ *
388
+ * @param {object} torrent
389
+ * @param {number} fileIndex
390
+ * @returns {{ audio: object[], subtitles: object[], images: object[] }}
391
+ */
392
+ function sidecarsOf(torrent, fileIndex) {
393
+ return sidecarsFromTorrent?.(torrent ?? {}, fileIndex) ?? { audio: [], subtitles: [], images: [] };
394
+ }
395
+
396
+ async function withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks) {
397
+ if (subtitleTracks.length === 0 || typeof declaredTracksOf !== "function") {
398
+ return subtitleTracks.map((track) => ({ ...track, declaresDefault: false }));
399
+ }
400
+ let declared = [];
401
+ try {
402
+ declared = (await declaredTracksOf({ sourceKey, fileIndex })).filter((track) => track?.type === "subtitle");
403
+ } catch (error) {
404
+ logger.info(`subtitle defaults: the container could not be read (${error?.message ?? error})`);
405
+ }
406
+ const merged = Container.mergeSubtitleFlags(subtitleTracks, declared);
407
+ logger.info(
408
+ merged.aligned
409
+ ? "subtitle defaults: the container wrote FlagDefault on " +
410
+ `${merged.tracks.filter((track) => track.declaresDefault).length} of ${merged.tracks.length} ` +
411
+ `subtitle tracks, marking ${merged.tracks.filter((track) => track.declaresDefault && track.isDefault).length}`
412
+ : `subtitle defaults: using the probe's own flags — ${merged.reason}`
413
+ );
414
+ return merged.tracks;
415
+ }
416
+
417
+ /**
418
+ * Every soundtrack this file can be watched with, as one numbered list: its
419
+ * own tracks and the ones shipped as separate files beside it.
420
+ *
421
+ * Built here, in the plan, because the plan is what the viewer's menu is drawn
422
+ * from — so every playable track gets a stable number before the file opens.
423
+ * Sidecar header fields may arrive later; they enrich an existing numbered
424
+ * entry without changing the inventory length. It is also what
425
+ * the master playlist's rendition group is built from, so the number in the
426
+ * menu and the number in the `a/<n>/` address are the same number by
427
+ * construction rather than by agreement.
428
+ *
429
+ * @param {string} sourceKey
430
+ * @param {object} torrent
431
+ * @param {number} fileIndex
432
+ * @param {object[]} bannerAudioTracks - The probe's own audio streams.
433
+ * @returns {Promise<{ audioTracks: import("./audio-inventory.js").AudioInventoryEntry[], pendingFileIndexes: number[] }>}
434
+ */
435
+ async function declaredAudioOf(sourceKey, wantedFileIndex, label) {
436
+ if (typeof declaredTracksOf !== "function") {
437
+ return { tracks: [], complete: false, timedOut: false };
438
+ }
439
+ const readKey = `${sourceKey}:${wantedFileIndex}`;
440
+ let readPromise = declaredAudioReads.get(readKey);
441
+ if (!readPromise) {
442
+ readPromise = Promise.resolve()
443
+ .then(() => declaredTracksOf({ sourceKey, fileIndex: wantedFileIndex }))
444
+ .then((tracks) => ({
445
+ tracks: tracks
446
+ .filter((track) => track?.type === "audio")
447
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0)),
448
+ // ContainerOrchestrator leaves empty reads uncached because the file
449
+ // header may not have arrived yet. Only a non-empty table proves the
450
+ // read reached the container's track list.
451
+ complete: Array.isArray(tracks) && tracks.length > 0
452
+ }))
453
+ .catch((error) => {
454
+ logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
455
+ return { tracks: [], complete: false };
456
+ });
457
+ declaredAudioReads.set(readKey, readPromise);
458
+ readPromise.then(
459
+ () => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); },
460
+ () => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); }
461
+ );
462
+ }
463
+ let timer = null;
464
+ try {
465
+ return await Promise.race([
466
+ readPromise,
467
+ new Promise((resolve) => {
468
+ timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
469
+ timer.unref?.();
470
+ })
471
+ ]).then((tracks) => {
472
+ if (tracks === null) {
473
+ logger.info(
474
+ `audio tracks: "${label}" did not answer within ${SIDECAR_HEADER_WAIT_MS / 1000}s — ` +
475
+ "offered without what its header would say"
476
+ );
477
+ return { tracks: [], complete: false, timedOut: true };
478
+ }
479
+ return {
480
+ tracks: Array.isArray(tracks?.tracks) ? tracks.tracks : [],
481
+ complete: tracks?.complete === true,
482
+ timedOut: false
483
+ };
484
+ });
485
+ } catch (error) {
486
+ logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
487
+ return { tracks: [], complete: false, timedOut: false };
488
+ } finally {
489
+ if (timer !== null) clearTimeout(timer);
490
+ }
491
+ }
492
+
493
+ async function buildInventory(sourceKey, torrent, fileIndex, bannerAudioTracks) {
494
+ const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
495
+ // The picture's own tracks: ffmpeg numbers them, the container declares what
496
+ // they are. Both readings, lined up and checked — see `audio-inventory.js`.
497
+ let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
498
+ if (banner.length > 0) {
499
+ // The picture's head is already downloaded — the codec probe just read it
500
+ // — so this is a parse and not a wait, but it is bounded like the rest.
501
+ const { tracks: declared } = await declaredAudioOf(sourceKey, fileIndex, "the picture");
502
+ const merged = Container.mergeAudioFlags(banner, declared);
503
+ embedded = merged.tracks;
504
+ logger.info(
505
+ merged.aligned
506
+ ? `audio tracks: the container describes all ${merged.tracks.length}` +
507
+ `${merged.tracks.some((track) => track.isCommentary) ? ", one of them commentary" : ""}` +
508
+ `${merged.tracks.some((track) => track.isVisualImpaired) ? ", one of them described" : ""}`
509
+ : `audio tracks: using the probe's own fields — ${merged.reason}`
510
+ );
511
+ }
512
+
513
+ const sidecarFiles = sidecarsOf(torrent, fileIndex);
514
+ // All of them at once. They are separate files with separate headers, and
515
+ // read one after another the waits add up on the path to the first frame.
516
+ const sidecars = await Promise.all(
517
+ sidecarFiles.audio.map(async (file) => ({
518
+ file,
519
+ // A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
520
+ // read, so nothing is asked of the swarm for it at all.
521
+ ...(file.declaresTracks
522
+ ? await declaredAudioOf(sourceKey, file.fileIndex, file.name)
523
+ : { tracks: [], timedOut: false })
524
+ }))
525
+ );
526
+ const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
527
+ if (sidecars.length > 0) {
528
+ logger.info(
529
+ `audio tracks: ${sidecars.length} file(s) beside the picture carry sound — ` +
530
+ inventory
531
+ .filter((entry) => entry.kind === "sidecar")
532
+ .map((entry) =>
533
+ `a:${entry.index}=${entry.folders.join("/") || "."}/${entry.fileName}` +
534
+ `#${entry.sourceTrackIndex}${entry.codec ? `(${entry.codec})` : ""}`
535
+ )
536
+ .join(" ")
537
+ );
538
+ }
539
+ return {
540
+ audioTracks: inventory,
541
+ pendingFileIndexes: sidecars.filter((sidecar) => sidecar.timedOut).map((sidecar) => sidecar.file.fileIndex)
542
+ };
543
+ }
544
+
545
+ async function refreshAudioTracks({ sourceKey, fileIndex }) {
546
+ const cacheKey = `${sourceKey}:${fileIndex}`;
547
+ const plan = cache.get(cacheKey);
548
+ if (!plan) {
549
+ return { audioTracks: Array.isArray(plan?.audioTracks) ? plan.audioTracks : [], pending: false };
550
+ }
551
+ const pendingIndexes = new Set(pendingSidecarHeaders.get(cacheKey) ?? []);
552
+ if (pendingIndexes.size === 0 || typeof declaredTracksOf !== "function") {
553
+ return { audioTracks: plan.audioTracks, pending: false };
554
+ }
555
+ const sourceRecord = sourceRegistry.get(sourceKey);
556
+ if (!sourceRecord) {
557
+ const error = new Error("Source key was not found.");
558
+ error.code = "SOURCE_NOT_FOUND";
559
+ throw error;
560
+ }
561
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
562
+ const sidecarFiles = sidecarsOf(torrent, fileIndex).audio;
563
+ for (const sidecar of sidecarFiles) {
564
+ if (!pendingIndexes.has(sidecar.fileIndex)) continue;
565
+ const read = await declaredAudioOf(sourceKey, sidecar.fileIndex, sidecar.name);
566
+ if (read.timedOut) continue;
567
+ if (read.tracks.length === 0) {
568
+ if (read.complete) pendingIndexes.delete(sidecar.fileIndex);
569
+ continue;
570
+ }
571
+ const audio = read.tracks
572
+ .filter((track) => track?.type === "audio")
573
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0));
574
+ if (audio.length === 0) continue;
575
+ if (!enrichAudioInventoryFromSidecar(plan.audioTracks, sidecar, audio[0])) {
576
+ pendingIndexes.delete(sidecar.fileIndex);
577
+ continue;
578
+ }
579
+ // Keep the published inventory length and every address stable for this
580
+ // session. A late header enriches the offered first track; adding tracks
581
+ // would require replacing the HLS master playlist already loaded by the
582
+ // browser.
583
+ pendingIndexes.delete(sidecar.fileIndex);
584
+ if (audio.length > 1) {
585
+ logger.info(
586
+ `audio tracks: late header for "${sidecar.name}" describes ${audio.length} tracks; ` +
587
+ "the active inventory keeps its published track count"
588
+ );
589
+ }
590
+ }
591
+ pendingSidecarHeaders.set(cacheKey, [...pendingIndexes]);
592
+ plan.audioTracksPending = pendingIndexes.size > 0;
593
+ return { audioTracks: plan.audioTracks, pending: pendingIndexes.size > 0 };
594
+ }
595
+
596
+ function withHostTimings(plan) {
597
+ const withOffer = {
598
+ ...plan,
599
+ expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
600
+ expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
601
+ // Answered here for the same reason as the two above: a plan is cached for
602
+ // the life of the process, and what this host will serve a file at is not.
603
+ // It starts as a prediction from the startup benchmarks and is replaced by
604
+ // what an encoder running on this very source turns out to cost — frozen
605
+ // into the cache, every later open of the file would hand the browser the
606
+ // first guess again and undo that. This is the 2.9.106 defect exactly.
607
+ offeredHeights: plan.mediaInfoForOffer
608
+ ? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
609
+ : null,
610
+ // The description the rest of the pool answers by arithmetic. Carried
611
+ // on every plan, not only on a refusal here: the output opened next can
612
+ // still be refused for want of a place on this machine (roadmap item 97,
613
+ // step 14), and the page then asks the pool the same question before
614
+ // anything plays — which it can only do with this in hand.
615
+ mediaInfoForOffer: plan.mediaInfoForOffer
616
+ };
617
+ // Refused rather than served badly. Both lists empty means this machine
618
+ // cannot sustain this file at ANY height — not even by copying the picture,
619
+ // which costs no encoder at all — so a session made here would produce a
620
+ // slideshow and take the swarm and the processor from whoever is already
621
+ // watching. Field 2026-08-28: five sessions on one file put every rung at
622
+ // 0.04x of realtime and the viewer watched one before the process was
623
+ // killed. The viewer is told why, which is a different thing from a spinner
624
+ // that never ends.
625
+ const offer = withOffer.offeredHeights;
626
+ if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
627
+ withOffer.cannotServe =
628
+ "This proxy cannot keep up with this file at any quality right now.";
629
+ // The description travels with the plan (above). It is what lets the
630
+ // browser ask the rest of the pool the same question without anybody
631
+ // else adding the torrent, fetching a byte or running ffmpeg — the
632
+ // expensive half of finding out what this file IS has been paid here,
633
+ // once. Everyone else answers by arithmetic against their own startup
634
+ // benchmarks.
635
+ }
636
+ return withOffer;
637
+ }
638
+
639
+ return {
640
+ /**
641
+ * Media info the planner already probed for this file, or `null`. Lets the
642
+ * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
643
+ *
644
+ * @param {{ sourceKey: string, fileIndex: number }} params
645
+ * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
646
+ */
647
+ /**
648
+ * The audio tracks this file was probed to have, or an empty list. The
649
+ * master playlist publishes one rendition per track, and the inventory is
650
+ * already here — probing again for it would be a second scan of a file the
651
+ * proxy is in the middle of serving.
652
+ *
653
+ * @param {{ sourceKey: string, fileIndex: number }} params
654
+ * @returns {object[]}
655
+ */
656
+ getCachedAudioTracks({ sourceKey, fileIndex }) {
657
+ const plan = cache.get(`${sourceKey}:${fileIndex}`);
658
+ return Array.isArray(plan?.audioTracks) ? plan.audioTracks : [];
659
+ },
660
+
661
+ refreshAudioTracks,
662
+
663
+ getCachedMediaInfo({ sourceKey, fileIndex }) {
664
+ return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
665
+ },
666
+
667
+ /**
668
+ * Return the playback plan for the given source file.
669
+ * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
670
+ * when the source or file cannot be located.
671
+ *
672
+ * When the file header has not downloaded yet (cold torrent, peers still
673
+ * connecting) the codec probe cannot succeed. Rather than block the HTTP
674
+ * response until it can, the planner prioritises the header, probes for at
675
+ * most `maxWaitMs`, and if still undetectable returns a plan flagged
676
+ * `pending: true` (NOT cached). The caller polls again — each call keeps the
677
+ * header prioritised and downloading — until a real plan comes back. This
678
+ * avoids a single long request racing the transport's request timeout.
679
+ *
680
+ * @param {object} params
681
+ * @param {string} params.sourceKey
682
+ * @param {number} params.fileIndex
683
+ * @param {string} [params.userAgent=""]
684
+ * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
685
+ * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
686
+ */
687
+ async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000, background = false }) {
688
+ const cacheKey = `${sourceKey}:${fileIndex}`;
689
+ const cached = cache.get(cacheKey);
690
+ if (cached) {
691
+ return withHostTimings(cached);
692
+ }
693
+ // Where the time before playback goes. `cold-start` already breaks down
694
+ // everything from the transcode-session request onwards, but the plan
695
+ // runs BEFORE that and was a single opaque wait: a field session spent
696
+ // 5.7 s here on a torrent already in the store, with the codec probe
697
+ // cached, and nothing said which part of it was slow.
698
+ const planEntryMs = Date.now();
699
+ let torrentReadyMs = 0;
700
+ let edgesReadyMs = 0;
701
+
702
+ const sourceRecord = sourceRegistry.get(sourceKey);
703
+ if (!sourceRecord) {
704
+ const error = new Error("Source key was not found.");
705
+ error.code = "SOURCE_NOT_FOUND";
706
+ throw error;
707
+ }
708
+
709
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
710
+ torrentReadyMs = Date.now() - planEntryMs;
711
+ const file = torrent.files[fileIndex];
712
+ if (!file) {
713
+ const error = new Error("File index was not found in torrent.");
714
+ error.code = "FILE_NOT_FOUND";
715
+ throw error;
716
+ }
717
+
718
+ const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
719
+ if (!transcodeAudioEnabled) {
720
+ const plan = {
721
+ mode: "direct",
722
+ directUrl,
723
+ reason: "transcode-disabled",
724
+ audioCodec: "",
725
+ videoCodec: "",
726
+ container: "",
727
+ durationSeconds: 0,
728
+ videoWidth: 0,
729
+ videoHeight: 0,
730
+ audioTracks: [],
731
+ audioTracksPending: false,
732
+ subtitleTracks: []
733
+ };
734
+ cache.set(cacheKey, plan);
735
+ return withHostTimings(plan);
736
+ }
737
+
738
+ // Pre-fetch file edges (head + tail), then probe — retrying while the
739
+ // file header is still downloading. In a multi-file torrent the pieces
740
+ // for a given file arrive unevenly, so the first probe can return empty
741
+ // codecs. A transient empty probe must NOT be cached: otherwise the wrong
742
+ // plan (file treated as directly playable) sticks permanently for this
743
+ // file, and an unsupported codec like xvid gets copied → black video.
744
+ // Awaited in the literal sense: this answer cannot be given until the
745
+ // file has said what is in it, and a person is watching a loading screen
746
+ // for as long as that takes.
747
+ await torrentPool.prefetchFileEdges(torrent, fileIndex, {
748
+ awaited: !background,
749
+ timeoutMs: background ? 10_000 : 300_000
750
+ });
751
+ edgesReadyMs = Date.now() - planEntryMs;
752
+ // The keyframe index reads the tail of the file, which the probe has just
753
+ // waited for as well. Started here it overlaps the probe instead of
754
+ // following the whole plan — worth 311-430 ms of the time before the
755
+ // first segment. Fire and forget: a session that finds the table
756
+ // unanswered joins this very read rather than starting a second one.
757
+ if (!background) {
758
+ warmKeyframeIndex?.({ sourceKey, fileIndex, logName: file.name });
759
+ }
760
+ let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
761
+ const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
762
+ let attempt = 0;
763
+ while (
764
+ probe.audioCodec.length === 0 &&
765
+ probe.videoCodec.length === 0 &&
766
+ Date.now() < probeDeadline
767
+ ) {
768
+ attempt += 1;
769
+ await delay(Math.min(3_000, 500 + attempt * 250));
770
+ await torrentPool.prefetchFileEdges(torrent, fileIndex, {
771
+ awaited: !background,
772
+ timeoutMs: background ? 10_000 : 300_000
773
+ });
774
+ probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
775
+ }
776
+ const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
777
+ const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
778
+ logger.info(
779
+ `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
780
+ `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
781
+ `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
782
+ `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
783
+ );
784
+
785
+ // The picture's own facts come from two readings and only one was ever
786
+ // used: every figure the encode is planned from came from ffmpeg's
787
+ // banner, while the `VideoTrack` the container declares was read and used
788
+ // for nothing but a line in the log.
789
+ let declaredVideo = null;
790
+ if (typeof declaredTracksOf === "function") {
791
+ try {
792
+ // One, because ffmpeg's `0:v:0` is what everything downstream is built
793
+ // on and a second video stream is a cover image far more often than a
794
+ // second film.
795
+ declaredVideo = (await declaredTracksOf({ sourceKey, fileIndex }))
796
+ .filter((track) => track?.type === "video")
797
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))[0] ?? null;
798
+ } catch (error) {
799
+ logger.info(`video track: could not be read (${error?.message ?? error})`);
800
+ }
801
+ }
802
+ // `mode` is advisory only (audio-codec based). The browser makes the
803
+ // authoritative decision independently per stream via canPlayType /
804
+ // mediaCapabilities, transcoding only what it cannot play.
805
+ const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
806
+ // The two readings of the picture, lined up. Which one answers is decided
807
+ // per field by what each IS — see `Container.mergeVideoFacts`.
808
+ const videoFacts = Container.mergeVideoFacts(
809
+ {
810
+ width: videoWidth,
811
+ height: videoHeight,
812
+ fps: parseFfmpegVideoFps(probe.stderr),
813
+ isHdr: parseFfmpegHdr(probe.stderr),
814
+ bitDepth: parseFfmpegBitDepth(probe.stderr)
815
+ },
816
+ declaredVideo
817
+ );
818
+ if (videoFacts.disagreements.length > 0) {
819
+ logger.info(
820
+ `video track: the file and the probe disagree — ${videoFacts.disagreements.join("; ")}; ` +
821
+ "the size and frame rate are the probe's, the bit depth and HDR the file's"
822
+ );
823
+ }
824
+ const sidecars = sidecarsOf(torrent, fileIndex);
825
+ const inventory = await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []);
826
+ const plan = {
827
+ mode: requiresTranscode ? "hls" : "direct",
828
+ directUrl,
829
+ reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
830
+ audioCodec,
831
+ videoCodec,
832
+ container,
833
+ durationSeconds,
834
+ // Source coded resolution — drives the browser's manual quality menu
835
+ // (list of forced resolutions <= source). 0 when unknown.
836
+ videoWidth: videoFacts.width ?? 0,
837
+ videoHeight: videoFacts.height ?? 0,
838
+ // Full track inventory for the browser's audio/subtitle menus. The audio
839
+ // half spans the picture's own tracks AND the soundtracks shipped as
840
+ // files beside it, under one numbering — see `buildInventory`.
841
+ audioTracks: inventory.audioTracks,
842
+ audioTracksPending: inventory.pendingFileIndexes.length > 0,
843
+ subtitleTracks: await withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks ?? []),
844
+ // The files BESIDE this picture that belong to it, and what each one's
845
+ // own path says about the track in it. Both answers are made here, by
846
+ // one grammar, because the browser used to make them again: it paired
847
+ // with a looser rule and read the names with a stricter one, and nothing
848
+ // compared the two. Measured 2026-09-04 over 115 real torrents — 1249
849
+ // video files, ten pairings differing — and the difference reached the
850
+ // viewer as a subtitle track offered but never warmed.
851
+ //
852
+ // The soundtracks are NOT repeated here: they are already in
853
+ // `audioTracks`, under the one flat numbering the browser addresses them
854
+ // by. What this adds is the two groups that had no place in the plan at
855
+ // all.
856
+ sidecarSubtitles: sidecars.subtitles,
857
+ sidecarImages: sidecars.images,
858
+ // Both host timings are filled in by `withHostTimings` on the way out,
859
+ // never here: read at build time they would be frozen into the cached
860
+ // plan, which is the bug fixed in 2.9.106.
861
+ expectedFirstSegmentMs: null,
862
+ expectedSessionCreateMs: null,
863
+ offeredHeights: null,
864
+ // What the offer is computed FROM, kept on the cached plan so the offer
865
+ // itself can be recomputed on every response. The figures are the
866
+ // probe's own and never change for a file; the answer derived from them
867
+ // does, as the host learns what this source costs. Stripped on the way
868
+ // out — it is not part of the plan the browser is given.
869
+ mediaInfoForOffer: {
870
+ width: videoFacts.width,
871
+ height: videoFacts.height,
872
+ fps: videoFacts.fps,
873
+ bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
874
+ // Which family of the decode measurement prices this source. A video
875
+ // that has to be re-encoded is one the browser could not play, so it
876
+ // is usually NOT H.264, and H.264 constants are wrong for it.
877
+ codec: videoCodec,
878
+ bitDepth: videoFacts.bitDepth,
879
+ // Which file this is, so the offer can be answered from what an
880
+ // encoder has already learned about THIS source rather than from the
881
+ // startup clips — the same correction a live session applies.
882
+ sourceKey,
883
+ fileIndex
884
+ }
885
+ };
886
+ // Only cache a plan whose codecs were actually detected. An empty probe is
887
+ // a "header not downloaded yet" signal, not a valid result — caching it
888
+ // would permanently mis-plan the file. In that case flag the plan
889
+ // `pending` so the caller polls again (the header keeps downloading,
890
+ // prioritised by the prefetch above).
891
+ if (codecsDetected) {
892
+ cache.set(cacheKey, plan);
893
+ pendingSidecarHeaders.set(cacheKey, inventory.pendingFileIndexes);
894
+ // Cache the full media info from THIS probe's banner (same helpers the
895
+ // session manager uses) so createSession can skip its own probe.
896
+ mediaInfoCache.set(cacheKey, {
897
+ // The codecs, because the session manager asks this cache which
898
+ // tracks the output will carry — and they were never stored here. It
899
+ // read `videoCodec`/`audioCodec` off an object that has only ever had
900
+ // dimensions and duration, got `undefined` for both, and declared
901
+ // `{video: false, audio: false}` for EVERY session since the check was
902
+ // written. Measured 2026-08-11: `declared tracks video=false
903
+ // audio=false`, which left the browser unable to tell "this file has
904
+ // no video" from "the video was lost on the way", and left the init
905
+ // guard expecting zero tracks and therefore accepting any header.
906
+ videoCodec: plan.videoCodec,
907
+ audioCodec: plan.audioCodec,
908
+ durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
909
+ width: videoFacts.width,
910
+ height: videoFacts.height,
911
+ bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
912
+ fps: videoFacts.fps,
913
+ startTime: parseFfmpegStartTimeSeconds(probe.stderr),
914
+ isHdr: videoFacts.isHdr,
915
+ bitDepth: videoFacts.bitDepth
916
+ });
917
+ // Warm the file-body start for the transcode session that follows.
918
+ // Fire-and-forget: never delays the plan response.
919
+ void torrentPool
920
+ .prefetchFileEdges(torrent, fileIndex, {
921
+ headBytes: BODY_PREFETCH_BYTES,
922
+ tailBytes: 0,
923
+ timeoutMs: 60_000
924
+ })
925
+ .catch(() => {});
926
+ return withHostTimings(plan);
927
+ }
928
+ return withHostTimings({ ...plan, pending: true });
929
+ }
930
+ };
931
+ }