@torrent-tv/proxy 2.88.4 → 2.88.5

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