@torrent-tv/proxy 2.87.3 → 2.87.4

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