@torrent-tv/proxy 2.87.5 → 2.87.7

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 (463) 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 +8 -0
  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 +101 -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 +1 -1
  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/audio-tracks/post.js +29 -0
  85. package/routes/api/playback-plan/post.js +53 -53
  86. package/routes/api/sources/files/get.js +100 -100
  87. package/routes/api/sources/post.js +36 -36
  88. package/routes/api/sources/stats/get.js +132 -132
  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 +4 -0
  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 +245 -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 +131 -59
  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 +40 -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 +2 -2
  239. package/services/torrent/worker/background-fill.js +213 -213
  240. package/services/torrent/worker/channel.js +271 -271
  241. package/services/torrent/worker/fastest-wires.js +342 -342
  242. package/services/torrent/worker/handle-state.js +23 -23
  243. package/services/torrent/worker/held-bytes.js +155 -155
  244. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  245. package/services/torrent/worker/piece-reader.js +1078 -1078
  246. package/services/torrent/worker/pool-adapter.js +1 -1
  247. package/services/torrent/worker/resume-warm.js +107 -107
  248. package/services/torrent/worker/webrtc-shim.js +219 -219
  249. package/services/transport/data-channel-handler.js +1356 -1356
  250. package/services/transport/delivery-probe.js +842 -842
  251. package/services/transport/health-collector.js +53 -53
  252. package/services/transport/nat-classifier.js +231 -231
  253. package/services/transport/packet-witness.js +808 -808
  254. package/services/transport/port-mapper.js +235 -235
  255. package/services/transport/registry-api.js +81 -81
  256. package/services/transport/tunnel-client.js +447 -447
  257. package/services/transport/usrsctp-state.js +175 -175
  258. package/services/transport/webrtc-manager.js +484 -484
  259. package/services/viewer/Assignments.js +340 -340
  260. package/services/viewer/PriorityMap.js +390 -390
  261. package/services/viewer/PriorityOrchestrator.js +325 -325
  262. package/services/viewer/Viewer.js +657 -657
  263. package/services/viewer/Viewers.js +368 -368
  264. package/services/viewer/active-output.js +37 -37
  265. package/services/viewer/audio-start.js +46 -46
  266. package/services/viewer/buffer-trend.js +81 -81
  267. package/services/viewer/choices.js +647 -647
  268. package/services/viewer/positions.js +126 -126
  269. package/services/viewer/report-intake.js +46 -46
  270. package/store/source-registry.js +71 -71
  271. package/test/asset-paths.test.js +28 -28
  272. package/test/audio-inventory.test.js +212 -176
  273. package/test/audio-track-bitrate.test.js +40 -40
  274. package/test/auto-quality-step.test.js +707 -707
  275. package/test/available-share.test.js +76 -76
  276. package/test/background-fill.test.js +190 -190
  277. package/test/client-log-file.test.js +110 -110
  278. package/test/completed-files.test.js +115 -115
  279. package/test/concurrent-cost.test.js +148 -148
  280. package/test/container-media-info.test.js +228 -228
  281. package/test/contention.test.js +76 -76
  282. package/test/core-dumps.test.js +57 -57
  283. package/test/coverage-follows-the-disk.test.js +228 -228
  284. package/test/coverage-map.test.js +195 -195
  285. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  286. package/test/cut-times-timeline.test.js +65 -65
  287. package/test/cuts-follow-published-grid.test.js +109 -109
  288. package/test/data-channel-frame.test.js +63 -63
  289. package/test/dead-torrent-handle.test.js +52 -52
  290. package/test/declared-rates.test.js +116 -116
  291. package/test/declared-tracks.test.js +35 -35
  292. package/test/decode-cost-fit.test.js +202 -202
  293. package/test/decode-cost.test.js +462 -462
  294. package/test/delivery-probe.test.js +393 -393
  295. package/test/demand-register.test.js +206 -206
  296. package/test/dht-bootstrap.test.js +72 -72
  297. package/test/drain-threat.test.js +65 -65
  298. package/test/duplicate-add.test.js +48 -48
  299. package/test/encode-admission.test.js +146 -146
  300. package/test/encode-cost.test.js +283 -283
  301. package/test/encode-exit.test.js +111 -111
  302. package/test/encode-orchestrator.test.js +419 -419
  303. package/test/encode-plan-viewers.test.js +718 -718
  304. package/test/encode-plan.test.js +545 -545
  305. package/test/encode-run-state.test.js +314 -314
  306. package/test/encode-run.test.js +161 -161
  307. package/test/encode-slope.test.js +46 -46
  308. package/test/encoded-output.test.js +39 -39
  309. package/test/encoder-kinds.test.js +122 -122
  310. package/test/encoder-readings.test.js +46 -46
  311. package/test/every-claimant-is-told-its-share.test.js +152 -152
  312. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  313. package/test/failed-start-breaker.test.js +202 -202
  314. package/test/fastest-wires.test.js +143 -143
  315. package/test/ffmpeg-stream-counts.test.js +78 -78
  316. package/test/file-edges.test.js +191 -191
  317. package/test/fmp4-self-contained.test.js +80 -80
  318. package/test/fmp4-timestamps.test.js +140 -140
  319. package/test/fmp4-track-completeness.test.js +41 -41
  320. package/test/format-keyed-outputs.test.js +78 -78
  321. package/test/health-metrics.test.js +38 -38
  322. package/test/held-request-width.test.js +157 -157
  323. package/test/helpers/encode-run.js +136 -136
  324. package/test/helpers/held-file.js +29 -29
  325. package/test/helpers/manager.js +57 -57
  326. package/test/helpers/output-spec.js +63 -63
  327. package/test/host-load.test.js +68 -68
  328. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  329. package/test/init-compat.test.js +219 -219
  330. package/test/input-lost-quiets-the-plan.test.js +261 -261
  331. package/test/input-unavailable.test.js +48 -48
  332. package/test/installation-id.test.js +39 -39
  333. package/test/keeping-period.test.js +83 -83
  334. package/test/keyframe-index-accuracy.test.js +188 -188
  335. package/test/keyframe-table-budget.test.js +102 -102
  336. package/test/keyframes-belong-to-the-file.test.js +249 -249
  337. package/test/learned-median.test.js +37 -37
  338. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  339. package/test/limit-row-by-area.test.js +60 -60
  340. package/test/link-budget.test.js +111 -111
  341. package/test/link-probe.test.js +56 -56
  342. package/test/local-encoding-data.test.js +273 -273
  343. package/test/logger-repeats.test.js +134 -134
  344. package/test/look-ahead-coverage.test.js +56 -56
  345. package/test/manager-surface.test.js +292 -292
  346. package/test/matroska-cues-track.test.js +192 -192
  347. package/test/memory-budget.test.js +390 -390
  348. package/test/move-cost.test.js +216 -216
  349. package/test/mp4-subtitles.test.js +173 -173
  350. package/test/one-authority.test.js +285 -285
  351. package/test/one-budget.test.js +149 -149
  352. package/test/one-output-one-session.test.js +204 -204
  353. package/test/one-resource-divided.test.js +185 -185
  354. package/test/one-serving-output-per-height.test.js +458 -458
  355. package/test/orchestrator-wired.test.js +206 -206
  356. package/test/output-catalog.test.js +39 -39
  357. package/test/output-shape.test.js +21 -21
  358. package/test/output-spec.test.js +251 -251
  359. package/test/output-speed.test.js +88 -88
  360. package/test/outputs-being-prepared.test.js +40 -40
  361. package/test/packet-witness-ring.test.js +236 -236
  362. package/test/packet-witness.test.js +148 -148
  363. package/test/piece-disk-store.test.js +381 -381
  364. package/test/piece-from-whole-file.test.js +129 -129
  365. package/test/piece-lru.test.js +319 -319
  366. package/test/piece-reader.test.js +162 -162
  367. package/test/piece-selection-offset.test.js +85 -85
  368. package/test/piece-store-eviction.test.js +560 -560
  369. package/test/piece-store-never-refuses.test.js +153 -153
  370. package/test/piece-store-reservations.test.js +318 -318
  371. package/test/piece-store-slow-disk.test.js +159 -159
  372. package/test/piece-tail.test.js +97 -97
  373. package/test/plan-host-timings.test.js +68 -68
  374. package/test/plan-is-steady.test.js +225 -225
  375. package/test/priority-map-download.test.js +174 -174
  376. package/test/priority-map-emptied.test.js +204 -204
  377. package/test/priority-map-per-output.test.js +215 -215
  378. package/test/priority-map.test.js +163 -163
  379. package/test/probe-wedge-certainty.test.js +60 -60
  380. package/test/produced-copy-choice.test.js +213 -213
  381. package/test/published-timeline.test.js +83 -83
  382. package/test/quality-offer.test.js +162 -162
  383. package/test/quality-variants.test.js +1229 -1229
  384. package/test/rate-control-in-the-output.test.js +180 -180
  385. package/test/read-survives-withdrawal.test.js +164 -164
  386. package/test/read-window.test.js +365 -365
  387. package/test/resume-warm.test.js +39 -39
  388. package/test/run-graph-drift.test.js +37 -37
  389. package/test/run-inputs.test.js +80 -80
  390. package/test/run-intervals.test.js +100 -100
  391. package/test/run-position-follows-published-grid.test.js +89 -89
  392. package/test/run-progress.test.js +65 -65
  393. package/test/same-height-switch.test.js +664 -664
  394. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  395. package/test/seek-landing.test.js +109 -109
  396. package/test/seek-target-not-superseded.test.js +195 -195
  397. package/test/segment-closed-on-both-branches.test.js +96 -96
  398. package/test/segment-cut-times.test.js +63 -63
  399. package/test/segment-demand.test.js +80 -80
  400. package/test/segment-hold-supersede.test.js +91 -91
  401. package/test/segment-judged-by-its-own-header.test.js +221 -221
  402. package/test/segment-serve-wiring.test.js +475 -475
  403. package/test/segment-store-eviction.test.js +247 -247
  404. package/test/segment-store.test.js +323 -323
  405. package/test/segment-true-start.test.js +86 -86
  406. package/test/segments-are-shared.test.js +176 -176
  407. package/test/send-chunk-bytes.test.js +86 -86
  408. package/test/serving-existing-output.test.js +102 -102
  409. package/test/serving-output.test.js +102 -102
  410. package/test/shared-piece-store.test.js +310 -310
  411. package/test/sidecar-naming.test.js +142 -142
  412. package/test/source-file.test.js +133 -133
  413. package/test/source-files-route.test.js +103 -103
  414. package/test/source-registry.test.js +18 -18
  415. package/test/split-a-stretch.test.js +139 -139
  416. package/test/stale-request-after-seek.test.js +158 -158
  417. package/test/startup-readings.test.js +124 -124
  418. package/test/stream-route.test.js +182 -182
  419. package/test/subtitle-cue-framing.test.js +200 -200
  420. package/test/subtitle-cue-source.test.js +102 -102
  421. package/test/subtitle-cue-walk.test.js +370 -370
  422. package/test/subtitle-cursor.test.js +63 -63
  423. package/test/subtitle-defaults.test.js +118 -118
  424. package/test/subtitle-language.test.js +252 -252
  425. package/test/subtitle-track-numbering.test.js +386 -386
  426. package/test/supply-margin.test.js +177 -177
  427. package/test/swarm-follows-readers.test.js +174 -174
  428. package/test/swarm-reach.test.js +130 -130
  429. package/test/swarm-selection.test.js +220 -220
  430. package/test/tail-duplication.test.js +167 -167
  431. package/test/the-channel-has-both-ends.test.js +91 -91
  432. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  433. package/test/thread-pool.test.js +53 -53
  434. package/test/timeline.test.js +84 -84
  435. package/test/torrent-contents.test.js +235 -235
  436. package/test/torrent-cost.test.js +71 -71
  437. package/test/torrent-files.test.js +221 -221
  438. package/test/torrent-naming.test.js +255 -255
  439. package/test/tracks-begin-together.test.js +209 -209
  440. package/test/tunnel-renewal.test.js +167 -167
  441. package/test/two-viewers-one-picture.test.js +407 -407
  442. package/test/upload-hurry.test.js +186 -186
  443. package/test/usrsctp-state.test.js +131 -131
  444. package/test/video-facts.test.js +102 -102
  445. package/test/viewer-assignments.test.js +143 -143
  446. package/test/viewer-outputs.test.js +285 -285
  447. package/test/viewer-presence.test.js +116 -116
  448. package/test/viewer-requests-admission.test.js +89 -89
  449. package/test/viewer-states.test.js +189 -189
  450. package/test/viewer-subtitle-subscription.test.js +96 -96
  451. package/test/viewer.test.js +139 -139
  452. package/test/viewing-generations.test.js +390 -390
  453. package/test/visible-rung.test.js +37 -37
  454. package/test/wait-ledger.test.js +100 -100
  455. package/test/webrtc-shim.test.js +149 -149
  456. package/test/wedge-certainty.test.js +131 -131
  457. package/test/withdraw-piece-claim.test.js +212 -212
  458. package/test/worker-channel.test.js +158 -158
  459. package/utils/client-log-file.js +151 -151
  460. package/utils/logger.js +312 -312
  461. package/utils/parse-range.js +25 -25
  462. package/utils/perf.js +59 -59
  463. package/{services/torrent → utils}/torrent-source-key.js +38 -38
@@ -1,525 +1,525 @@
1
- /**
2
- * What the proxy's own work costs this host, read while it runs.
3
- *
4
- * The torrent's CPU per megabyte it moves, the share of a core this process
5
- * draws doing nothing priced, how fast each watched torrent is moving, the speed
6
- * each file's supply demands, how long each file is, and how much of the machine
7
- * a new encoder could have. The encoders' own cost is `EncodeCost`'s.
8
- */
9
-
10
- import os from "node:os";
11
- import { logger } from "../../../utils/logger.js";
12
- import { availableShareFrom } from "../available-share.js";
13
- import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
14
- import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learned-median.js";
15
- import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
16
- import { SourceFiles } from "../../media/SourceFile.js";
17
-
18
- // How far ahead of its own read head a reader asks the swarm for, expressed in
19
- // seconds of PLAYBACK. The torrent thread can only think in bytes, and a fixed
20
- // byte window is wrong at both ends of the range: 32 MB is half a minute of a
21
- // 1080p film and about four seconds of a disc remux. Duration and file size are
22
- // both known here, so the window is sized where the knowledge is and sent down
23
- // on the ffmpeg input URL.
24
- const READ_WINDOW_SECONDS = 30;
25
- // Bounds, so a wrong or unusual byte rate cannot ask for something absurd. The
26
- // floor keeps a few pieces in flight on a low-bitrate file; the ceiling keeps
27
- // one reader from claiming more than a fraction of the piece store.
28
- const READ_WINDOW_MIN_BYTES = 16 * 1024 * 1024;
29
- const READ_WINDOW_MAX_BYTES = 96 * 1024 * 1024;
30
-
31
- export class HostLoad {
32
- /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
33
- #host;
34
-
35
- /** How much of the machine a new encoder could have, from the last reading; undefined before one. */
36
- hostAvailability;
37
-
38
- /** The previous reading of the machine, to compare the next one against. */
39
- #hostLoadSample = null;
40
-
41
- /** The previous reading taken while nothing was encoding, for the torrent's own cost. */
42
- #idleLoadSample = null;
43
-
44
- /**
45
- * How long each file is in bytes, from the stats call the read window already
46
- * makes. The torrent moves the CONTAINER, so this — not the video stream's
47
- * bitrate — is what its work should be priced against.
48
- *
49
- * @type {Map<string, number>}
50
- */
51
- fileLengthByKey = new Map();
52
-
53
- /** Seconds of this process's CPU per megabyte the torrent moves, once measured. */
54
- observedTorrentCostPerMegabyte = null;
55
-
56
- /** @type {number[]} Recent readings behind that median. */
57
- #torrentCostReadings = [];
58
-
59
- /**
60
- * The share of one core this process draws with nothing encoding and the
61
- * torrents moving nothing — the spending that would have happened anyway, and
62
- * which must come off a reading before the rest is called the torrent's.
63
- */
64
- #observedBaseDraw = null;
65
-
66
- /** @type {number[]} Recent readings behind that median. */
67
- #baseDrawReadings = [];
68
-
69
- /**
70
- * What each watched TORRENT is measured to be moving right now, in bytes per
71
- * second, keyed by source. Rebuilt every budget tick from the live sessions,
72
- * so an entry that is present was taken this tick.
73
- *
74
- * @type {Map<string, number>}
75
- */
76
- #downloadRateByKey = new Map();
77
-
78
- /**
79
- * The speed each file's own interruptions were last measured to demand,
80
- * keyed `sourceKey:fileIndex`. Kept per source rather than per session
81
- * because the first offer for a file is made before any session of it exists,
82
- * and a file that has been watched before has already told the reader what
83
- * its swarm does.
84
- *
85
- * @type {Map<string, number>}
86
- */
87
- #requiredSpeedByKey = new Map();
88
-
89
- /** Last complete supply reading for each source file. @type {Map<string, object>} */
90
- #supplyByKey = new Map();
91
-
92
- /**
93
- * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
94
- */
95
- constructor(host) {
96
- this.#host = host;
97
- }
98
-
99
- /**
100
- * The read-ahead window for a file, in bytes, sized from how many seconds of
101
- * playback it holds.
102
- *
103
- * @param {string} sourceKey
104
- * @param {number} fileIndex
105
- * @param {number} durationSeconds
106
- * @returns {Promise<number>} Zero when the byte rate cannot be established,
107
- * which leaves the reader on its own default.
108
- */
109
- async readWindowBytesFor(sourceKey, fileIndex, durationSeconds) {
110
- // The length read here is also what prices the torrent's own work for this
111
- // file, so it is remembered rather than discarded.
112
-
113
- if (!this.#host.getSourceStats || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
114
- return 0;
115
- }
116
- let fileLength = 0;
117
- try {
118
- const stats = await this.#host.getSourceStats(sourceKey, fileIndex);
119
- fileLength = Number(stats?.fileLength);
120
- } catch {
121
- return 0;
122
- }
123
- if (!Number.isFinite(fileLength) || fileLength <= 0) {
124
- return 0;
125
- }
126
- this.fileLengthByKey.set(SourceFiles.keyFor(sourceKey, fileIndex), fileLength);
127
- const bytesPerSecond = fileLength / durationSeconds;
128
- // Shared between the readers this file already has. The window is stated in
129
- // seconds of playback and the store's memory is one budget for the whole
130
- // torrent, so N readers asking for thirty seconds each ask for N times what
131
- // was provided for — and on 2026-08-15 that is exactly what happened: a
132
- // viewer with a picture and an audio track had every resident piece held at
133
- // once, a read ended with zero bytes, and every encoder on the file took
134
- // that for the end of it.
135
- //
136
- // Dividing keeps the promise the budget was written against. It is not the
137
- // sliding window of roadmap item 8 — pieces still leave only by the store's
138
- // own eviction — but it removes the multiplication that broke it.
139
- const readers = Math.max(1, this.#readersOn(sourceKey, fileIndex));
140
- const wanted = Math.round((bytesPerSecond * READ_WINDOW_SECONDS) / readers);
141
- return Math.min(READ_WINDOW_MAX_BYTES, Math.max(READ_WINDOW_MIN_BYTES, wanted));
142
- }
143
-
144
- /**
145
- * How many live sessions read this file: the picture, any rung being warmed
146
- * beside it, and any audio track published on its own.
147
- *
148
- * @param {string} sourceKey
149
- * @param {number} fileIndex
150
- * @returns {number}
151
- */
152
- #readersOn(sourceKey, fileIndex) {
153
- let readers = 0;
154
- for (const session of this.#host.outputs.values()) {
155
- if (session?.file.sourceKey === sourceKey && session.file.fileIndex === fileIndex) {
156
- readers += 1;
157
- }
158
- }
159
- return readers;
160
- }
161
-
162
- /**
163
- * What the torrent itself costs this machine, per megabyte it moves.
164
- *
165
- * Downloading, verifying every piece and pushing segments down a data channel
166
- * are work on the same box as the encoder, they scale with the file's own
167
- * bitrate, and the budget counts none of it. Measured on the addon host with
168
- * every encoder suspended, the machine was still 20-29 % busy.
169
- *
170
- * Taken only while NOTHING is encoding, because that is the only moment the
171
- * spending can be attributed without arithmetic: what this process uses then
172
- * is the torrent's.
173
- */
174
- async #learnTorrentCost() {
175
- const now = {
176
- takenAt: Date.now(),
177
- cpuSeconds: this.#host.readProxyCpuSeconds(),
178
- bytes: await this.#torrentBytesMoved()
179
- };
180
- const previous = this.#idleLoadSample;
181
- this.#idleLoadSample = now;
182
- if (previous === null || now.bytes === null || previous.bytes === null) {
183
- return;
184
- }
185
- const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
186
- const megabytes = (now.bytes - previous.bytes) / 1e6;
187
- // Divided by the cores, because `process.cpuUsage()` adds up every thread
188
- // while everything this figure is later added to is measured in WALL
189
- // seconds per second of video. Left undivided on the four-core addon host
190
- // it overstated the torrent by four times, which on the field's own rung
191
- // was the difference between offering it and refusing it.
192
- const cores = Math.max(1, os.cpus().length);
193
- const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / cores;
194
- if (megabytes === 0) {
195
- // Nothing encoding and not one byte moved: whatever this process spent in
196
- // that interval, it spends whether or not there is a torrent. Measuring
197
- // it is what lets the next interval be attributed instead of divided
198
- // whole — see `encode/torrent-cost.js` for the readings that forced this.
199
- this.#learnBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
200
- return;
201
- }
202
- const costPerMegabyte = costPerMegabyteFrom({
203
- cpuSeconds,
204
- elapsedSeconds: elapsedSec,
205
- megabytes,
206
- baseDraw: this.#observedBaseDraw,
207
- // How much the draw's own readings disagree, which is how much of this
208
- // interval's remainder means nothing.
209
- drawScatter: scatterOf(this.#baseDrawReadings)
210
- });
211
- if (costPerMegabyte === null) {
212
- return;
213
- }
214
- const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-READINGS_KEPT);
215
- this.#torrentCostReadings = readings;
216
- const median = medianOf(readings);
217
- if (!movedBeyondScatter(this.observedTorrentCostPerMegabyte, median, readings)) {
218
- return;
219
- }
220
- this.observedTorrentCostPerMegabyte = median;
221
- logger.info(
222
- `host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
223
- `(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB, ` +
224
- `base draw ${((this.#observedBaseDraw ?? 0) * 100).toFixed(1)}% of a core already taken off)`
225
- );
226
- }
227
-
228
- /**
229
- * What each watched file's torrent is moving right now.
230
- *
231
- * The torrent is priced per megabyte it moves, so the price has to be charged
232
- * against the megabytes it IS moving. Charged against the file's own byte
233
- * rate — what the viewer consumes — it asks for payment on a fully downloaded
234
- * file that is moving nothing, and it under-charges a file being fetched
235
- * ahead of the viewer, which is the state every session starts in.
236
- *
237
- * Rebuilt whole each tick from the live sessions, so an entry that is here
238
- * was taken this tick and a source nobody is watching leaves by itself.
239
- *
240
- * @returns {Promise<void>}
241
- */
242
- async sampleDownloadRates() {
243
- if (!this.#host.getSourceStats) {
244
- return;
245
- }
246
- /** @type {Map<string, { sourceKey: string, fileIndex: number }>} */
247
- const wanted = new Map();
248
- for (const session of this.#host.outputs.values()) {
249
- wanted.set(session.file.key, {
250
- sourceKey: session.file.sourceKey,
251
- fileIndex: session.file.fileIndex
252
- });
253
- }
254
- /** @type {Map<string, number>} */
255
- const measured = new Map();
256
- for (const [key, source] of wanted) {
257
- try {
258
- const stats = await this.#host.getSourceStats(source.sourceKey, source.fileIndex);
259
- // The TORRENT's rate, which is what it is: one swarm feeding one
260
- // client, whichever of its files are being read. Kept per source and
261
- // divided among the files being watched, so two episodes of one pack
262
- // do not each charge the machine for the whole download.
263
- const rate = Number(stats?.downloadSpeed);
264
- if (Number.isFinite(rate) && rate >= 0) {
265
- measured.set(source.sourceKey, rate);
266
- }
267
- // Kept, not rebuilt: the demand a swarm made on this file does not stop
268
- // being true when a tick fails to fetch it, and it is what the FIRST
269
- // offer of the next session will be judged against.
270
- const demanded = Number(stats?.supply?.requiredSpeed);
271
- if (Number.isFinite(demanded) && demanded > 0) {
272
- this.#requiredSpeedByKey.set(key, demanded);
273
- }
274
- if (stats?.supply) {
275
- this.#supplyByKey.set(key, stats.supply);
276
- }
277
- } catch {
278
- // The pool is busy or gone. A reading missed is not a fault, and the
279
- // key simply does not appear this tick.
280
- }
281
- }
282
- this.#downloadRateByKey = measured;
283
- }
284
-
285
- /**
286
- * How many files of one torrent have a live session reading them.
287
- *
288
- * @param {string} sourceKey
289
- * @returns {number} At least one, so the rate is never divided by nothing.
290
- */
291
- #filesWatchedOn(sourceKey) {
292
- const files = new Set();
293
- for (const session of this.#host.outputs.values()) {
294
- if (session?.file.sourceKey === sourceKey) {
295
- files.add(session.file.fileIndex);
296
- }
297
- }
298
- return Math.max(1, files.size);
299
- }
300
-
301
- /**
302
- * The speed this file's supply demands, as last measured on this swarm.
303
- *
304
- * @param {string} sourceKey
305
- * @param {number} fileIndex
306
- * @returns {number | null}
307
- */
308
- requiredSpeedFor(sourceKey, fileIndex) {
309
- return this.#requiredSpeedByKey.get(SourceFiles.keyFor(sourceKey, fileIndex)) ?? null;
310
- }
311
-
312
- /** Last complete supply reading for a source file. */
313
- supplyFor(file) {
314
- return this.#supplyByKey.get(SourceFiles.keyFor(file.sourceKey, file.fileIndex)) ?? null;
315
- }
316
-
317
- /**
318
- * How many megabytes a second the torrent is moving for this file — measured
319
- * where a reading exists, and otherwise the rate the file has to be moved at
320
- * to be watched at all (its length over its duration), which is what the
321
- * measured rate averages to over a viewing.
322
- *
323
- * @param {string} sourceKey
324
- * @param {number} fileIndex
325
- * @param {number | null} fileLengthBytes
326
- * @param {number | null} durationSeconds
327
- * @returns {number | null}
328
- */
329
- torrentMegabytesPerSecond(sourceKey, fileIndex, fileLengthBytes, durationSeconds) {
330
- const measured = this.#downloadRateByKey.get(sourceKey);
331
- if (Number.isFinite(measured)) {
332
- return measured / 1e6 / this.#filesWatchedOn(sourceKey);
333
- }
334
- // The FILE's rate, not the video stream's. What the torrent moves is the
335
- // container: on the releases this serves, two or three AC-3 tracks add
336
- // 10-25 % to what the picture alone would suggest.
337
- const fileLength = Number(fileLengthBytes);
338
- const duration = Number(durationSeconds);
339
- if (Number.isFinite(fileLength) && fileLength > 0 && Number.isFinite(duration) && duration > 0) {
340
- return fileLength / duration / 1e6;
341
- }
342
- return null;
343
- }
344
-
345
- /**
346
- * Record what this process draws when it is doing none of the work that gets
347
- * priced.
348
- *
349
- * @param {number | null} share - Of one core, over the interval just read.
350
- * @returns {void}
351
- */
352
- #learnBaseDraw(share) {
353
- if (share === null) {
354
- return;
355
- }
356
- const readings = [...this.#baseDrawReadings, share].slice(-READINGS_KEPT);
357
- this.#baseDrawReadings = readings;
358
- const median = medianOf(readings);
359
- if (!movedBeyondScatter(this.#observedBaseDraw, median, readings)) {
360
- return;
361
- }
362
- this.#observedBaseDraw = median;
363
- logger.info(
364
- `host-load: this process draws ${(median * 100).toFixed(1)}% of a core with nothing encoding and ` +
365
- `nothing downloading (median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
366
- );
367
- }
368
-
369
- /**
370
- * Bytes this proxy's torrents have moved in total, or null when it cannot be
371
- * asked.
372
- *
373
- * @returns {Promise<number | null>}
374
- */
375
- async #torrentBytesMoved() {
376
- if (typeof this.#host.getTorrentTotals !== "function") {
377
- return null;
378
- }
379
- try {
380
- const totals = await this.#host.getTorrentTotals();
381
- // Downloaded bytes only. Every one of them is verified against the piece
382
- // hash and written to the store; a byte sent back to the swarm is neither,
383
- // and adding the two would price both at whatever the mixture happened to
384
- // be on the day.
385
- const downloaded = Number(totals?.downloaded);
386
- return Number.isFinite(downloaded) ? downloaded : null;
387
- } catch {
388
- return null; // the pool is busy or gone; a reading missed is not a fault
389
- }
390
- }
391
-
392
- async reportHostLoad() {
393
- const encoding = [...this.#host.outputs.values()].filter(
394
- (session) => processCanBeSignalled(this.#host.runStateOf(session))
395
- );
396
- const runningNow = encoding.filter((session) => this.#host.runStateOf(session) !== ENCODE_RUN_STATE.SUSPENDED);
397
- if (runningNow.length === 0) {
398
- // No encoder is RUNNING. A suspended one costs nothing, and counting it
399
- // as work meant this was never reached: measured 2026-08-15, four minutes
400
- // in which every encoder was suspended, the torrent's price could have
401
- // been taken, and none was. What this process spends now is the download,
402
- // the verification, the piece store and the delivery. Item 7.
403
- await this.#learnTorrentCost();
404
- this.#hostLoadSample = null;
405
- return;
406
- }
407
- this.#idleLoadSample = null;
408
- // EVERY encoder, added up. One of them is meaningless on a host that runs a
409
- // picture and an audio track at once, and picking the first would have
410
- // reported whichever the map happened to hold.
411
- // Kept per PROCESS, not as one total. The set changes between readings —
412
- // a seek kills ffmpeg and starts another with a new pid whose counter
413
- // begins at zero, a session ends, a rendition begins — and subtracting one
414
- // total from another across a changed set produces nonsense: a restart
415
- // alone would print something like `ffmpeg=-598%`. Only pids present in
416
- // BOTH readings are counted, so a process that came or went contributes
417
- // nothing rather than a lie.
418
- const pids = encoding.flatMap((session) => this.#host.liveRunsOf(session).map((run) => run.process?.pid ?? null)).filter((pid) => pid !== null);
419
- const [system, ...cpuReadings] = await Promise.all([
420
- this.#host.readSystemCpu(),
421
- ...pids.map((pid) => this.#host.readProcessCpuSeconds(pid))
422
- ]);
423
- /** @type {Map<number, number>} */
424
- const byPid = new Map();
425
- pids.forEach((pid, index) => {
426
- const seconds = cpuReadings[index];
427
- if (seconds !== null) {
428
- byPid.set(pid, seconds);
429
- }
430
- });
431
- const sample = {
432
- takenAt: Date.now(),
433
- byPid,
434
- system,
435
- // The proxy's own CPU, across every thread: the torrent, the hashing, the
436
- // piece store and the delivery. None of it is in the encode budget, and
437
- // on the addon host it is most of what the machine does while encoders
438
- // are suspended.
439
- proxyCpuSeconds: this.#host.readProxyCpuSeconds()
440
- };
441
- const previous = this.#hostLoadSample;
442
- this.#hostLoadSample = sample;
443
- if (previous === null) {
444
- return; // the first reading is only something to compare against
445
- }
446
- // Summed over the pids both readings hold, so nothing is measured against a
447
- // process that was not there before. Unknown stays unknown: on a host with
448
- // no `/proc` there are no readings at all, and the share is null rather
449
- // than a confident zero.
450
- let encoderDelta = null;
451
- for (const [pid, seconds] of sample.byPid) {
452
- const before = previous.byPid?.get(pid);
453
- if (before !== undefined && seconds >= before) {
454
- encoderDelta = (encoderDelta ?? 0) + (seconds - before);
455
- }
456
- }
457
- const share = this.#host.shareOfMachine(
458
- { takenAt: previous.takenAt, processCpuSeconds: encoderDelta === null ? null : 0, system: previous.system },
459
- { takenAt: sample.takenAt, processCpuSeconds: encoderDelta, system: sample.system }
460
- );
461
- if (share === null) {
462
- return;
463
- }
464
- // How many of them are stopped by the look-ahead cap. Without this a zero
465
- // share reads as an encoder being starved of the machine, when it is an
466
- // encoder deliberately not running — which is what the first readings on
467
- // the addon host actually were (2026-08-15: `ffmpeg=0% system=24%`, both
468
- // encoders suspended, and the speed beside it a stale figure from before
469
- // they stopped).
470
- const suspended = encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length;
471
- const running = encoding.length - suspended;
472
- const machine = await this.#host.readMachineState();
473
- const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
474
- const cores = Math.max(1, os.cpus().length);
475
- const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
476
- ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
477
- : null;
478
- // Kept for the quality offer, which predicts from a benchmark taken on a
479
- // QUIET host: the same reading that is printed here says how much of the
480
- // machine a new encoder could actually have. Only what nobody has been
481
- // charged for is subtracted — see `encode/available-share.js`.
482
- this.hostAvailability = availableShareFrom({
483
- systemBusy: share.systemShare,
484
- encoderShare: share.processShare,
485
- proxyShare
486
- });
487
- logger.info(
488
- `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
489
- `system=${asPercent(share.systemShare)} ` +
490
- `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
491
- `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
492
- `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
493
- ` over=${share.elapsedSec.toFixed(1)}s` +
494
- // What the offer will multiply a prediction by, in the same line as the
495
- // readings it comes from.
496
- ` available=${asPercent(this.hostAvailability.share)}`
497
- );
498
- }
499
-
500
- /**
501
- * What this file costs the machine merely by being fetched and delivered
502
- * while it is watched, in seconds of work per second of video.
503
- *
504
- * A viewer consumes the file at its own byte rate, and every one of those
505
- * bytes is downloaded, verified and pushed by this process. Priced per
506
- * megabyte from readings taken while nothing was encoding, so the two
507
- * measurements do not contain each other. Zero while either term is unmeasured
508
- * — a guess here would refuse rungs on arithmetic nobody performed.
509
- *
510
- * @param {HlsSession} session
511
- * @returns {number}
512
- */
513
- torrentCostSecFor(session) {
514
- const perMegabyte = this.observedTorrentCostPerMegabyte;
515
- const megabytesPerSecond = this.torrentMegabytesPerSecond(
516
- session.file.sourceKey,
517
- session.file.fileIndex,
518
- this.fileLengthByKey.get(session.file.key) ?? null,
519
- session.file.durationSeconds
520
- );
521
- return perMegabyte !== null && megabytesPerSecond !== null
522
- ? perMegabyte * megabytesPerSecond
523
- : 0;
524
- }
525
- }
1
+ /**
2
+ * What the proxy's own work costs this host, read while it runs.
3
+ *
4
+ * The torrent's CPU per megabyte it moves, the share of a core this process
5
+ * draws doing nothing priced, how fast each watched torrent is moving, the speed
6
+ * each file's supply demands, how long each file is, and how much of the machine
7
+ * a new encoder could have. The encoders' own cost is `EncodeCost`'s.
8
+ */
9
+
10
+ import os from "node:os";
11
+ import { logger } from "../../../utils/logger.js";
12
+ import { availableShareFrom } from "../available-share.js";
13
+ import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
14
+ import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learned-median.js";
15
+ import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
16
+ import { SourceFiles } from "../../media/SourceFile.js";
17
+
18
+ // How far ahead of its own read head a reader asks the swarm for, expressed in
19
+ // seconds of PLAYBACK. The torrent thread can only think in bytes, and a fixed
20
+ // byte window is wrong at both ends of the range: 32 MB is half a minute of a
21
+ // 1080p film and about four seconds of a disc remux. Duration and file size are
22
+ // both known here, so the window is sized where the knowledge is and sent down
23
+ // on the ffmpeg input URL.
24
+ const READ_WINDOW_SECONDS = 30;
25
+ // Bounds, so a wrong or unusual byte rate cannot ask for something absurd. The
26
+ // floor keeps a few pieces in flight on a low-bitrate file; the ceiling keeps
27
+ // one reader from claiming more than a fraction of the piece store.
28
+ const READ_WINDOW_MIN_BYTES = 16 * 1024 * 1024;
29
+ const READ_WINDOW_MAX_BYTES = 96 * 1024 * 1024;
30
+
31
+ export class HostLoad {
32
+ /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
33
+ #host;
34
+
35
+ /** How much of the machine a new encoder could have, from the last reading; undefined before one. */
36
+ hostAvailability;
37
+
38
+ /** The previous reading of the machine, to compare the next one against. */
39
+ #hostLoadSample = null;
40
+
41
+ /** The previous reading taken while nothing was encoding, for the torrent's own cost. */
42
+ #idleLoadSample = null;
43
+
44
+ /**
45
+ * How long each file is in bytes, from the stats call the read window already
46
+ * makes. The torrent moves the CONTAINER, so this — not the video stream's
47
+ * bitrate — is what its work should be priced against.
48
+ *
49
+ * @type {Map<string, number>}
50
+ */
51
+ fileLengthByKey = new Map();
52
+
53
+ /** Seconds of this process's CPU per megabyte the torrent moves, once measured. */
54
+ observedTorrentCostPerMegabyte = null;
55
+
56
+ /** @type {number[]} Recent readings behind that median. */
57
+ #torrentCostReadings = [];
58
+
59
+ /**
60
+ * The share of one core this process draws with nothing encoding and the
61
+ * torrents moving nothing — the spending that would have happened anyway, and
62
+ * which must come off a reading before the rest is called the torrent's.
63
+ */
64
+ #observedBaseDraw = null;
65
+
66
+ /** @type {number[]} Recent readings behind that median. */
67
+ #baseDrawReadings = [];
68
+
69
+ /**
70
+ * What each watched TORRENT is measured to be moving right now, in bytes per
71
+ * second, keyed by source. Rebuilt every budget tick from the live sessions,
72
+ * so an entry that is present was taken this tick.
73
+ *
74
+ * @type {Map<string, number>}
75
+ */
76
+ #downloadRateByKey = new Map();
77
+
78
+ /**
79
+ * The speed each file's own interruptions were last measured to demand,
80
+ * keyed `sourceKey:fileIndex`. Kept per source rather than per session
81
+ * because the first offer for a file is made before any session of it exists,
82
+ * and a file that has been watched before has already told the reader what
83
+ * its swarm does.
84
+ *
85
+ * @type {Map<string, number>}
86
+ */
87
+ #requiredSpeedByKey = new Map();
88
+
89
+ /** Last complete supply reading for each source file. @type {Map<string, object>} */
90
+ #supplyByKey = new Map();
91
+
92
+ /**
93
+ * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
94
+ */
95
+ constructor(host) {
96
+ this.#host = host;
97
+ }
98
+
99
+ /**
100
+ * The read-ahead window for a file, in bytes, sized from how many seconds of
101
+ * playback it holds.
102
+ *
103
+ * @param {string} sourceKey
104
+ * @param {number} fileIndex
105
+ * @param {number} durationSeconds
106
+ * @returns {Promise<number>} Zero when the byte rate cannot be established,
107
+ * which leaves the reader on its own default.
108
+ */
109
+ async readWindowBytesFor(sourceKey, fileIndex, durationSeconds) {
110
+ // The length read here is also what prices the torrent's own work for this
111
+ // file, so it is remembered rather than discarded.
112
+
113
+ if (!this.#host.getSourceStats || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
114
+ return 0;
115
+ }
116
+ let fileLength = 0;
117
+ try {
118
+ const stats = await this.#host.getSourceStats(sourceKey, fileIndex);
119
+ fileLength = Number(stats?.fileLength);
120
+ } catch {
121
+ return 0;
122
+ }
123
+ if (!Number.isFinite(fileLength) || fileLength <= 0) {
124
+ return 0;
125
+ }
126
+ this.fileLengthByKey.set(SourceFiles.keyFor(sourceKey, fileIndex), fileLength);
127
+ const bytesPerSecond = fileLength / durationSeconds;
128
+ // Shared between the readers this file already has. The window is stated in
129
+ // seconds of playback and the store's memory is one budget for the whole
130
+ // torrent, so N readers asking for thirty seconds each ask for N times what
131
+ // was provided for — and on 2026-08-15 that is exactly what happened: a
132
+ // viewer with a picture and an audio track had every resident piece held at
133
+ // once, a read ended with zero bytes, and every encoder on the file took
134
+ // that for the end of it.
135
+ //
136
+ // Dividing keeps the promise the budget was written against. It is not the
137
+ // sliding window of roadmap item 8 — pieces still leave only by the store's
138
+ // own eviction — but it removes the multiplication that broke it.
139
+ const readers = Math.max(1, this.#readersOn(sourceKey, fileIndex));
140
+ const wanted = Math.round((bytesPerSecond * READ_WINDOW_SECONDS) / readers);
141
+ return Math.min(READ_WINDOW_MAX_BYTES, Math.max(READ_WINDOW_MIN_BYTES, wanted));
142
+ }
143
+
144
+ /**
145
+ * How many live sessions read this file: the picture, any rung being warmed
146
+ * beside it, and any audio track published on its own.
147
+ *
148
+ * @param {string} sourceKey
149
+ * @param {number} fileIndex
150
+ * @returns {number}
151
+ */
152
+ #readersOn(sourceKey, fileIndex) {
153
+ let readers = 0;
154
+ for (const session of this.#host.outputs.values()) {
155
+ if (session?.file.sourceKey === sourceKey && session.file.fileIndex === fileIndex) {
156
+ readers += 1;
157
+ }
158
+ }
159
+ return readers;
160
+ }
161
+
162
+ /**
163
+ * What the torrent itself costs this machine, per megabyte it moves.
164
+ *
165
+ * Downloading, verifying every piece and pushing segments down a data channel
166
+ * are work on the same box as the encoder, they scale with the file's own
167
+ * bitrate, and the budget counts none of it. Measured on the addon host with
168
+ * every encoder suspended, the machine was still 20-29 % busy.
169
+ *
170
+ * Taken only while NOTHING is encoding, because that is the only moment the
171
+ * spending can be attributed without arithmetic: what this process uses then
172
+ * is the torrent's.
173
+ */
174
+ async #learnTorrentCost() {
175
+ const now = {
176
+ takenAt: Date.now(),
177
+ cpuSeconds: this.#host.readProxyCpuSeconds(),
178
+ bytes: await this.#torrentBytesMoved()
179
+ };
180
+ const previous = this.#idleLoadSample;
181
+ this.#idleLoadSample = now;
182
+ if (previous === null || now.bytes === null || previous.bytes === null) {
183
+ return;
184
+ }
185
+ const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
186
+ const megabytes = (now.bytes - previous.bytes) / 1e6;
187
+ // Divided by the cores, because `process.cpuUsage()` adds up every thread
188
+ // while everything this figure is later added to is measured in WALL
189
+ // seconds per second of video. Left undivided on the four-core addon host
190
+ // it overstated the torrent by four times, which on the field's own rung
191
+ // was the difference between offering it and refusing it.
192
+ const cores = Math.max(1, os.cpus().length);
193
+ const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / cores;
194
+ if (megabytes === 0) {
195
+ // Nothing encoding and not one byte moved: whatever this process spent in
196
+ // that interval, it spends whether or not there is a torrent. Measuring
197
+ // it is what lets the next interval be attributed instead of divided
198
+ // whole — see `encode/torrent-cost.js` for the readings that forced this.
199
+ this.#learnBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
200
+ return;
201
+ }
202
+ const costPerMegabyte = costPerMegabyteFrom({
203
+ cpuSeconds,
204
+ elapsedSeconds: elapsedSec,
205
+ megabytes,
206
+ baseDraw: this.#observedBaseDraw,
207
+ // How much the draw's own readings disagree, which is how much of this
208
+ // interval's remainder means nothing.
209
+ drawScatter: scatterOf(this.#baseDrawReadings)
210
+ });
211
+ if (costPerMegabyte === null) {
212
+ return;
213
+ }
214
+ const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-READINGS_KEPT);
215
+ this.#torrentCostReadings = readings;
216
+ const median = medianOf(readings);
217
+ if (!movedBeyondScatter(this.observedTorrentCostPerMegabyte, median, readings)) {
218
+ return;
219
+ }
220
+ this.observedTorrentCostPerMegabyte = median;
221
+ logger.info(
222
+ `host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
223
+ `(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB, ` +
224
+ `base draw ${((this.#observedBaseDraw ?? 0) * 100).toFixed(1)}% of a core already taken off)`
225
+ );
226
+ }
227
+
228
+ /**
229
+ * What each watched file's torrent is moving right now.
230
+ *
231
+ * The torrent is priced per megabyte it moves, so the price has to be charged
232
+ * against the megabytes it IS moving. Charged against the file's own byte
233
+ * rate — what the viewer consumes — it asks for payment on a fully downloaded
234
+ * file that is moving nothing, and it under-charges a file being fetched
235
+ * ahead of the viewer, which is the state every session starts in.
236
+ *
237
+ * Rebuilt whole each tick from the live sessions, so an entry that is here
238
+ * was taken this tick and a source nobody is watching leaves by itself.
239
+ *
240
+ * @returns {Promise<void>}
241
+ */
242
+ async sampleDownloadRates() {
243
+ if (!this.#host.getSourceStats) {
244
+ return;
245
+ }
246
+ /** @type {Map<string, { sourceKey: string, fileIndex: number }>} */
247
+ const wanted = new Map();
248
+ for (const session of this.#host.outputs.values()) {
249
+ wanted.set(session.file.key, {
250
+ sourceKey: session.file.sourceKey,
251
+ fileIndex: session.file.fileIndex
252
+ });
253
+ }
254
+ /** @type {Map<string, number>} */
255
+ const measured = new Map();
256
+ for (const [key, source] of wanted) {
257
+ try {
258
+ const stats = await this.#host.getSourceStats(source.sourceKey, source.fileIndex);
259
+ // The TORRENT's rate, which is what it is: one swarm feeding one
260
+ // client, whichever of its files are being read. Kept per source and
261
+ // divided among the files being watched, so two episodes of one pack
262
+ // do not each charge the machine for the whole download.
263
+ const rate = Number(stats?.downloadSpeed);
264
+ if (Number.isFinite(rate) && rate >= 0) {
265
+ measured.set(source.sourceKey, rate);
266
+ }
267
+ // Kept, not rebuilt: the demand a swarm made on this file does not stop
268
+ // being true when a tick fails to fetch it, and it is what the FIRST
269
+ // offer of the next session will be judged against.
270
+ const demanded = Number(stats?.supply?.requiredSpeed);
271
+ if (Number.isFinite(demanded) && demanded > 0) {
272
+ this.#requiredSpeedByKey.set(key, demanded);
273
+ }
274
+ if (stats?.supply) {
275
+ this.#supplyByKey.set(key, stats.supply);
276
+ }
277
+ } catch {
278
+ // The pool is busy or gone. A reading missed is not a fault, and the
279
+ // key simply does not appear this tick.
280
+ }
281
+ }
282
+ this.#downloadRateByKey = measured;
283
+ }
284
+
285
+ /**
286
+ * How many files of one torrent have a live session reading them.
287
+ *
288
+ * @param {string} sourceKey
289
+ * @returns {number} At least one, so the rate is never divided by nothing.
290
+ */
291
+ #filesWatchedOn(sourceKey) {
292
+ const files = new Set();
293
+ for (const session of this.#host.outputs.values()) {
294
+ if (session?.file.sourceKey === sourceKey) {
295
+ files.add(session.file.fileIndex);
296
+ }
297
+ }
298
+ return Math.max(1, files.size);
299
+ }
300
+
301
+ /**
302
+ * The speed this file's supply demands, as last measured on this swarm.
303
+ *
304
+ * @param {string} sourceKey
305
+ * @param {number} fileIndex
306
+ * @returns {number | null}
307
+ */
308
+ requiredSpeedFor(sourceKey, fileIndex) {
309
+ return this.#requiredSpeedByKey.get(SourceFiles.keyFor(sourceKey, fileIndex)) ?? null;
310
+ }
311
+
312
+ /** Last complete supply reading for a source file. */
313
+ supplyFor(file) {
314
+ return this.#supplyByKey.get(SourceFiles.keyFor(file.sourceKey, file.fileIndex)) ?? null;
315
+ }
316
+
317
+ /**
318
+ * How many megabytes a second the torrent is moving for this file — measured
319
+ * where a reading exists, and otherwise the rate the file has to be moved at
320
+ * to be watched at all (its length over its duration), which is what the
321
+ * measured rate averages to over a viewing.
322
+ *
323
+ * @param {string} sourceKey
324
+ * @param {number} fileIndex
325
+ * @param {number | null} fileLengthBytes
326
+ * @param {number | null} durationSeconds
327
+ * @returns {number | null}
328
+ */
329
+ torrentMegabytesPerSecond(sourceKey, fileIndex, fileLengthBytes, durationSeconds) {
330
+ const measured = this.#downloadRateByKey.get(sourceKey);
331
+ if (Number.isFinite(measured)) {
332
+ return measured / 1e6 / this.#filesWatchedOn(sourceKey);
333
+ }
334
+ // The FILE's rate, not the video stream's. What the torrent moves is the
335
+ // container: on the releases this serves, two or three AC-3 tracks add
336
+ // 10-25 % to what the picture alone would suggest.
337
+ const fileLength = Number(fileLengthBytes);
338
+ const duration = Number(durationSeconds);
339
+ if (Number.isFinite(fileLength) && fileLength > 0 && Number.isFinite(duration) && duration > 0) {
340
+ return fileLength / duration / 1e6;
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Record what this process draws when it is doing none of the work that gets
347
+ * priced.
348
+ *
349
+ * @param {number | null} share - Of one core, over the interval just read.
350
+ * @returns {void}
351
+ */
352
+ #learnBaseDraw(share) {
353
+ if (share === null) {
354
+ return;
355
+ }
356
+ const readings = [...this.#baseDrawReadings, share].slice(-READINGS_KEPT);
357
+ this.#baseDrawReadings = readings;
358
+ const median = medianOf(readings);
359
+ if (!movedBeyondScatter(this.#observedBaseDraw, median, readings)) {
360
+ return;
361
+ }
362
+ this.#observedBaseDraw = median;
363
+ logger.info(
364
+ `host-load: this process draws ${(median * 100).toFixed(1)}% of a core with nothing encoding and ` +
365
+ `nothing downloading (median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Bytes this proxy's torrents have moved in total, or null when it cannot be
371
+ * asked.
372
+ *
373
+ * @returns {Promise<number | null>}
374
+ */
375
+ async #torrentBytesMoved() {
376
+ if (typeof this.#host.getTorrentTotals !== "function") {
377
+ return null;
378
+ }
379
+ try {
380
+ const totals = await this.#host.getTorrentTotals();
381
+ // Downloaded bytes only. Every one of them is verified against the piece
382
+ // hash and written to the store; a byte sent back to the swarm is neither,
383
+ // and adding the two would price both at whatever the mixture happened to
384
+ // be on the day.
385
+ const downloaded = Number(totals?.downloaded);
386
+ return Number.isFinite(downloaded) ? downloaded : null;
387
+ } catch {
388
+ return null; // the pool is busy or gone; a reading missed is not a fault
389
+ }
390
+ }
391
+
392
+ async reportHostLoad() {
393
+ const encoding = [...this.#host.outputs.values()].filter(
394
+ (session) => processCanBeSignalled(this.#host.runStateOf(session))
395
+ );
396
+ const runningNow = encoding.filter((session) => this.#host.runStateOf(session) !== ENCODE_RUN_STATE.SUSPENDED);
397
+ if (runningNow.length === 0) {
398
+ // No encoder is RUNNING. A suspended one costs nothing, and counting it
399
+ // as work meant this was never reached: measured 2026-08-15, four minutes
400
+ // in which every encoder was suspended, the torrent's price could have
401
+ // been taken, and none was. What this process spends now is the download,
402
+ // the verification, the piece store and the delivery. Item 7.
403
+ await this.#learnTorrentCost();
404
+ this.#hostLoadSample = null;
405
+ return;
406
+ }
407
+ this.#idleLoadSample = null;
408
+ // EVERY encoder, added up. One of them is meaningless on a host that runs a
409
+ // picture and an audio track at once, and picking the first would have
410
+ // reported whichever the map happened to hold.
411
+ // Kept per PROCESS, not as one total. The set changes between readings —
412
+ // a seek kills ffmpeg and starts another with a new pid whose counter
413
+ // begins at zero, a session ends, a rendition begins — and subtracting one
414
+ // total from another across a changed set produces nonsense: a restart
415
+ // alone would print something like `ffmpeg=-598%`. Only pids present in
416
+ // BOTH readings are counted, so a process that came or went contributes
417
+ // nothing rather than a lie.
418
+ const pids = encoding.flatMap((session) => this.#host.liveRunsOf(session).map((run) => run.process?.pid ?? null)).filter((pid) => pid !== null);
419
+ const [system, ...cpuReadings] = await Promise.all([
420
+ this.#host.readSystemCpu(),
421
+ ...pids.map((pid) => this.#host.readProcessCpuSeconds(pid))
422
+ ]);
423
+ /** @type {Map<number, number>} */
424
+ const byPid = new Map();
425
+ pids.forEach((pid, index) => {
426
+ const seconds = cpuReadings[index];
427
+ if (seconds !== null) {
428
+ byPid.set(pid, seconds);
429
+ }
430
+ });
431
+ const sample = {
432
+ takenAt: Date.now(),
433
+ byPid,
434
+ system,
435
+ // The proxy's own CPU, across every thread: the torrent, the hashing, the
436
+ // piece store and the delivery. None of it is in the encode budget, and
437
+ // on the addon host it is most of what the machine does while encoders
438
+ // are suspended.
439
+ proxyCpuSeconds: this.#host.readProxyCpuSeconds()
440
+ };
441
+ const previous = this.#hostLoadSample;
442
+ this.#hostLoadSample = sample;
443
+ if (previous === null) {
444
+ return; // the first reading is only something to compare against
445
+ }
446
+ // Summed over the pids both readings hold, so nothing is measured against a
447
+ // process that was not there before. Unknown stays unknown: on a host with
448
+ // no `/proc` there are no readings at all, and the share is null rather
449
+ // than a confident zero.
450
+ let encoderDelta = null;
451
+ for (const [pid, seconds] of sample.byPid) {
452
+ const before = previous.byPid?.get(pid);
453
+ if (before !== undefined && seconds >= before) {
454
+ encoderDelta = (encoderDelta ?? 0) + (seconds - before);
455
+ }
456
+ }
457
+ const share = this.#host.shareOfMachine(
458
+ { takenAt: previous.takenAt, processCpuSeconds: encoderDelta === null ? null : 0, system: previous.system },
459
+ { takenAt: sample.takenAt, processCpuSeconds: encoderDelta, system: sample.system }
460
+ );
461
+ if (share === null) {
462
+ return;
463
+ }
464
+ // How many of them are stopped by the look-ahead cap. Without this a zero
465
+ // share reads as an encoder being starved of the machine, when it is an
466
+ // encoder deliberately not running — which is what the first readings on
467
+ // the addon host actually were (2026-08-15: `ffmpeg=0% system=24%`, both
468
+ // encoders suspended, and the speed beside it a stale figure from before
469
+ // they stopped).
470
+ const suspended = encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length;
471
+ const running = encoding.length - suspended;
472
+ const machine = await this.#host.readMachineState();
473
+ const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
474
+ const cores = Math.max(1, os.cpus().length);
475
+ const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
476
+ ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
477
+ : null;
478
+ // Kept for the quality offer, which predicts from a benchmark taken on a
479
+ // QUIET host: the same reading that is printed here says how much of the
480
+ // machine a new encoder could actually have. Only what nobody has been
481
+ // charged for is subtracted — see `encode/available-share.js`.
482
+ this.hostAvailability = availableShareFrom({
483
+ systemBusy: share.systemShare,
484
+ encoderShare: share.processShare,
485
+ proxyShare
486
+ });
487
+ logger.info(
488
+ `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
489
+ `system=${asPercent(share.systemShare)} ` +
490
+ `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
491
+ `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
492
+ `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
493
+ ` over=${share.elapsedSec.toFixed(1)}s` +
494
+ // What the offer will multiply a prediction by, in the same line as the
495
+ // readings it comes from.
496
+ ` available=${asPercent(this.hostAvailability.share)}`
497
+ );
498
+ }
499
+
500
+ /**
501
+ * What this file costs the machine merely by being fetched and delivered
502
+ * while it is watched, in seconds of work per second of video.
503
+ *
504
+ * A viewer consumes the file at its own byte rate, and every one of those
505
+ * bytes is downloaded, verified and pushed by this process. Priced per
506
+ * megabyte from readings taken while nothing was encoding, so the two
507
+ * measurements do not contain each other. Zero while either term is unmeasured
508
+ * — a guess here would refuse rungs on arithmetic nobody performed.
509
+ *
510
+ * @param {HlsSession} session
511
+ * @returns {number}
512
+ */
513
+ torrentCostSecFor(session) {
514
+ const perMegabyte = this.observedTorrentCostPerMegabyte;
515
+ const megabytesPerSecond = this.torrentMegabytesPerSecond(
516
+ session.file.sourceKey,
517
+ session.file.fileIndex,
518
+ this.fileLengthByKey.get(session.file.key) ?? null,
519
+ session.file.durationSeconds
520
+ );
521
+ return perMegabyte !== null && megabytesPerSecond !== null
522
+ ? perMegabyte * megabytesPerSecond
523
+ : 0;
524
+ }
525
+ }