@torrent-tv/proxy 2.87.3 → 2.87.4

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