@torrent-tv/proxy 2.89.7 → 2.89.8

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 (497) 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 +358 -338
  13. package/CHANGELOG.md +2340 -2335
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -19
  19. package/assets/calibration/audio/manifest.json +98 -98
  20. package/assets/diagnostics/sctpstate.gdb +31 -31
  21. package/bin/cli.js +684 -684
  22. package/bin/thread-pool.js +30 -30
  23. package/biome.json +412 -412
  24. package/docs/browser-proxy-contract.md +155 -155
  25. package/docs/container-architecture.md +273 -273
  26. package/docs/download-architecture.md +261 -261
  27. package/docs/encode-architecture.md +765 -765
  28. package/docs/encode-run-state.md +96 -96
  29. package/docs/logs.md +97 -97
  30. package/docs/playback-start-forecast.md +113 -113
  31. package/docs/socks5-egress-research.md +114 -114
  32. package/docs/storage-architecture.md +273 -273
  33. package/knip.json +14 -14
  34. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  35. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  36. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  37. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  38. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  39. package/openspec/changes/binary-distribution/proposal.md +53 -53
  40. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  41. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  42. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  43. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  44. package/openspec/changes/cold-start/design.md +98 -98
  45. package/openspec/changes/cold-start/proposal.md +62 -62
  46. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  47. package/openspec/changes/cold-start/tasks.md +48 -48
  48. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  49. package/openspec/changes/disk-cap/proposal.md +37 -37
  50. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  51. package/openspec/changes/disk-cap/tasks.md +19 -19
  52. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  53. package/openspec/changes/proxy-observability/design.md +38 -38
  54. package/openspec/changes/proxy-observability/proposal.md +49 -49
  55. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  56. package/openspec/changes/proxy-observability/tasks.md +21 -21
  57. package/openspec/changes/seek-debounce/design.md +118 -118
  58. package/openspec/changes/seek-debounce/proposal.md +59 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  60. package/openspec/changes/seek-debounce/tasks.md +33 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  62. package/openspec/changes/subtitle-language/proposal.md +50 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  64. package/openspec/changes/subtitle-language/tasks.md +26 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  66. package/openspec/changes/track-selection/design.md +47 -47
  67. package/openspec/changes/track-selection/proposal.md +45 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  69. package/openspec/changes/track-selection/tasks.md +20 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  71. package/openspec/changes/transcode-quality/proposal.md +51 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  73. package/openspec/changes/transcode-quality/tasks.md +63 -63
  74. package/openspec/config.yaml +38 -38
  75. package/package.json +51 -50
  76. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  77. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  78. package/research/double-spawn-2026-09-10.md +171 -171
  79. package/research/handover-2026-09-13.md +209 -209
  80. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  81. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  82. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  83. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  84. package/routes/api/client-logs/post.js +86 -86
  85. package/routes/api/delivery-sink/get.js +72 -72
  86. package/routes/api/link-probe/get.js +51 -51
  87. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  88. package/routes/api/playback-plan/post.js +53 -53
  89. package/routes/api/sources/files/get.js +106 -106
  90. package/routes/api/sources/post.js +36 -36
  91. package/routes/api/sources/stats/get.js +132 -132
  92. package/routes/api/sources/warm/post.js +217 -217
  93. package/routes/api/subtitles/get.js +230 -230
  94. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  95. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  96. package/routes/api/transcode-sessions/post.js +178 -178
  97. package/routes/api/transcode-sessions/progress/get.js +27 -27
  98. package/routes/api/transcode-sessions/release/post.js +41 -41
  99. package/routes/api/transcode-sessions/seek/post.js +63 -63
  100. package/routes/health/get.js +13 -13
  101. package/routes/healthz/get.js +13 -13
  102. package/routes/stream/get.js +416 -416
  103. package/routes/transcode/audio-file/get.js +64 -64
  104. package/routes/transcode/audio-warm/get.js +95 -95
  105. package/routes/transcode/session-file/get.js +52 -52
  106. package/routes/transcode/variant-file/get.js +95 -95
  107. package/routes/transcode/variant-warm/get.js +84 -84
  108. package/scripts/build-audio-samples.mjs +31 -31
  109. package/scripts/render-run-graph.js +173 -173
  110. package/server.js +748 -748
  111. package/services/encode/CoverageMap.js +428 -428
  112. package/services/encode/CushionReport.js +345 -345
  113. package/services/encode/EncodeAdmission.js +376 -376
  114. package/services/encode/EncodeOrchestrator.js +1192 -1192
  115. package/services/encode/EncodePlan.js +1368 -1368
  116. package/services/encode/EncodeRun.js +36 -12
  117. package/services/encode/Encoder.js +142 -142
  118. package/services/encode/EncoderSelection.js +38 -38
  119. package/services/encode/LocalObservations.js +359 -359
  120. package/services/encode/NvencEncoder.js +50 -50
  121. package/services/encode/OutputOpening.js +784 -784
  122. package/services/encode/OutputTimes.js +579 -579
  123. package/services/encode/QsvEncoder.js +58 -58
  124. package/services/encode/Renditions.js +2283 -2283
  125. package/services/encode/RunClock.js +87 -87
  126. package/services/encode/RunProgress.js +79 -79
  127. package/services/encode/SegmentDemand.js +95 -95
  128. package/services/encode/SoftwareEncoder.js +121 -121
  129. package/services/encode/V4l2m2mEncoder.js +53 -53
  130. package/services/encode/VaapiEncoder.js +66 -66
  131. package/services/encode/args.js +418 -418
  132. package/services/encode/audio-calibration.js +67 -67
  133. package/services/encode/audio-work-rate.js +15 -15
  134. package/services/encode/available-share.js +89 -89
  135. package/services/encode/calibration.js +361 -361
  136. package/services/encode/contention.js +128 -128
  137. package/services/encode/decode-cost-fit.js +363 -363
  138. package/services/encode/encode-exit.js +116 -116
  139. package/services/encode/encode-run-state.js +551 -551
  140. package/services/encode/encoder-readings.js +43 -43
  141. package/services/encode/fingerprint.js +322 -322
  142. package/services/encode/host-load.js +174 -174
  143. package/services/encode/hwaccel.js +1894 -1894
  144. package/services/encode/index.js +9 -9
  145. package/services/encode/learned-median.js +90 -90
  146. package/services/encode/output/EncodedOutput.js +57 -57
  147. package/services/encode/output/Output.js +131 -131
  148. package/services/encode/output/OutputCatalog.js +414 -414
  149. package/services/encode/output/OutputSpec.js +390 -390
  150. package/services/encode/output/Timeline.js +308 -308
  151. package/services/encode/output/cut-grid.js +107 -107
  152. package/services/encode/output/index.js +24 -24
  153. package/services/encode/output/ladder.js +32 -32
  154. package/services/encode/output/playlists.js +284 -284
  155. package/services/encode/output/rates.js +125 -125
  156. package/services/encode/output-key-format.js +21 -21
  157. package/services/encode/quality/EncodeCost.js +1043 -1043
  158. package/services/encode/quality/HostLoad.js +525 -525
  159. package/services/encode/quality/HostTimings.js +249 -249
  160. package/services/encode/quality/OutputQualityState.js +24 -24
  161. package/services/encode/quality/QualityController.js +758 -758
  162. package/services/encode/quality/QualityOffer.js +416 -416
  163. package/services/encode/quality/drain-threat.js +47 -47
  164. package/services/encode/quality/link-budget.js +337 -337
  165. package/services/encode/quality/output-format.js +319 -319
  166. package/services/encode/quality/serving-output.js +216 -216
  167. package/services/encode/quality/visible-rung.js +57 -57
  168. package/services/encode/run-budget.js +97 -97
  169. package/services/encode/run-command.js +757 -757
  170. package/services/encode/run-costs.js +155 -155
  171. package/services/encode/run-inputs.js +53 -53
  172. package/services/encode/segment-formats/fmp4.js +476 -476
  173. package/services/encode/segment-formats/init-compat.js +324 -324
  174. package/services/encode/segment-formats/mpegts.js +134 -134
  175. package/services/encode/start-stop-cost.js +178 -178
  176. package/services/encode/throughput.js +97 -97
  177. package/services/encode/torrent-cost.js +88 -88
  178. package/services/identity/installation-id.js +38 -38
  179. package/services/media/ContainerOrchestrator.js +202 -202
  180. package/services/media/KeyframeTables.js +356 -356
  181. package/services/media/SourceFile.js +298 -298
  182. package/services/media/SubtitleCues.js +592 -592
  183. package/services/media/SubtitleOrchestrator.js +167 -167
  184. package/services/media/audio-inventory.js +299 -299
  185. package/services/media/container/AviContainer.js +268 -268
  186. package/services/media/container/Container.js +632 -632
  187. package/services/media/container/ContainerFactory.js +38 -38
  188. package/services/media/container/KeyframeTable.js +272 -272
  189. package/services/media/container/MatroskaContainer.js +1031 -1031
  190. package/services/media/container/Mp4Container.js +1354 -1354
  191. package/services/media/container/SubtitleFileContainer.js +339 -339
  192. package/services/media/container/ebml-reader.js +159 -159
  193. package/services/media/container/ebml-stream.js +174 -174
  194. package/services/media/container/matroska-clusters.js +1165 -1165
  195. package/services/media/container/unavailable.js +77 -77
  196. package/services/media/ffmpeg-banner.js +260 -260
  197. package/services/media/keyframe-probe.js +157 -157
  198. package/services/media/media-info-probe.js +93 -93
  199. package/services/media/playback-planner.js +931 -931
  200. package/services/media/tracks/AudioTrack.js +143 -143
  201. package/services/media/tracks/ContainerTrack.js +84 -84
  202. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  203. package/services/media/tracks/SubtitleTrack.js +38 -38
  204. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  205. package/services/media/tracks/VideoTrack.js +33 -33
  206. package/services/media/tracks/audio-bound.js +233 -233
  207. package/services/media/tracks/index.js +15 -15
  208. package/services/media/tracks/language-detect.js +174 -174
  209. package/services/server/OutputLifecycle.js +418 -418
  210. package/services/server/SegmentServing.js +1152 -1152
  211. package/services/server/ViewerRequests.js +785 -785
  212. package/services/server/WaitLedger.js +142 -142
  213. package/services/server/controllers/PlaybackController.js +40 -40
  214. package/services/server/controllers/SubtitleController.js +109 -109
  215. package/services/server/http-responses.js +60 -60
  216. package/services/server/transcode-session-files.js +325 -325
  217. package/services/server/wire-outputs.js +1029 -1029
  218. package/services/storage/Diagnostics.js +202 -202
  219. package/services/storage/MachineBudget.js +235 -235
  220. package/services/storage/allowance.js +107 -107
  221. package/services/storage/core-dumps.js +106 -106
  222. package/services/storage/files/CompletedFiles.js +391 -391
  223. package/services/storage/files/piece-from-whole-file.js +118 -118
  224. package/services/storage/free.js +71 -71
  225. package/services/storage/keep.js +48 -48
  226. package/services/storage/machine-memory.js +56 -56
  227. package/services/storage/memory-report.js +575 -575
  228. package/services/storage/piece-store/piece-disk-store.js +539 -539
  229. package/services/storage/piece-store/piece-lru.js +495 -495
  230. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  231. package/services/storage/returns.js +103 -103
  232. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  233. package/services/storage/wire.js +175 -175
  234. package/services/torrent/Contents.js +382 -382
  235. package/services/torrent/demand/DemandRegister.js +202 -202
  236. package/services/torrent/demand/Urgency.js +137 -137
  237. package/services/torrent/demand/Window.js +118 -118
  238. package/services/torrent/demand/index.js +8 -8
  239. package/services/torrent/demand/pieces.js +140 -140
  240. package/services/torrent/download/SwarmSelection.js +386 -386
  241. package/services/torrent/download/registry.js +102 -102
  242. package/services/torrent/download/withdraw-claim.js +80 -80
  243. package/services/torrent/episode-naming.js +182 -182
  244. package/services/torrent/files.js +455 -455
  245. package/services/torrent/naming.js +619 -619
  246. package/services/torrent/piece-store-of.js +29 -29
  247. package/services/torrent/supply-margin.js +264 -264
  248. package/services/torrent/torrent-pool.js +2688 -2688
  249. package/services/torrent/worker/background-fill.js +213 -213
  250. package/services/torrent/worker/channel.js +271 -271
  251. package/services/torrent/worker/client.js +836 -836
  252. package/services/torrent/worker/fastest-wires.js +342 -342
  253. package/services/torrent/worker/handle-state.js +23 -23
  254. package/services/torrent/worker/held-bytes.js +231 -231
  255. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  256. package/services/torrent/worker/piece-reader.js +1078 -1078
  257. package/services/torrent/worker/pool-adapter.js +371 -371
  258. package/services/torrent/worker/protocol.js +184 -184
  259. package/services/torrent/worker/resume-warm.js +107 -107
  260. package/services/torrent/worker/webrtc-shim.js +219 -219
  261. package/services/torrent/worker/worker.js +1137 -1137
  262. package/services/transport/data-channel-handler.js +1373 -1373
  263. package/services/transport/delivery-probe.js +842 -842
  264. package/services/transport/health-collector.js +53 -53
  265. package/services/transport/nat-classifier.js +231 -231
  266. package/services/transport/packet-witness.js +808 -808
  267. package/services/transport/port-mapper.js +235 -235
  268. package/services/transport/registry-api.js +81 -81
  269. package/services/transport/tunnel-client.js +447 -447
  270. package/services/transport/usrsctp-state.js +175 -175
  271. package/services/transport/webrtc-manager.js +484 -484
  272. package/services/viewer/Assignments.js +340 -340
  273. package/services/viewer/PriorityMap.js +421 -421
  274. package/services/viewer/PriorityOrchestrator.js +325 -325
  275. package/services/viewer/Viewer.js +691 -691
  276. package/services/viewer/Viewers.js +368 -368
  277. package/services/viewer/active-output.js +37 -37
  278. package/services/viewer/audio-start.js +46 -46
  279. package/services/viewer/buffer-trend.js +81 -81
  280. package/services/viewer/choices.js +647 -647
  281. package/services/viewer/media-time.js +355 -355
  282. package/services/viewer/playback-readiness.js +635 -635
  283. package/services/viewer/positions.js +126 -126
  284. package/services/viewer/report-intake.js +46 -46
  285. package/store/source-registry.js +71 -71
  286. package/test/asset-paths.test.js +28 -28
  287. package/test/audio-buffer-continuity.test.js +77 -77
  288. package/test/audio-calibration.test.js +23 -23
  289. package/test/audio-inventory.test.js +212 -212
  290. package/test/audio-track-bitrate.test.js +40 -40
  291. package/test/auto-quality-step.test.js +762 -762
  292. package/test/available-share.test.js +76 -76
  293. package/test/background-fill.test.js +190 -190
  294. package/test/client-log-file.test.js +110 -110
  295. package/test/coalesce.test.js +39 -39
  296. package/test/completed-files.test.js +115 -115
  297. package/test/concurrent-cost.test.js +148 -148
  298. package/test/container-media-info.test.js +228 -228
  299. package/test/contention.test.js +76 -76
  300. package/test/core-dumps.test.js +57 -57
  301. package/test/coverage-follows-the-disk.test.js +233 -228
  302. package/test/coverage-map.test.js +195 -195
  303. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  304. package/test/cut-times-timeline.test.js +65 -65
  305. package/test/cuts-follow-published-grid.test.js +109 -109
  306. package/test/data-channel-frame.test.js +63 -63
  307. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  308. package/test/dead-torrent-handle.test.js +52 -52
  309. package/test/declared-rates.test.js +116 -116
  310. package/test/declared-tracks.test.js +43 -43
  311. package/test/decode-cost-fit.test.js +202 -202
  312. package/test/decode-cost.test.js +475 -475
  313. package/test/delivery-probe.test.js +393 -393
  314. package/test/demand-register.test.js +206 -206
  315. package/test/dht-bootstrap.test.js +72 -72
  316. package/test/drain-threat.test.js +65 -65
  317. package/test/duplicate-add.test.js +48 -48
  318. package/test/encode-admission.test.js +146 -146
  319. package/test/encode-cost.test.js +275 -275
  320. package/test/encode-exit.test.js +111 -111
  321. package/test/encode-orchestrator.test.js +426 -426
  322. package/test/encode-plan-viewers.test.js +725 -725
  323. package/test/encode-plan.test.js +545 -545
  324. package/test/encode-run-state.test.js +314 -314
  325. package/test/encode-run.test.js +20 -0
  326. package/test/encode-slope.test.js +46 -46
  327. package/test/encoded-output.test.js +39 -39
  328. package/test/encoder-kinds.test.js +122 -122
  329. package/test/encoder-readings.test.js +68 -68
  330. package/test/episode-naming.test.js +131 -131
  331. package/test/every-claimant-is-told-its-share.test.js +152 -152
  332. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  333. package/test/failed-start-breaker.test.js +202 -202
  334. package/test/fastest-wires.test.js +143 -143
  335. package/test/ffmpeg-stream-counts.test.js +78 -78
  336. package/test/file-edges.test.js +191 -191
  337. package/test/fixtures/audio-frame-boundary.json +1 -1
  338. package/test/fmp4-self-contained.test.js +80 -80
  339. package/test/fmp4-timestamps.test.js +140 -140
  340. package/test/fmp4-track-completeness.test.js +41 -41
  341. package/test/format-keyed-outputs.test.js +78 -78
  342. package/test/health-metrics.test.js +36 -38
  343. package/test/held-read-ownership.test.js +77 -77
  344. package/test/held-request-width.test.js +157 -157
  345. package/test/helpers/encode-run.js +248 -240
  346. package/test/helpers/held-file.js +77 -77
  347. package/test/helpers/manager.js +62 -62
  348. package/test/helpers/matroska-file.js +296 -296
  349. package/test/helpers/output-spec.js +63 -63
  350. package/test/host-load.test.js +68 -68
  351. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  352. package/test/init-compat.test.js +219 -219
  353. package/test/input-lost-quiets-the-plan.test.js +261 -261
  354. package/test/input-unavailable.test.js +48 -48
  355. package/test/installation-id.test.js +39 -39
  356. package/test/keeping-period.test.js +83 -83
  357. package/test/keyframe-index-accuracy.test.js +188 -188
  358. package/test/keyframe-table-budget.test.js +123 -123
  359. package/test/keyframes-belong-to-the-file.test.js +249 -249
  360. package/test/learned-median.test.js +37 -37
  361. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  362. package/test/limit-row-by-area.test.js +60 -60
  363. package/test/link-budget.test.js +111 -111
  364. package/test/link-probe.test.js +56 -56
  365. package/test/local-encoding-data.test.js +273 -273
  366. package/test/logger-repeats.test.js +134 -134
  367. package/test/look-ahead-coverage.test.js +56 -56
  368. package/test/manager-surface.test.js +291 -291
  369. package/test/matroska-cluster-discovery.test.js +323 -323
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/media-time.test.js +62 -62
  372. package/test/memory-budget.test.js +390 -390
  373. package/test/move-cost.test.js +216 -216
  374. package/test/mp4-subtitles.test.js +173 -173
  375. package/test/one-authority.test.js +285 -285
  376. package/test/one-budget.test.js +149 -149
  377. package/test/one-output-one-session.test.js +273 -273
  378. package/test/one-resource-divided.test.js +185 -185
  379. package/test/one-serving-output-per-height.test.js +458 -458
  380. package/test/orchestrator-wired.test.js +207 -207
  381. package/test/output-catalog.test.js +39 -39
  382. package/test/output-shape.test.js +21 -21
  383. package/test/output-spec.test.js +251 -251
  384. package/test/output-speed.test.js +92 -87
  385. package/test/outputs-being-prepared.test.js +40 -40
  386. package/test/packet-witness-ring.test.js +236 -236
  387. package/test/packet-witness.test.js +148 -148
  388. package/test/piece-disk-store.test.js +401 -401
  389. package/test/piece-from-whole-file.test.js +129 -129
  390. package/test/piece-lru.test.js +319 -319
  391. package/test/piece-reader.test.js +162 -162
  392. package/test/piece-selection-offset.test.js +85 -85
  393. package/test/piece-store-eviction.test.js +595 -595
  394. package/test/piece-store-never-refuses.test.js +153 -153
  395. package/test/piece-store-reservations.test.js +403 -403
  396. package/test/piece-store-slow-disk.test.js +159 -159
  397. package/test/piece-tail.test.js +97 -97
  398. package/test/plan-host-timings.test.js +68 -68
  399. package/test/plan-is-steady.test.js +225 -225
  400. package/test/playback-readiness.test.js +518 -518
  401. package/test/playback-start-regressions.test.js +101 -101
  402. package/test/priority-map-download.test.js +174 -174
  403. package/test/priority-map-emptied.test.js +204 -204
  404. package/test/priority-map-per-output.test.js +215 -215
  405. package/test/priority-map.test.js +163 -163
  406. package/test/probe-wedge-certainty.test.js +60 -60
  407. package/test/produced-copy-choice.test.js +213 -213
  408. package/test/published-timeline.test.js +83 -83
  409. package/test/quality-offer.test.js +160 -160
  410. package/test/quality-variants.test.js +1274 -1270
  411. package/test/rate-control-in-the-output.test.js +181 -180
  412. package/test/read-survives-withdrawal.test.js +164 -164
  413. package/test/read-window.test.js +365 -365
  414. package/test/reads-not-yet-arrived.test.js +218 -218
  415. package/test/resume-warm.test.js +39 -39
  416. package/test/run-graph-drift.test.js +37 -37
  417. package/test/run-inputs.test.js +80 -80
  418. package/test/run-intervals.test.js +100 -100
  419. package/test/run-position-follows-published-grid.test.js +89 -89
  420. package/test/run-progress.test.js +64 -64
  421. package/test/same-height-switch.test.js +663 -663
  422. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  423. package/test/seek-landing.test.js +109 -109
  424. package/test/seek-target-not-superseded.test.js +195 -195
  425. package/test/segment-closed-on-both-branches.test.js +114 -114
  426. package/test/segment-cut-times.test.js +63 -63
  427. package/test/segment-demand.test.js +80 -80
  428. package/test/segment-hold-supersede.test.js +91 -91
  429. package/test/segment-judged-by-its-own-header.test.js +222 -222
  430. package/test/segment-serve-wiring.test.js +481 -481
  431. package/test/segment-store-eviction.test.js +247 -247
  432. package/test/segment-store.test.js +352 -352
  433. package/test/segment-true-start.test.js +86 -86
  434. package/test/segments-are-shared.test.js +176 -176
  435. package/test/send-chunk-bytes.test.js +86 -86
  436. package/test/serving-existing-output.test.js +111 -111
  437. package/test/serving-output.test.js +102 -102
  438. package/test/shared-piece-store.test.js +310 -310
  439. package/test/sidecar-naming.test.js +142 -142
  440. package/test/soundtrack-rate-bound.test.js +289 -289
  441. package/test/source-file.test.js +133 -133
  442. package/test/source-files-route.test.js +113 -113
  443. package/test/source-registry.test.js +18 -18
  444. package/test/split-a-stretch.test.js +139 -139
  445. package/test/stale-request-after-seek.test.js +158 -158
  446. package/test/startup-readings.test.js +124 -124
  447. package/test/stream-route.test.js +237 -237
  448. package/test/subtitle-cue-framing.test.js +200 -200
  449. package/test/subtitle-cue-source.test.js +102 -102
  450. package/test/subtitle-cue-walk.test.js +385 -385
  451. package/test/subtitle-cursor.test.js +63 -63
  452. package/test/subtitle-defaults.test.js +118 -118
  453. package/test/subtitle-language.test.js +252 -252
  454. package/test/subtitle-metadata.test.js +15 -15
  455. package/test/subtitle-plan-waits.test.js +125 -125
  456. package/test/subtitle-track-numbering.test.js +391 -391
  457. package/test/supply-margin.test.js +190 -190
  458. package/test/swarm-follows-readers.test.js +174 -174
  459. package/test/swarm-reach.test.js +130 -130
  460. package/test/swarm-selection.test.js +220 -220
  461. package/test/tail-duplication.test.js +167 -167
  462. package/test/the-channel-has-both-ends.test.js +91 -91
  463. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  464. package/test/thread-pool.test.js +53 -53
  465. package/test/timeline.test.js +84 -84
  466. package/test/torrent-contents.test.js +235 -235
  467. package/test/torrent-cost.test.js +71 -71
  468. package/test/torrent-files.test.js +221 -221
  469. package/test/torrent-naming.test.js +255 -255
  470. package/test/tracks-begin-together.test.js +209 -209
  471. package/test/tunnel-renewal.test.js +167 -167
  472. package/test/two-viewers-one-picture.test.js +508 -508
  473. package/test/upload-hurry.test.js +186 -186
  474. package/test/usrsctp-state.test.js +131 -131
  475. package/test/video-facts.test.js +102 -102
  476. package/test/viewer-assignments.test.js +143 -143
  477. package/test/viewer-outputs.test.js +285 -285
  478. package/test/viewer-presence.test.js +116 -116
  479. package/test/viewer-requests-admission.test.js +92 -92
  480. package/test/viewer-starts-on-map.test.js +27 -27
  481. package/test/viewer-states.test.js +192 -189
  482. package/test/viewer-subtitle-subscription.test.js +96 -96
  483. package/test/viewer.test.js +139 -139
  484. package/test/viewing-generations.test.js +390 -390
  485. package/test/visible-rung.test.js +37 -37
  486. package/test/wait-ledger.test.js +100 -100
  487. package/test/webrtc-shim.test.js +149 -149
  488. package/test/wedge-certainty.test.js +131 -131
  489. package/test/whole-file-held-read.test.js +30 -30
  490. package/test/withdraw-piece-claim.test.js +212 -212
  491. package/test/worker-channel.test.js +158 -158
  492. package/utils/client-log-file.js +151 -151
  493. package/utils/coalesce.js +41 -41
  494. package/utils/logger.js +312 -312
  495. package/utils/parse-range.js +25 -25
  496. package/utils/perf.js +59 -59
  497. package/utils/torrent-source-key.js +38 -38
package/server.js CHANGED
@@ -1,748 +1,748 @@
1
- /**
2
- * @file Proxy HTTP server bootstrap.
3
- *
4
- * Creates and configures the Fastify application, registers all routes and
5
- * plugins, then starts listening on the first available port at or above the
6
- * requested one.
7
- */
8
-
9
- import Fastify from "fastify";
10
- import fastifyCors from "@fastify/cors";
11
- import fastifyHelmet from "@fastify/helmet";
12
- import fastifyStatic from "@fastify/static";
13
- import getPort from "get-port";
14
- import path from "node:path";
15
- import { createRequire } from "node:module";
16
- import { fileURLToPath } from "node:url";
17
- import { handleHealthGet } from "./routes/health/get.js";
18
- import { handleHealthzGet } from "./routes/healthz/get.js";
19
- import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
20
- import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
21
- import { handleApiSourcesPost } from "./routes/api/sources/post.js";
22
- import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
23
- import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
24
- import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
25
- import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
26
- import { handleApiPlaybackPlanAudioTracksPost } from "./routes/api/playback-plan/audio-tracks/post.js";
27
- import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
28
- import { createClientLogFiles } from "./utils/client-log-file.js";
29
- import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
30
- import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
31
- import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
32
- import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
33
- import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
34
- import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
35
- import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
36
- import { handleStreamGet } from "./routes/stream/get.js";
37
- import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
38
- import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
39
- import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
40
- import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
41
- import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
42
- import { createSourceRegistry } from "./store/source-registry.js";
43
- import { WorkerTorrentPool } from "./services/torrent/worker/pool-adapter.js";
44
- import { wireOutputs } from "./services/server/wire-outputs.js";
45
- import { createPlaybackPlanner } from "./services/media/playback-planner.js";
46
- import { KeyframeTables } from "./services/media/KeyframeTables.js";
47
- import { contentsOf } from "./services/torrent/Contents.js";
48
- import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
49
- import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
50
- import { viewerStartsOn } from "./services/viewer/PriorityMap.js";
51
- import { coalescing } from "./utils/coalesce.js";
52
- import {
53
- warmSubtitleCues,
54
- cuesHeldFor,
55
- subtitleTracksOf,
56
- declaredSubtitleTracksOf,
57
- forgetSubtitles
58
- } from "./services/media/SubtitleCues.js";
59
- import { detectVideoEncoder, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport, softwareDescriptor } from "./services/encode/hwaccel.js";
60
- import { calibrateEncoder, HostCalibration } from "./services/encode/calibration.js";
61
- import { readHostFingerprint } from "./services/encode/fingerprint.js";
62
- import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
63
- import { benchmarkAudio } from "./services/encode/audio-calibration.js";
64
- import { logger } from "./utils/logger.js";
65
- import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
66
-
67
- const __filename = fileURLToPath(import.meta.url);
68
- const __dirname = path.dirname(__filename);
69
- const require = createRequire(import.meta.url);
70
- const { version } = require("./package.json");
71
- const publicRoot = path.resolve(__dirname, "./public");
72
-
73
- /**
74
- * Build a list of candidate port numbers starting at `startPort`.
75
- *
76
- * @param {number} startPort
77
- * @param {number} [maxAttempts=51]
78
- * @returns {number[]}
79
- */
80
- function buildPortCandidates(startPort, maxAttempts = 51) {
81
- const ports = [];
82
- for (let index = 0; index < maxAttempts; index += 1) {
83
- ports.push(startPort + index);
84
- }
85
- return ports;
86
- }
87
-
88
- /**
89
- * @typedef {Object} ProxyServerOptions
90
- * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
91
- * @property {number} port - Preferred listen port.
92
- * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
93
- * @property {string} ffmpegBin - Path to the ffmpeg executable.
94
- * @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
95
- * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
96
- * @property {string} [stateDir] - Where to keep what this host has measured about itself.
97
- */
98
-
99
- /**
100
- * Create, configure, and start the proxy HTTP server.
101
- *
102
- * @param {ProxyServerOptions} options
103
- * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
104
- */
105
- export async function startProxyServer({
106
- host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
107
- deliverySink = false,
108
- // Where the proxy writes its own log. Its DIRECTORY is what matters here:
109
- // the browser's half of every session is written beside it, so the two are
110
- // on one durable disk and join by name.
111
- logFile = ""
112
- }) {
113
- const app = Fastify({
114
- // No practical body-size limit — the proxy server is localhost-only and
115
- // receives torrent source payloads that may be arbitrarily large.
116
- bodyLimit: 256 * 1024 * 1024 // 256 MB
117
- });
118
-
119
- await app.register(fastifyHelmet, {
120
- // Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
121
- crossOriginResourcePolicy: {
122
- policy: "cross-origin"
123
- }
124
- });
125
- await app.register(fastifyCors, {
126
- origin: true,
127
- methods: ["GET", "POST", "OPTIONS"],
128
- allowedHeaders: ["Content-Type", "Range"]
129
- });
130
-
131
- // Allow browser requests from an HTTPS page to this private-network proxy
132
- // without triggering Chromium's Private Network Access permission prompt.
133
- app.addHook("onRequest", async (_req, reply) => {
134
- reply.header("Access-Control-Allow-Private-Network", "true");
135
- });
136
-
137
- const sourceRegistry = createSourceRegistry(200);
138
- // The torrent runs on its own thread. Profiling a live seek (2026-08-02)
139
- // found the main thread ~85% occupied by WebTorrent — buffer concatenation
140
- // ~15%, wire updates ~9%, garbage collection ~5% — while three of four cores
141
- // idled. Serving a segment shared that thread, so reading an already-finished
142
- // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
143
- // adapter keeps TorrentPool's interface, so nothing downstream changed.
144
- const torrentPool = new WorkerTorrentPool({
145
- memoryBytes,
146
- stateDir,
147
- // Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
148
- // done about it — walking a file's new subtitle clusters and pushing what
149
- // came out — happens here, because that is a reading of what the file says
150
- // about itself. The walk used to run in that thread, which is what put a
151
- // container parse there.
152
- // The ONE forward reference here, and it is safe by construction: this fires
153
- // only once the worker thread is up and a piece has verified, which is long
154
- // after the declarations below have run.
155
- onPiecesArrived: ({ sourceKey, fileIndexes }) => {
156
- for (const fileIndex of fileIndexes) {
157
- void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
158
- // A keyframe table somebody asked for while its bytes had not arrived
159
- // is read again now; nothing was recorded for that wait.
160
- keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
161
- }
162
- }
163
- });
164
- const selectedPort = await getPort({
165
- port: buildPortCandidates(port)
166
- });
167
- // Auto-detect the best available H.264 encoder (hardware-accelerated or
168
- // software) once at startup, with a real test-encode and graceful fallback.
169
- // Only needed when transcoding can occur.
170
- let videoEncoder = transcodeAudio
171
- ? await detectVideoEncoder({ ffmpegBin, logger })
172
- : null;
173
- // WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
174
- // was gated on the chosen encoder being SOFTWARE, and two of the three are
175
- // not about the encoder at all: a host with a GPU decodes in software just
176
- // the same — no hardware decoder is asked for anywhere — and what a second
177
- // job costs is a property of the machine. So a GPU host had no decode cost,
178
- // no contention penalty and no encoder throughput, and the quality offer,
179
- // which is arithmetic over those three, had nothing to compute from.
180
- //
181
- // The decode model first and the throughput second — they are independent
182
- // (the rungs are timed on raw frames), but the order keeps the two figures
183
- // side by side in the log.
184
- const decodeCostModel = transcodeAudio
185
- ? await benchmarkDecodeCost({ ffmpegBin, logger })
186
- : null;
187
- // What a second job costs on this host. Measured because the budget adds
188
- // independent prices and this host says two jobs that each fit alone do not
189
- // fit together — 2.6× on the addon box (2026-08-18).
190
- const contentionPenalties = transcodeAudio
191
- ? await benchmarkContention({ ffmpegBin, logger })
192
- : null;
193
- // WHICH MODES THIS HOST MAY ENCODE WITH, AND WHAT EACH COSTS AT EVERY SIZE
194
- // (roadmap item 97, step 14). Every setting an output can actually be
195
- // encoded at is put through the product's own arguments and its segments
196
- // decoded, then timed at the sizes of the ladder; a mode that fails either is
197
- // not used, and a size nothing was timed at is not offered. Software is
198
- // calibrated too when detection chose a device: it is what a failing device
199
- // falls back to for the rest of the process, and it must not be priced by
200
- // the device's figures.
201
- //
202
- // And WHICH CONFIGURATION all of it describes — the ffmpeg and x264 builds,
203
- // the processor and the threads, the device and its driver — which is what
204
- // decides whether anything kept from an earlier run still applies.
205
- const fingerprint = videoEncoder
206
- ? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder })
207
- : null;
208
- /** @type {Record<string, object[]>} */
209
- const calibratedModes = {};
210
- if (videoEncoder) {
211
- calibratedModes[videoEncoder.kind] =
212
- (await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger })).modes;
213
- if (videoEncoder.kind !== "software") {
214
- calibratedModes.software =
215
- (await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger })).modes;
216
- // A device that passed detection's own test and then none of whose modes
217
- // passed the product's is not used: detection encodes a synthetic pattern
218
- // with arguments of its own, and it is the product's arguments a viewer
219
- // is served with.
220
- if (calibratedModes[videoEncoder.kind].length === 0) {
221
- logger.warn(
222
- `calibration: ${videoEncoder.name} passed detection but none of its modes produced correct segments ` +
223
- "through the product's own arguments; encoding in software instead"
224
- );
225
- videoEncoder = softwareDescriptor();
226
- }
227
- }
228
- }
229
- const calibration = videoEncoder ? new HostCalibration({ byKind: calibratedModes, fingerprint }) : null;
230
- // What this host does with a picture it does NOT re-encode. Every other
231
- // startup measurement prices encoding or decoding, and a copied picture does
232
- // neither — it reads packets and writes them out again — so that whole branch
233
- // had no speed until its own run had been running long enough to report one.
234
- // The encoding layer decides where encoders go from arrivals, and an arrival
235
- // cannot be computed without a speed, so the moment it mattered most was the
236
- // moment nothing was known. Measured whatever the encoder is: copying does not
237
- // touch it.
238
- const copySpeedX = transcodeAudio
239
- ? await benchmarkCopySpeed({ ffmpegBin, logger })
240
- : null;
241
- const audioCalibration = transcodeAudio
242
- ? await benchmarkAudio({ ffmpegBin, logger })
243
- : [];
244
- // WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
245
- // thing — leave an encoder where it stands, or kill it and start another —
246
- // and both used to be learned only from runs that had ENDED, so at a cold
247
- // open they were zero. Zero does not read as "not measured": it reads as
248
- // "free", and a free move is always taken. Field 2026-09-08: an encoder moved
249
- // between two adjacent numbers every half second and produced nothing.
250
- const startStopCost = transcodeAudio
251
- ? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
252
- : null;
253
- // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
254
- // Detected once; the session manager applies the tonemap chain only for HDR
255
- // sources on the software path when available.
256
- const tonemapSupported = transcodeAudio
257
- ? await detectTonemapSupport({ ffmpegBin, logger })
258
- : false;
259
- // Where every file's keyframe table lives, and the only thing that reads one.
260
- // Built here rather than inside the session manager because it is not a fact
261
- // about a session: the playback planner warms it while a file is being opened
262
- // and no session exists yet, and a session created later is handed the very
263
- // object that warm read filled in.
264
- // Subtitles are two layers put together, and this is the one place that knows
265
- // about both: what a file STATES about its subtitle tracks and how a cue is
266
- // written out is the media layer's, while which clusters may be read, where
267
- // the cursor stands and what one walk at a time means are the TORRENT's own
268
- // rules. The media layer used to import the second directly.
269
- /**
270
- * One file of one torrent, reduced to what the subtitle walk may know: a
271
- * name, a length, its one container, which ranges are downloaded whole, how
272
- * to read one of those without asking the swarm, the portion one read may
273
- * span, and where the viewers stand. File offsets and the bitfield stay in
274
- * the torrent's thread; the piece length crosses as a plain number.
275
- *
276
- * @param {string} sourceKey
277
- * @param {number} fileIndex
278
- * @param {object} [known] - The torrent handle when the caller already has it.
279
- * @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
280
- */
281
- const heldFileFor = async (sourceKey, fileIndex, known = null) => {
282
- let torrent = known;
283
- if (!torrent) {
284
- const record = sourceRegistry.get(sourceKey);
285
- if (!record) {
286
- return null;
287
- }
288
- torrent = await torrentPool.getTorrent(record.sourceType, record.source);
289
- }
290
- const file = torrent?.files?.[fileIndex];
291
- if (!file || !(file.length > 0)) {
292
- return null;
293
- }
294
- return {
295
- sourceKey,
296
- fileIndex,
297
- name: String(file.name ?? ""),
298
- length: file.length,
299
- portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
300
- // The file's ONE container — the one the track table, the media info and
301
- // the keyframe table are read from. Built here only when nobody has built
302
- // it yet, so a pass of the walk does not fetch the file's edges again.
303
- container: async () => {
304
- const known = containerOrchestrator.known(sourceKey, fileIndex);
305
- if (known !== undefined) {
306
- return known;
307
- }
308
- const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
309
- return params ? containerOrchestrator.containerFor(params) : null;
310
- },
311
- heldRanges: () => torrentPool.heldRangesOf(torrent, fileIndex),
312
- readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end),
313
- // Where the viewers of this file stand: the start of every urgent stretch
314
- // of its priority map, which is where each viewer's own need begins.
315
- wantedSeconds: () => viewerStartsOn(outputParts.priority?.mapFor(sourceKey, fileIndex)),
316
- // A pass left readable clusters for the next one: run it.
317
- askAgain: () => {
318
- void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
319
- }
320
- };
321
- };
322
- /**
323
- * Walk whatever new cues a file now holds and push them to the viewers.
324
- *
325
- * One walk of a file at a time, and one more after it whenever pieces
326
- * arrived meanwhile (`coalescing`): `verified` fires per piece, so a queue of
327
- * identical passes would only postpone the one with something new to find,
328
- * and dropping them — as this used to — lost the pass that would have found
329
- * the last pieces of a file.
330
- */
331
- const pushFreshCues = coalescing(async (sourceKey, fileIndex) => {
332
- try {
333
- const file = await heldFileFor(sourceKey, fileIndex);
334
- if (!file) {
335
- return;
336
- }
337
- for (const entry of await warmSubtitleCues(file)) {
338
- const span = entry.spanStartSeconds === null
339
- ? "empty"
340
- : `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
341
- logger.info(
342
- `subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
343
- `${entry.cues.length} new cue(s) covering ${span}` +
344
- (entry.withdrawn.length > 0 ? `, ${entry.withdrawn.length} taken back` : "") +
345
- `, clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
346
- );
347
- onSubtitleCues?.({ sourceKey, fileIndex, ...entry });
348
- }
349
- } catch (error) {
350
- logger.warn(`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} failed: ${error?.message ?? error}`);
351
- }
352
- });
353
- const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
354
- warm: async (torrent, fileIndex, sourceKey) => {
355
- const file = await heldFileFor(sourceKey, fileIndex, torrent);
356
- return file ? warmSubtitleCues(file) : [];
357
- },
358
- held: async (torrent, fileIndex, sourceKey, trackNumber) => {
359
- const file = await heldFileFor(sourceKey, fileIndex, torrent);
360
- return file ? cuesHeldFor(file, trackNumber) : null;
361
- },
362
- tracksOf: async (torrent, fileIndex, sourceKey) => {
363
- const file = await heldFileFor(sourceKey, fileIndex, torrent);
364
- return file ? subtitleTracksOf(file) : [];
365
- },
366
- declaredTracksOf: async (torrent, fileIndex, sourceKey) => {
367
- const file = await heldFileFor(sourceKey, fileIndex, torrent);
368
- return file ? declaredSubtitleTracksOf(file) : [];
369
- },
370
- forget: forgetSubtitles
371
- });
372
- // WHAT A FILE STATES ABOUT ITSELF IS READ HERE, ON THIS THREAD.
373
- //
374
- // The container layer is built from one function — `readRange(start, end)` —
375
- // and the bytes behind it live in shared memory, so the parse has no reason
376
- // to happen anywhere else. It used to happen in the torrent thread for one
377
- // stated reason, that "the main thread cannot open a read stream on one of
378
- // its files": true of WebTorrent's own API, and not of the bytes. The price
379
- // was three commands, a second `ContainerOrchestrator` in that thread, and
380
- // every answer carried back across the channel — which is what made "where is
381
- // this fact kept" a question at all.
382
- //
383
- // The edges are fetched first because the file this is asked about is usually
384
- // one nobody has played: a sidecar soundtrack is asked about before anyone
385
- // has chosen it, and a Matroska file's Cues sit at the END behind a SeekHead
386
- // in the head, so a read that has neither waits for the swarm twice over.
387
- const containerOver = async ({ sourceKey, fileIndex, tailBytes = 0 }) => {
388
- const record = sourceRegistry.get(sourceKey);
389
- if (!record) {
390
- return null;
391
- }
392
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
393
- const file = torrent?.files?.[fileIndex];
394
- if (!file || !(file.length > 0)) {
395
- return null;
396
- }
397
- try {
398
- await torrentPool.prefetchFileEdges(torrent, fileIndex, {
399
- headBytes: CONTAINER_HEAD_BYTES,
400
- tailBytes,
401
- timeoutMs: 60_000
402
- });
403
- } catch {
404
- // A prefetch that failed is not a reason to skip the read: the read
405
- // fetches what it needs itself, only more slowly.
406
- }
407
- return {
408
- sourceKey,
409
- fileIndex,
410
- readRange: (start, end) =>
411
- torrentPool.readRangeOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
412
- fileSize: file.length,
413
- label: String(file.name ?? ""),
414
- portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
415
- };
416
- };
417
- const keyframeTables = new KeyframeTables({
418
- // Read by the same container that answers the track table and the media
419
- // info, from the same header, and now in the same thread as the session
420
- // that is waiting for it.
421
- readTable: async ({ sourceKey, fileIndex }) => {
422
- // Both edges: the Cues of a Matroska file are at the end.
423
- const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
424
- if (!params) {
425
- return null;
426
- }
427
- const index = await containerOrchestrator.getKeyframeIndex(params);
428
- return {
429
- times: index?.times ?? null,
430
- tolerance: index?.tolerance ?? 0,
431
- // Which container answered, whether or not it produced a table: the
432
- // refusal that follows names it, and a measurement of how often an
433
- // index disagrees with its own file cannot be read without it.
434
- format: (await containerOrchestrator.getContainer(params))?.formatName ?? "unrecognised"
435
- };
436
- }
437
- });
438
- const outputParts = wireOutputs({
439
- enabled: transcodeAudio,
440
- keyframeTables,
441
- ffmpegBin,
442
- localBindHost: host,
443
- localPort: selectedPort,
444
- videoEncoder,
445
- calibration,
446
- audioCalibration,
447
- decodeCostModel,
448
- contentionPenalties,
449
- copySpeedX,
450
- startStopCost,
451
- tonemapSupported,
452
- segmentFormatId: segmentFormat,
453
- stateDir,
454
- diagnostics,
455
- // Live download stats accessor for the realtime budget: lets it tell a
456
- // CPU-bound transcode from a download-starved input before downscaling.
457
- // What every torrent here has moved, so the proxy can price its own
458
- // downloading, hashing and delivery against the machine (roadmap item 7).
459
- // What the spilled pieces weigh on the torrent thread, and how to tell them
460
- // their share of the disk. The owner of the disk is on this side, where the
461
- // segments are; the pieces are on the other.
462
- spillDisk: typeof torrentPool.allowSpillBytes === "function"
463
- ? {
464
- held: () => torrentPool.spilledBytes ?? 0,
465
- allow: (bytes) => torrentPool.allowSpillBytes(bytes)
466
- }
467
- : null,
468
- // Files downloaded whole, kept as files. They live on the torrent thread
469
- // too, and until 2026-09-14 they had no bound of any kind — a 2.8 GB film
470
- // on a host whose disk is often a 32 GB card.
471
- wholeFiles: typeof torrentPool.allowWholeFileBytes === "function"
472
- ? {
473
- held: () => torrentPool.wholeFileBytes ?? 0,
474
- allow: (bytes) => torrentPool.allowWholeFileBytes(bytes),
475
- root: completedFilesRoot()
476
- }
477
- : null,
478
- // The pieces held IN memory, on the torrent thread. Told their share by the
479
- // same owner that divides the disk: what does not fit here is spilled
480
- // there, so the two cannot be divided apart.
481
- memoryClaimant: typeof torrentPool.allowMemoryBytes === "function"
482
- ? {
483
- held: () => torrentPool.memoryClaim?.held ?? 0,
484
- wanted: () => torrentPool.memoryClaim?.wanted ?? 0,
485
- allow: (bytes) => torrentPool.allowMemoryBytes(bytes)
486
- }
487
- : null,
488
- diagnosticsRoot: stateDir || "",
489
- budgetPolicy,
490
- getTorrentTotals: async () => {
491
- if (typeof torrentPool.getTorrentTotals !== "function") {
492
- return null;
493
- }
494
- return torrentPool.getTorrentTotals();
495
- },
496
- // The priority map, on its way to the downloading. It lives in another
497
- // thread, so the map crosses the worker channel; what it does with it —
498
- // seconds into bytes, what to ask the swarm for, what to keep in memory —
499
- // is its own business.
500
- setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
501
- const record = sourceRegistry.get(sourceKey);
502
- if (!record) {
503
- return;
504
- }
505
- try {
506
- await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
507
- } catch {
508
- // Best effort: the map is republished on the next change, and the
509
- // downloading goes on serving reads meanwhile.
510
- }
511
- },
512
- getSourceStats: async (sourceKey, fileIndex) => {
513
- const record = sourceRegistry.get(sourceKey);
514
- if (!record) {
515
- return null;
516
- }
517
- try {
518
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
519
- // Awaited for the same reason as the stats route: this now crosses a
520
- // thread boundary and returns a promise.
521
- return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
522
- } catch {
523
- return null;
524
- }
525
- },
526
- // Reuse the media info the planner already probed for this file (same
527
- // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
528
- // only at session-create time, after playbackPlanner is initialised.
529
- getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
530
- // The file's audio tracks, for the master playlist's rendition group. Already
531
- // probed for the browser's audio menu; read from there rather than probed again.
532
- getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
533
- // What a file declares about itself, read by the container layer from the
534
- // same header its track table comes from. This is how the session learns
535
- // where a soundtrack shipped as its own file begins — it used to spawn an
536
- // ffmpeg over this proxy's own HTTP to ask the same question of the same
537
- // bytes, and that read cost 8.1 s of every cold start (field 2026-09-03).
538
- getContainerMediaInfo: async ({ sourceKey, fileIndex }) => {
539
- try {
540
- const params = await containerOver({ sourceKey, fileIndex });
541
- return params ? await containerOrchestrator.getMediaInfo(params) : null;
542
- } catch {
543
- return null;
544
- }
545
- },
546
- // Pull one whole file onto the disk. Used for a soundtrack that ships beside
547
- // the picture, once the encoder is as far ahead of the viewer as it is
548
- // allowed to get — the one moment the swarm's capacity is demonstrably
549
- // spare. A bounded read of the file's whole length, NOT `file.select()`:
550
- // selecting a file alongside the readers' own windows is what made a seek
551
- // wait 93 s while the swarm fetched 2.47 GB in file order (see
552
- // `#syncSelections` in `torrent/torrent-pool.js`).
553
- fetchWholeFile: async ({ sourceKey, fileIndex }) => {
554
- const record = sourceRegistry.get(sourceKey);
555
- if (!record) {
556
- return;
557
- }
558
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
559
- // The same background fill the warm-up starts when a file is chosen, not a
560
- // second way of doing it. It is guarded against running twice on one file,
561
- // so the two triggers converge instead of putting two readers on the same
562
- // soundtrack — and only one of them would have stood aside for the
563
- // picture. This trigger remains for the session that never had a warm-up
564
- // before it.
565
- await torrentPool.fillFileInBackground?.(torrent, fileIndex);
566
- }
567
- });
568
- // What the last life of this process left on the disk. The kernel kills this
569
- // one often enough for that to be an ordinary state rather than an odd one —
570
- // twice in a single viewing on 2026-09-02 — and when it does, no exit handler
571
- // runs and nothing is cleared up. So this is both the cleanup and the only
572
- // record that those encoders ended at all: it says what it found before it
573
- // decides anything, keeps the segments whose closure is proven, and removes
574
- // the one piece per output that was being written when the process died.
575
- outputParts.lifecycle.adoptSegmentsLeftBehind();
576
- const playbackPlanner = createPlaybackPlanner({
577
- ffmpegBin,
578
- transcodeAudioEnabled: transcodeAudio,
579
- localBaseUrl: outputParts.localBaseUrl,
580
- sourceRegistry,
581
- torrentPool,
582
- sidecarsFromTorrent: (torrent, fileIndex) => contentsOf(torrent).sidecarsOf(fileIndex),
583
- // What a file declares about its own tracks, parsed on this thread from the
584
- // header the swarm delivered. The planner used to ask the torrent pool for
585
- // this, which meant the media layer asking the torrent layer to parse a
586
- // container on its behalf, in the other thread, with the answer carried
587
- // back over the channel.
588
- declaredTracksOf: async ({ sourceKey, fileIndex }) => {
589
- const params = await containerOver({ sourceKey, fileIndex });
590
- return params ? await containerOrchestrator.getTracks(params) : [];
591
- },
592
- warmKeyframeIndex: (params) => keyframeTables.warm(params),
593
- expectedFirstSegmentMs: () => outputParts.hostTimings.expectedFirstSegmentMs(),
594
- expectedSessionCreateMs: () => outputParts.hostTimings.expectedSessionCreateMs(),
595
- // The quality menu is on screen from the moment a file is opened, so the
596
- // heights this host can actually serve have to be answerable before any
597
- // encoder exists — from the probe and the startup benchmarks alone.
598
- predictOfferedHeights: (mediaInfo) => outputParts.quality.predictOfferedHeights(mediaInfo)
599
- });
600
-
601
- app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
602
- app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
603
- // Off unless --delivery-sink was given; see the route for why it exists.
604
- app.get("/api/delivery-sink", async (req, reply) =>
605
- handleApiDeliverySinkGet(req, reply, { enabled: deliverySink === true })
606
- );
607
- // Bytes a browser can time its link with before any film has been chosen.
608
- app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
609
- app.post("/api/sources", async (req, reply) =>
610
- handleApiSourcesPost(req, reply, { sourceRegistry })
611
- );
612
- app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
613
- handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
614
- );
615
- app.get("/api/sources/:sourceKey/files", async (req, reply) =>
616
- handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
617
- );
618
- app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
619
- handleApiSourceWarmPost(req, reply, {
620
- sourceRegistry,
621
- torrentPool,
622
- playbackPlanner,
623
- // How long the file runs, read on this thread from the header. The warm
624
- // turns a position in seconds into a byte offset and needs it; it used to
625
- // read the container itself, in the torrent thread, to find out.
626
- durationOf: async (params) => {
627
- const over = await containerOver(params);
628
- return over ? (await containerOrchestrator.getMediaInfo(over))?.durationSeconds ?? null : null;
629
- }
630
- })
631
- );
632
- // The browser's own log, kept beside the proxy's. See the route's own file
633
- // for why it is here and not only on the registry server.
634
- const clientLogs = logFile ? createClientLogFiles(logFile) : null;
635
- app.post("/api/client-logs", async (req, reply) =>
636
- handleApiClientLogsPost(req, reply, { clientLogs })
637
- );
638
- app.post("/api/playback-plan", async (req, reply) =>
639
- handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
640
- );
641
- app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
642
- handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
643
- );
644
- app.get("/api/subtitles", async (req, reply) =>
645
- handleApiSubtitlesGet(req, reply, {
646
- sourceRegistry,
647
- torrentPool,
648
- ffmpegBin,
649
- localBaseUrl: outputParts.localBaseUrl,
650
- viewers: outputParts.viewers,
651
- subtitles
652
- })
653
- );
654
- app.get("/stream", async (req, reply) =>
655
- handleStreamGet(req, reply, {
656
- sourceRegistry,
657
- torrentPool,
658
- // So a session that has produced nothing yet can still show it is being
659
- // fed. The route knows only files; the session id rides on the URL the
660
- // session itself built.
661
- noteInputBytes: (sessionId, bytes) => outputParts.encodeRuns.noteInputBytes(sessionId, bytes),
662
- // When the encoder reading this waits for its input, so its speed is
663
- // measured over its own work and not over the swarm's.
664
- noteInputWaiting: (runToken, waiting) => outputParts.encodeRuns.noteInputWaiting(runToken, waiting)
665
- })
666
- );
667
- app.post("/api/transcode-sessions", async (req, reply) =>
668
- handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool })
669
- );
670
- app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
671
- handleApiTranscodeSessionReleasePost(req, reply, { lifecycle: outputParts.lifecycle })
672
- );
673
- app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
674
- handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests: outputParts.viewerRequests })
675
- );
676
- app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
677
- // A viewer's statement about itself goes to the VIEWER layer, not through
678
- // the session manager: what it needs is the live sessions and the registry
679
- // of viewers, and nothing about encoding.
680
- handleApiTranscodeSessionNetReportPost(req, reply, {
681
- outputs: outputParts.outputs,
682
- viewers: outputParts.viewers,
683
- renditions: outputParts.renditions,
684
- quality: outputParts.quality
685
- })
686
- );
687
- app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
688
- handleApiTranscodeSessionFragmentFarPost(req, reply, { serving: outputParts.serving })
689
- );
690
- app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
691
- handleApiTranscodeSessionSeekPost(req, reply, { viewerRequests: outputParts.viewerRequests })
692
- );
693
- app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
694
- handleTranscodeSessionFileGet(req, reply, { serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
695
- );
696
- // A quality variant's files. Registered before the static handler for the
697
- // same reason as the line above, and kept a separate route rather than a
698
- // wildcard so the height stays a parsed parameter.
699
- // Registered BEFORE the variant file route: `warm` is not a file name, and
700
- // Fastify matches a static segment ahead of a parameter either way — stated
701
- // here so the order is not "tidied" into a bug.
702
- app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
703
- handleTranscodeVariantWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
704
- );
705
- app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
706
- handleTranscodeAudioWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
707
- );
708
- app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
709
- handleTranscodeAudioFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
710
- );
711
- app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
712
- handleTranscodeVariantFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
713
- );
714
- await app.register(fastifyStatic, {
715
- root: publicRoot,
716
- prefix: "/",
717
- serveDotFiles: true
718
- });
719
-
720
- app.addHook("onClose", async () => {
721
- // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
722
- // the torrents whose files they read from, then remove the torrent data.
723
- await outputParts.lifecycle.disposeAll();
724
- await torrentPool.destroyAll();
725
- });
726
-
727
- await app.listen({ host, port: selectedPort });
728
- return {
729
- app,
730
- port: selectedPort,
731
- // Asked over the tunnel when the proxy a viewer landed on has refused their
732
- // file: could THIS host sustain it? Answered from the startup benchmarks
733
- // and a description, so it needs no torrent and costs milliseconds.
734
- outputParts,
735
- // The browser only ever knows a source by its REGISTRY key (a hash of the
736
- // raw request bytes, scoped to one API session) — never the torrent
737
- // pool's own key (the content's infohash, shared across a magnet and a
738
- // `.torrent` naming the same film). The subtitle push subscription is
739
- // recorded from a browser request and published from the pool's side, so
740
- // resolving one into the other is what lets the two ends agree on what
741
- // they are both calling "sourceKey".
742
- sourceRegistry,
743
- // Which films this host holds, for the health poll the browser makes before
744
- // it picks a proxy. The pool is on the worker thread and this is the way to
745
- // it from the process that answers that poll.
746
- torrentPool
747
- };
748
- }
1
+ /**
2
+ * @file Proxy HTTP server bootstrap.
3
+ *
4
+ * Creates and configures the Fastify application, registers all routes and
5
+ * plugins, then starts listening on the first available port at or above the
6
+ * requested one.
7
+ */
8
+
9
+ import Fastify from "fastify";
10
+ import fastifyCors from "@fastify/cors";
11
+ import fastifyHelmet from "@fastify/helmet";
12
+ import fastifyStatic from "@fastify/static";
13
+ import getPort from "get-port";
14
+ import path from "node:path";
15
+ import { createRequire } from "node:module";
16
+ import { fileURLToPath } from "node:url";
17
+ import { handleHealthGet } from "./routes/health/get.js";
18
+ import { handleHealthzGet } from "./routes/healthz/get.js";
19
+ import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
20
+ import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
21
+ import { handleApiSourcesPost } from "./routes/api/sources/post.js";
22
+ import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
23
+ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
24
+ import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
25
+ import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
26
+ import { handleApiPlaybackPlanAudioTracksPost } from "./routes/api/playback-plan/audio-tracks/post.js";
27
+ import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
28
+ import { createClientLogFiles } from "./utils/client-log-file.js";
29
+ import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
30
+ import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
31
+ import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
32
+ import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
33
+ import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
34
+ import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
35
+ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
36
+ import { handleStreamGet } from "./routes/stream/get.js";
37
+ import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
38
+ import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
39
+ import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
40
+ import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
41
+ import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
42
+ import { createSourceRegistry } from "./store/source-registry.js";
43
+ import { WorkerTorrentPool } from "./services/torrent/worker/pool-adapter.js";
44
+ import { wireOutputs } from "./services/server/wire-outputs.js";
45
+ import { createPlaybackPlanner } from "./services/media/playback-planner.js";
46
+ import { KeyframeTables } from "./services/media/KeyframeTables.js";
47
+ import { contentsOf } from "./services/torrent/Contents.js";
48
+ import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
49
+ import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
50
+ import { viewerStartsOn } from "./services/viewer/PriorityMap.js";
51
+ import { coalescing } from "./utils/coalesce.js";
52
+ import {
53
+ warmSubtitleCues,
54
+ cuesHeldFor,
55
+ subtitleTracksOf,
56
+ declaredSubtitleTracksOf,
57
+ forgetSubtitles
58
+ } from "./services/media/SubtitleCues.js";
59
+ import { detectVideoEncoder, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport, softwareDescriptor } from "./services/encode/hwaccel.js";
60
+ import { calibrateEncoder, HostCalibration } from "./services/encode/calibration.js";
61
+ import { readHostFingerprint } from "./services/encode/fingerprint.js";
62
+ import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
63
+ import { benchmarkAudio } from "./services/encode/audio-calibration.js";
64
+ import { logger } from "./utils/logger.js";
65
+ import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
66
+
67
+ const __filename = fileURLToPath(import.meta.url);
68
+ const __dirname = path.dirname(__filename);
69
+ const require = createRequire(import.meta.url);
70
+ const { version } = require("./package.json");
71
+ const publicRoot = path.resolve(__dirname, "./public");
72
+
73
+ /**
74
+ * Build a list of candidate port numbers starting at `startPort`.
75
+ *
76
+ * @param {number} startPort
77
+ * @param {number} [maxAttempts=51]
78
+ * @returns {number[]}
79
+ */
80
+ function buildPortCandidates(startPort, maxAttempts = 51) {
81
+ const ports = [];
82
+ for (let index = 0; index < maxAttempts; index += 1) {
83
+ ports.push(startPort + index);
84
+ }
85
+ return ports;
86
+ }
87
+
88
+ /**
89
+ * @typedef {Object} ProxyServerOptions
90
+ * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
91
+ * @property {number} port - Preferred listen port.
92
+ * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
93
+ * @property {string} ffmpegBin - Path to the ffmpeg executable.
94
+ * @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
95
+ * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
96
+ * @property {string} [stateDir] - Where to keep what this host has measured about itself.
97
+ */
98
+
99
+ /**
100
+ * Create, configure, and start the proxy HTTP server.
101
+ *
102
+ * @param {ProxyServerOptions} options
103
+ * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
104
+ */
105
+ export async function startProxyServer({
106
+ host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
107
+ deliverySink = false,
108
+ // Where the proxy writes its own log. Its DIRECTORY is what matters here:
109
+ // the browser's half of every session is written beside it, so the two are
110
+ // on one durable disk and join by name.
111
+ logFile = ""
112
+ }) {
113
+ const app = Fastify({
114
+ // No practical body-size limit — the proxy server is localhost-only and
115
+ // receives torrent source payloads that may be arbitrarily large.
116
+ bodyLimit: 256 * 1024 * 1024 // 256 MB
117
+ });
118
+
119
+ await app.register(fastifyHelmet, {
120
+ // Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
121
+ crossOriginResourcePolicy: {
122
+ policy: "cross-origin"
123
+ }
124
+ });
125
+ await app.register(fastifyCors, {
126
+ origin: true,
127
+ methods: ["GET", "POST", "OPTIONS"],
128
+ allowedHeaders: ["Content-Type", "Range"]
129
+ });
130
+
131
+ // Allow browser requests from an HTTPS page to this private-network proxy
132
+ // without triggering Chromium's Private Network Access permission prompt.
133
+ app.addHook("onRequest", async (_req, reply) => {
134
+ reply.header("Access-Control-Allow-Private-Network", "true");
135
+ });
136
+
137
+ const sourceRegistry = createSourceRegistry(200);
138
+ // The torrent runs on its own thread. Profiling a live seek (2026-08-02)
139
+ // found the main thread ~85% occupied by WebTorrent — buffer concatenation
140
+ // ~15%, wire updates ~9%, garbage collection ~5% — while three of four cores
141
+ // idled. Serving a segment shared that thread, so reading an already-finished
142
+ // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
143
+ // adapter keeps TorrentPool's interface, so nothing downstream changed.
144
+ const torrentPool = new WorkerTorrentPool({
145
+ memoryBytes,
146
+ stateDir,
147
+ // Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
148
+ // done about it — walking a file's new subtitle clusters and pushing what
149
+ // came out — happens here, because that is a reading of what the file says
150
+ // about itself. The walk used to run in that thread, which is what put a
151
+ // container parse there.
152
+ // The ONE forward reference here, and it is safe by construction: this fires
153
+ // only once the worker thread is up and a piece has verified, which is long
154
+ // after the declarations below have run.
155
+ onPiecesArrived: ({ sourceKey, fileIndexes }) => {
156
+ for (const fileIndex of fileIndexes) {
157
+ void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
158
+ // A keyframe table somebody asked for while its bytes had not arrived
159
+ // is read again now; nothing was recorded for that wait.
160
+ keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
161
+ }
162
+ }
163
+ });
164
+ const selectedPort = await getPort({
165
+ port: buildPortCandidates(port)
166
+ });
167
+ // Auto-detect the best available H.264 encoder (hardware-accelerated or
168
+ // software) once at startup, with a real test-encode and graceful fallback.
169
+ // Only needed when transcoding can occur.
170
+ let videoEncoder = transcodeAudio
171
+ ? await detectVideoEncoder({ ffmpegBin, logger })
172
+ : null;
173
+ // WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
174
+ // was gated on the chosen encoder being SOFTWARE, and two of the three are
175
+ // not about the encoder at all: a host with a GPU decodes in software just
176
+ // the same — no hardware decoder is asked for anywhere — and what a second
177
+ // job costs is a property of the machine. So a GPU host had no decode cost,
178
+ // no contention penalty and no encoder throughput, and the quality offer,
179
+ // which is arithmetic over those three, had nothing to compute from.
180
+ //
181
+ // The decode model first and the throughput second — they are independent
182
+ // (the rungs are timed on raw frames), but the order keeps the two figures
183
+ // side by side in the log.
184
+ const decodeCostModel = transcodeAudio
185
+ ? await benchmarkDecodeCost({ ffmpegBin, logger })
186
+ : null;
187
+ // What a second job costs on this host. Measured because the budget adds
188
+ // independent prices and this host says two jobs that each fit alone do not
189
+ // fit together — 2.6× on the addon box (2026-08-18).
190
+ const contentionPenalties = transcodeAudio
191
+ ? await benchmarkContention({ ffmpegBin, logger })
192
+ : null;
193
+ // WHICH MODES THIS HOST MAY ENCODE WITH, AND WHAT EACH COSTS AT EVERY SIZE
194
+ // (roadmap item 97, step 14). Every setting an output can actually be
195
+ // encoded at is put through the product's own arguments and its segments
196
+ // decoded, then timed at the sizes of the ladder; a mode that fails either is
197
+ // not used, and a size nothing was timed at is not offered. Software is
198
+ // calibrated too when detection chose a device: it is what a failing device
199
+ // falls back to for the rest of the process, and it must not be priced by
200
+ // the device's figures.
201
+ //
202
+ // And WHICH CONFIGURATION all of it describes — the ffmpeg and x264 builds,
203
+ // the processor and the threads, the device and its driver — which is what
204
+ // decides whether anything kept from an earlier run still applies.
205
+ const fingerprint = videoEncoder
206
+ ? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder })
207
+ : null;
208
+ /** @type {Record<string, object[]>} */
209
+ const calibratedModes = {};
210
+ if (videoEncoder) {
211
+ calibratedModes[videoEncoder.kind] =
212
+ (await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger })).modes;
213
+ if (videoEncoder.kind !== "software") {
214
+ calibratedModes.software =
215
+ (await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger })).modes;
216
+ // A device that passed detection's own test and then none of whose modes
217
+ // passed the product's is not used: detection encodes a synthetic pattern
218
+ // with arguments of its own, and it is the product's arguments a viewer
219
+ // is served with.
220
+ if (calibratedModes[videoEncoder.kind].length === 0) {
221
+ logger.warn(
222
+ `calibration: ${videoEncoder.name} passed detection but none of its modes produced correct segments ` +
223
+ "through the product's own arguments; encoding in software instead"
224
+ );
225
+ videoEncoder = softwareDescriptor();
226
+ }
227
+ }
228
+ }
229
+ const calibration = videoEncoder ? new HostCalibration({ byKind: calibratedModes, fingerprint }) : null;
230
+ // What this host does with a picture it does NOT re-encode. Every other
231
+ // startup measurement prices encoding or decoding, and a copied picture does
232
+ // neither — it reads packets and writes them out again — so that whole branch
233
+ // had no speed until its own run had been running long enough to report one.
234
+ // The encoding layer decides where encoders go from arrivals, and an arrival
235
+ // cannot be computed without a speed, so the moment it mattered most was the
236
+ // moment nothing was known. Measured whatever the encoder is: copying does not
237
+ // touch it.
238
+ const copySpeedX = transcodeAudio
239
+ ? await benchmarkCopySpeed({ ffmpegBin, logger })
240
+ : null;
241
+ const audioCalibration = transcodeAudio
242
+ ? await benchmarkAudio({ ffmpegBin, logger })
243
+ : [];
244
+ // WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
245
+ // thing — leave an encoder where it stands, or kill it and start another —
246
+ // and both used to be learned only from runs that had ENDED, so at a cold
247
+ // open they were zero. Zero does not read as "not measured": it reads as
248
+ // "free", and a free move is always taken. Field 2026-09-08: an encoder moved
249
+ // between two adjacent numbers every half second and produced nothing.
250
+ const startStopCost = transcodeAudio
251
+ ? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
252
+ : null;
253
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
254
+ // Detected once; the session manager applies the tonemap chain only for HDR
255
+ // sources on the software path when available.
256
+ const tonemapSupported = transcodeAudio
257
+ ? await detectTonemapSupport({ ffmpegBin, logger })
258
+ : false;
259
+ // Where every file's keyframe table lives, and the only thing that reads one.
260
+ // Built here rather than inside the session manager because it is not a fact
261
+ // about a session: the playback planner warms it while a file is being opened
262
+ // and no session exists yet, and a session created later is handed the very
263
+ // object that warm read filled in.
264
+ // Subtitles are two layers put together, and this is the one place that knows
265
+ // about both: what a file STATES about its subtitle tracks and how a cue is
266
+ // written out is the media layer's, while which clusters may be read, where
267
+ // the cursor stands and what one walk at a time means are the TORRENT's own
268
+ // rules. The media layer used to import the second directly.
269
+ /**
270
+ * One file of one torrent, reduced to what the subtitle walk may know: a
271
+ * name, a length, its one container, which ranges are downloaded whole, how
272
+ * to read one of those without asking the swarm, the portion one read may
273
+ * span, and where the viewers stand. File offsets and the bitfield stay in
274
+ * the torrent's thread; the piece length crosses as a plain number.
275
+ *
276
+ * @param {string} sourceKey
277
+ * @param {number} fileIndex
278
+ * @param {object} [known] - The torrent handle when the caller already has it.
279
+ * @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
280
+ */
281
+ const heldFileFor = async (sourceKey, fileIndex, known = null) => {
282
+ let torrent = known;
283
+ if (!torrent) {
284
+ const record = sourceRegistry.get(sourceKey);
285
+ if (!record) {
286
+ return null;
287
+ }
288
+ torrent = await torrentPool.getTorrent(record.sourceType, record.source);
289
+ }
290
+ const file = torrent?.files?.[fileIndex];
291
+ if (!file || !(file.length > 0)) {
292
+ return null;
293
+ }
294
+ return {
295
+ sourceKey,
296
+ fileIndex,
297
+ name: String(file.name ?? ""),
298
+ length: file.length,
299
+ portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
300
+ // The file's ONE container — the one the track table, the media info and
301
+ // the keyframe table are read from. Built here only when nobody has built
302
+ // it yet, so a pass of the walk does not fetch the file's edges again.
303
+ container: async () => {
304
+ const known = containerOrchestrator.known(sourceKey, fileIndex);
305
+ if (known !== undefined) {
306
+ return known;
307
+ }
308
+ const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
309
+ return params ? containerOrchestrator.containerFor(params) : null;
310
+ },
311
+ heldRanges: () => torrentPool.heldRangesOf(torrent, fileIndex),
312
+ readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end),
313
+ // Where the viewers of this file stand: the start of every urgent stretch
314
+ // of its priority map, which is where each viewer's own need begins.
315
+ wantedSeconds: () => viewerStartsOn(outputParts.priority?.mapFor(sourceKey, fileIndex)),
316
+ // A pass left readable clusters for the next one: run it.
317
+ askAgain: () => {
318
+ void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
319
+ }
320
+ };
321
+ };
322
+ /**
323
+ * Walk whatever new cues a file now holds and push them to the viewers.
324
+ *
325
+ * One walk of a file at a time, and one more after it whenever pieces
326
+ * arrived meanwhile (`coalescing`): `verified` fires per piece, so a queue of
327
+ * identical passes would only postpone the one with something new to find,
328
+ * and dropping them — as this used to — lost the pass that would have found
329
+ * the last pieces of a file.
330
+ */
331
+ const pushFreshCues = coalescing(async (sourceKey, fileIndex) => {
332
+ try {
333
+ const file = await heldFileFor(sourceKey, fileIndex);
334
+ if (!file) {
335
+ return;
336
+ }
337
+ for (const entry of await warmSubtitleCues(file)) {
338
+ const span = entry.spanStartSeconds === null
339
+ ? "empty"
340
+ : `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
341
+ logger.info(
342
+ `subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
343
+ `${entry.cues.length} new cue(s) covering ${span}` +
344
+ (entry.withdrawn.length > 0 ? `, ${entry.withdrawn.length} taken back` : "") +
345
+ `, clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
346
+ );
347
+ onSubtitleCues?.({ sourceKey, fileIndex, ...entry });
348
+ }
349
+ } catch (error) {
350
+ logger.warn(`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} failed: ${error?.message ?? error}`);
351
+ }
352
+ });
353
+ const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
354
+ warm: async (torrent, fileIndex, sourceKey) => {
355
+ const file = await heldFileFor(sourceKey, fileIndex, torrent);
356
+ return file ? warmSubtitleCues(file) : [];
357
+ },
358
+ held: async (torrent, fileIndex, sourceKey, trackNumber) => {
359
+ const file = await heldFileFor(sourceKey, fileIndex, torrent);
360
+ return file ? cuesHeldFor(file, trackNumber) : null;
361
+ },
362
+ tracksOf: async (torrent, fileIndex, sourceKey) => {
363
+ const file = await heldFileFor(sourceKey, fileIndex, torrent);
364
+ return file ? subtitleTracksOf(file) : [];
365
+ },
366
+ declaredTracksOf: async (torrent, fileIndex, sourceKey) => {
367
+ const file = await heldFileFor(sourceKey, fileIndex, torrent);
368
+ return file ? declaredSubtitleTracksOf(file) : [];
369
+ },
370
+ forget: forgetSubtitles
371
+ });
372
+ // WHAT A FILE STATES ABOUT ITSELF IS READ HERE, ON THIS THREAD.
373
+ //
374
+ // The container layer is built from one function — `readRange(start, end)` —
375
+ // and the bytes behind it live in shared memory, so the parse has no reason
376
+ // to happen anywhere else. It used to happen in the torrent thread for one
377
+ // stated reason, that "the main thread cannot open a read stream on one of
378
+ // its files": true of WebTorrent's own API, and not of the bytes. The price
379
+ // was three commands, a second `ContainerOrchestrator` in that thread, and
380
+ // every answer carried back across the channel — which is what made "where is
381
+ // this fact kept" a question at all.
382
+ //
383
+ // The edges are fetched first because the file this is asked about is usually
384
+ // one nobody has played: a sidecar soundtrack is asked about before anyone
385
+ // has chosen it, and a Matroska file's Cues sit at the END behind a SeekHead
386
+ // in the head, so a read that has neither waits for the swarm twice over.
387
+ const containerOver = async ({ sourceKey, fileIndex, tailBytes = 0 }) => {
388
+ const record = sourceRegistry.get(sourceKey);
389
+ if (!record) {
390
+ return null;
391
+ }
392
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
393
+ const file = torrent?.files?.[fileIndex];
394
+ if (!file || !(file.length > 0)) {
395
+ return null;
396
+ }
397
+ try {
398
+ await torrentPool.prefetchFileEdges(torrent, fileIndex, {
399
+ headBytes: CONTAINER_HEAD_BYTES,
400
+ tailBytes,
401
+ timeoutMs: 60_000
402
+ });
403
+ } catch {
404
+ // A prefetch that failed is not a reason to skip the read: the read
405
+ // fetches what it needs itself, only more slowly.
406
+ }
407
+ return {
408
+ sourceKey,
409
+ fileIndex,
410
+ readRange: (start, end) =>
411
+ torrentPool.readRangeOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
412
+ fileSize: file.length,
413
+ label: String(file.name ?? ""),
414
+ portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
415
+ };
416
+ };
417
+ const keyframeTables = new KeyframeTables({
418
+ // Read by the same container that answers the track table and the media
419
+ // info, from the same header, and now in the same thread as the session
420
+ // that is waiting for it.
421
+ readTable: async ({ sourceKey, fileIndex }) => {
422
+ // Both edges: the Cues of a Matroska file are at the end.
423
+ const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
424
+ if (!params) {
425
+ return null;
426
+ }
427
+ const index = await containerOrchestrator.getKeyframeIndex(params);
428
+ return {
429
+ times: index?.times ?? null,
430
+ tolerance: index?.tolerance ?? 0,
431
+ // Which container answered, whether or not it produced a table: the
432
+ // refusal that follows names it, and a measurement of how often an
433
+ // index disagrees with its own file cannot be read without it.
434
+ format: (await containerOrchestrator.getContainer(params))?.formatName ?? "unrecognised"
435
+ };
436
+ }
437
+ });
438
+ const outputParts = wireOutputs({
439
+ enabled: transcodeAudio,
440
+ keyframeTables,
441
+ ffmpegBin,
442
+ localBindHost: host,
443
+ localPort: selectedPort,
444
+ videoEncoder,
445
+ calibration,
446
+ audioCalibration,
447
+ decodeCostModel,
448
+ contentionPenalties,
449
+ copySpeedX,
450
+ startStopCost,
451
+ tonemapSupported,
452
+ segmentFormatId: segmentFormat,
453
+ stateDir,
454
+ diagnostics,
455
+ // Live download stats accessor for the realtime budget: lets it tell a
456
+ // CPU-bound transcode from a download-starved input before downscaling.
457
+ // What every torrent here has moved, so the proxy can price its own
458
+ // downloading, hashing and delivery against the machine (roadmap item 7).
459
+ // What the spilled pieces weigh on the torrent thread, and how to tell them
460
+ // their share of the disk. The owner of the disk is on this side, where the
461
+ // segments are; the pieces are on the other.
462
+ spillDisk: typeof torrentPool.allowSpillBytes === "function"
463
+ ? {
464
+ held: () => torrentPool.spilledBytes ?? 0,
465
+ allow: (bytes) => torrentPool.allowSpillBytes(bytes)
466
+ }
467
+ : null,
468
+ // Files downloaded whole, kept as files. They live on the torrent thread
469
+ // too, and until 2026-09-14 they had no bound of any kind — a 2.8 GB film
470
+ // on a host whose disk is often a 32 GB card.
471
+ wholeFiles: typeof torrentPool.allowWholeFileBytes === "function"
472
+ ? {
473
+ held: () => torrentPool.wholeFileBytes ?? 0,
474
+ allow: (bytes) => torrentPool.allowWholeFileBytes(bytes),
475
+ root: completedFilesRoot()
476
+ }
477
+ : null,
478
+ // The pieces held IN memory, on the torrent thread. Told their share by the
479
+ // same owner that divides the disk: what does not fit here is spilled
480
+ // there, so the two cannot be divided apart.
481
+ memoryClaimant: typeof torrentPool.allowMemoryBytes === "function"
482
+ ? {
483
+ held: () => torrentPool.memoryClaim?.held ?? 0,
484
+ wanted: () => torrentPool.memoryClaim?.wanted ?? 0,
485
+ allow: (bytes) => torrentPool.allowMemoryBytes(bytes)
486
+ }
487
+ : null,
488
+ diagnosticsRoot: stateDir || "",
489
+ budgetPolicy,
490
+ getTorrentTotals: async () => {
491
+ if (typeof torrentPool.getTorrentTotals !== "function") {
492
+ return null;
493
+ }
494
+ return torrentPool.getTorrentTotals();
495
+ },
496
+ // The priority map, on its way to the downloading. It lives in another
497
+ // thread, so the map crosses the worker channel; what it does with it —
498
+ // seconds into bytes, what to ask the swarm for, what to keep in memory —
499
+ // is its own business.
500
+ setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
501
+ const record = sourceRegistry.get(sourceKey);
502
+ if (!record) {
503
+ return;
504
+ }
505
+ try {
506
+ await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
507
+ } catch {
508
+ // Best effort: the map is republished on the next change, and the
509
+ // downloading goes on serving reads meanwhile.
510
+ }
511
+ },
512
+ getSourceStats: async (sourceKey, fileIndex) => {
513
+ const record = sourceRegistry.get(sourceKey);
514
+ if (!record) {
515
+ return null;
516
+ }
517
+ try {
518
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
519
+ // Awaited for the same reason as the stats route: this now crosses a
520
+ // thread boundary and returns a promise.
521
+ return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
522
+ } catch {
523
+ return null;
524
+ }
525
+ },
526
+ // Reuse the media info the planner already probed for this file (same
527
+ // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
528
+ // only at session-create time, after playbackPlanner is initialised.
529
+ getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
530
+ // The file's audio tracks, for the master playlist's rendition group. Already
531
+ // probed for the browser's audio menu; read from there rather than probed again.
532
+ getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
533
+ // What a file declares about itself, read by the container layer from the
534
+ // same header its track table comes from. This is how the session learns
535
+ // where a soundtrack shipped as its own file begins — it used to spawn an
536
+ // ffmpeg over this proxy's own HTTP to ask the same question of the same
537
+ // bytes, and that read cost 8.1 s of every cold start (field 2026-09-03).
538
+ getContainerMediaInfo: async ({ sourceKey, fileIndex }) => {
539
+ try {
540
+ const params = await containerOver({ sourceKey, fileIndex });
541
+ return params ? await containerOrchestrator.getMediaInfo(params) : null;
542
+ } catch {
543
+ return null;
544
+ }
545
+ },
546
+ // Pull one whole file onto the disk. Used for a soundtrack that ships beside
547
+ // the picture, once the encoder is as far ahead of the viewer as it is
548
+ // allowed to get — the one moment the swarm's capacity is demonstrably
549
+ // spare. A bounded read of the file's whole length, NOT `file.select()`:
550
+ // selecting a file alongside the readers' own windows is what made a seek
551
+ // wait 93 s while the swarm fetched 2.47 GB in file order (see
552
+ // `#syncSelections` in `torrent/torrent-pool.js`).
553
+ fetchWholeFile: async ({ sourceKey, fileIndex }) => {
554
+ const record = sourceRegistry.get(sourceKey);
555
+ if (!record) {
556
+ return;
557
+ }
558
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
559
+ // The same background fill the warm-up starts when a file is chosen, not a
560
+ // second way of doing it. It is guarded against running twice on one file,
561
+ // so the two triggers converge instead of putting two readers on the same
562
+ // soundtrack — and only one of them would have stood aside for the
563
+ // picture. This trigger remains for the session that never had a warm-up
564
+ // before it.
565
+ await torrentPool.fillFileInBackground?.(torrent, fileIndex);
566
+ }
567
+ });
568
+ // What the last life of this process left on the disk. The kernel kills this
569
+ // one often enough for that to be an ordinary state rather than an odd one —
570
+ // twice in a single viewing on 2026-09-02 — and when it does, no exit handler
571
+ // runs and nothing is cleared up. So this is both the cleanup and the only
572
+ // record that those encoders ended at all: it says what it found before it
573
+ // decides anything, keeps the segments whose closure is proven, and removes
574
+ // the one piece per output that was being written when the process died.
575
+ outputParts.lifecycle.adoptSegmentsLeftBehind();
576
+ const playbackPlanner = createPlaybackPlanner({
577
+ ffmpegBin,
578
+ transcodeAudioEnabled: transcodeAudio,
579
+ localBaseUrl: outputParts.localBaseUrl,
580
+ sourceRegistry,
581
+ torrentPool,
582
+ sidecarsFromTorrent: (torrent, fileIndex) => contentsOf(torrent).sidecarsOf(fileIndex),
583
+ // What a file declares about its own tracks, parsed on this thread from the
584
+ // header the swarm delivered. The planner used to ask the torrent pool for
585
+ // this, which meant the media layer asking the torrent layer to parse a
586
+ // container on its behalf, in the other thread, with the answer carried
587
+ // back over the channel.
588
+ declaredTracksOf: async ({ sourceKey, fileIndex }) => {
589
+ const params = await containerOver({ sourceKey, fileIndex });
590
+ return params ? await containerOrchestrator.getTracks(params) : [];
591
+ },
592
+ warmKeyframeIndex: (params) => keyframeTables.warm(params),
593
+ expectedFirstSegmentMs: () => outputParts.hostTimings.expectedFirstSegmentMs(),
594
+ expectedSessionCreateMs: () => outputParts.hostTimings.expectedSessionCreateMs(),
595
+ // The quality menu is on screen from the moment a file is opened, so the
596
+ // heights this host can actually serve have to be answerable before any
597
+ // encoder exists — from the probe and the startup benchmarks alone.
598
+ predictOfferedHeights: (mediaInfo) => outputParts.quality.predictOfferedHeights(mediaInfo)
599
+ });
600
+
601
+ app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
602
+ app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
603
+ // Off unless --delivery-sink was given; see the route for why it exists.
604
+ app.get("/api/delivery-sink", async (req, reply) =>
605
+ handleApiDeliverySinkGet(req, reply, { enabled: deliverySink === true })
606
+ );
607
+ // Bytes a browser can time its link with before any film has been chosen.
608
+ app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
609
+ app.post("/api/sources", async (req, reply) =>
610
+ handleApiSourcesPost(req, reply, { sourceRegistry })
611
+ );
612
+ app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
613
+ handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
614
+ );
615
+ app.get("/api/sources/:sourceKey/files", async (req, reply) =>
616
+ handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
617
+ );
618
+ app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
619
+ handleApiSourceWarmPost(req, reply, {
620
+ sourceRegistry,
621
+ torrentPool,
622
+ playbackPlanner,
623
+ // How long the file runs, read on this thread from the header. The warm
624
+ // turns a position in seconds into a byte offset and needs it; it used to
625
+ // read the container itself, in the torrent thread, to find out.
626
+ durationOf: async (params) => {
627
+ const over = await containerOver(params);
628
+ return over ? (await containerOrchestrator.getMediaInfo(over))?.durationSeconds ?? null : null;
629
+ }
630
+ })
631
+ );
632
+ // The browser's own log, kept beside the proxy's. See the route's own file
633
+ // for why it is here and not only on the registry server.
634
+ const clientLogs = logFile ? createClientLogFiles(logFile) : null;
635
+ app.post("/api/client-logs", async (req, reply) =>
636
+ handleApiClientLogsPost(req, reply, { clientLogs })
637
+ );
638
+ app.post("/api/playback-plan", async (req, reply) =>
639
+ handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
640
+ );
641
+ app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
642
+ handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
643
+ );
644
+ app.get("/api/subtitles", async (req, reply) =>
645
+ handleApiSubtitlesGet(req, reply, {
646
+ sourceRegistry,
647
+ torrentPool,
648
+ ffmpegBin,
649
+ localBaseUrl: outputParts.localBaseUrl,
650
+ viewers: outputParts.viewers,
651
+ subtitles
652
+ })
653
+ );
654
+ app.get("/stream", async (req, reply) =>
655
+ handleStreamGet(req, reply, {
656
+ sourceRegistry,
657
+ torrentPool,
658
+ // So a session that has produced nothing yet can still show it is being
659
+ // fed. The route knows only files; the session id rides on the URL the
660
+ // session itself built.
661
+ noteInputBytes: (sessionId, bytes) => outputParts.encodeRuns.noteInputBytes(sessionId, bytes),
662
+ // When the encoder reading this waits for its input, so its speed is
663
+ // measured over its own work and not over the swarm's.
664
+ noteInputWaiting: (runToken, waiting) => outputParts.encodeRuns.noteInputWaiting(runToken, waiting)
665
+ })
666
+ );
667
+ app.post("/api/transcode-sessions", async (req, reply) =>
668
+ handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool })
669
+ );
670
+ app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
671
+ handleApiTranscodeSessionReleasePost(req, reply, { lifecycle: outputParts.lifecycle })
672
+ );
673
+ app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
674
+ handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests: outputParts.viewerRequests })
675
+ );
676
+ app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
677
+ // A viewer's statement about itself goes to the VIEWER layer, not through
678
+ // the session manager: what it needs is the live sessions and the registry
679
+ // of viewers, and nothing about encoding.
680
+ handleApiTranscodeSessionNetReportPost(req, reply, {
681
+ outputs: outputParts.outputs,
682
+ viewers: outputParts.viewers,
683
+ renditions: outputParts.renditions,
684
+ quality: outputParts.quality
685
+ })
686
+ );
687
+ app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
688
+ handleApiTranscodeSessionFragmentFarPost(req, reply, { serving: outputParts.serving })
689
+ );
690
+ app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
691
+ handleApiTranscodeSessionSeekPost(req, reply, { viewerRequests: outputParts.viewerRequests })
692
+ );
693
+ app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
694
+ handleTranscodeSessionFileGet(req, reply, { serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
695
+ );
696
+ // A quality variant's files. Registered before the static handler for the
697
+ // same reason as the line above, and kept a separate route rather than a
698
+ // wildcard so the height stays a parsed parameter.
699
+ // Registered BEFORE the variant file route: `warm` is not a file name, and
700
+ // Fastify matches a static segment ahead of a parameter either way — stated
701
+ // here so the order is not "tidied" into a bug.
702
+ app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
703
+ handleTranscodeVariantWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
704
+ );
705
+ app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
706
+ handleTranscodeAudioWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
707
+ );
708
+ app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
709
+ handleTranscodeAudioFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
710
+ );
711
+ app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
712
+ handleTranscodeVariantFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
713
+ );
714
+ await app.register(fastifyStatic, {
715
+ root: publicRoot,
716
+ prefix: "/",
717
+ serveDotFiles: true
718
+ });
719
+
720
+ app.addHook("onClose", async () => {
721
+ // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
722
+ // the torrents whose files they read from, then remove the torrent data.
723
+ await outputParts.lifecycle.disposeAll();
724
+ await torrentPool.destroyAll();
725
+ });
726
+
727
+ await app.listen({ host, port: selectedPort });
728
+ return {
729
+ app,
730
+ port: selectedPort,
731
+ // Asked over the tunnel when the proxy a viewer landed on has refused their
732
+ // file: could THIS host sustain it? Answered from the startup benchmarks
733
+ // and a description, so it needs no torrent and costs milliseconds.
734
+ outputParts,
735
+ // The browser only ever knows a source by its REGISTRY key (a hash of the
736
+ // raw request bytes, scoped to one API session) — never the torrent
737
+ // pool's own key (the content's infohash, shared across a magnet and a
738
+ // `.torrent` naming the same film). The subtitle push subscription is
739
+ // recorded from a browser request and published from the pool's side, so
740
+ // resolving one into the other is what lets the two ends agree on what
741
+ // they are both calling "sourceKey".
742
+ sourceRegistry,
743
+ // Which films this host holds, for the health poll the browser makes before
744
+ // it picks a proxy. The pool is on the worker thread and this is the way to
745
+ // it from the process that answers that poll.
746
+ torrentPool
747
+ };
748
+ }