@torrent-tv/proxy 2.87.3 → 2.87.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (465) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2157 -2153
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/diagnostics/sctpstate.gdb +31 -31
  19. package/bin/cli.js +684 -684
  20. package/bin/thread-pool.js +30 -30
  21. package/biome.json +411 -411
  22. package/docs/browser-proxy-contract.md +94 -94
  23. package/docs/container-architecture.md +218 -218
  24. package/docs/download-architecture.md +261 -261
  25. package/docs/encode-architecture.md +729 -729
  26. package/docs/encode-run-state.md +96 -96
  27. package/docs/logs.md +97 -97
  28. package/docs/socks5-egress-research.md +114 -114
  29. package/docs/storage-architecture.md +267 -267
  30. package/knip.json +14 -14
  31. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  32. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  33. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  34. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  35. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  36. package/openspec/changes/binary-distribution/proposal.md +53 -53
  37. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  38. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  39. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  40. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  41. package/openspec/changes/cold-start/design.md +98 -98
  42. package/openspec/changes/cold-start/proposal.md +62 -62
  43. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  44. package/openspec/changes/cold-start/tasks.md +48 -48
  45. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  46. package/openspec/changes/disk-cap/proposal.md +37 -37
  47. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  48. package/openspec/changes/disk-cap/tasks.md +19 -19
  49. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  50. package/openspec/changes/proxy-observability/design.md +38 -38
  51. package/openspec/changes/proxy-observability/proposal.md +49 -49
  52. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  53. package/openspec/changes/proxy-observability/tasks.md +21 -21
  54. package/openspec/changes/seek-debounce/design.md +118 -118
  55. package/openspec/changes/seek-debounce/proposal.md +59 -59
  56. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  57. package/openspec/changes/seek-debounce/tasks.md +33 -33
  58. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  59. package/openspec/changes/subtitle-language/proposal.md +50 -50
  60. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  61. package/openspec/changes/subtitle-language/tasks.md +26 -26
  62. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  63. package/openspec/changes/track-selection/design.md +47 -47
  64. package/openspec/changes/track-selection/proposal.md +45 -45
  65. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  66. package/openspec/changes/track-selection/tasks.md +20 -20
  67. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  68. package/openspec/changes/transcode-quality/proposal.md +51 -51
  69. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  70. package/openspec/changes/transcode-quality/tasks.md +63 -63
  71. package/openspec/config.yaml +38 -38
  72. package/package.json +50 -50
  73. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  74. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  75. package/research/double-spawn-2026-09-10.md +171 -171
  76. package/research/handover-2026-09-13.md +209 -209
  77. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  78. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  79. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  80. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  81. package/routes/api/client-logs/post.js +86 -86
  82. package/routes/api/delivery-sink/get.js +72 -72
  83. package/routes/api/link-probe/get.js +51 -51
  84. package/routes/api/playback-plan/post.js +53 -53
  85. package/routes/api/sources/files/get.js +100 -100
  86. package/routes/api/sources/post.js +36 -36
  87. package/routes/api/sources/stats/get.js +132 -132
  88. package/routes/api/sources/warm/post.js +217 -181
  89. package/routes/api/subtitles/get.js +230 -230
  90. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  91. package/routes/api/transcode-sessions/net-report/post.js +129 -129
  92. package/routes/api/transcode-sessions/post.js +160 -160
  93. package/routes/api/transcode-sessions/progress/get.js +27 -27
  94. package/routes/api/transcode-sessions/release/post.js +41 -41
  95. package/routes/api/transcode-sessions/seek/post.js +63 -63
  96. package/routes/health/get.js +13 -13
  97. package/routes/healthz/get.js +13 -13
  98. package/routes/stream/get.js +361 -361
  99. package/routes/transcode/audio-file/get.js +64 -64
  100. package/routes/transcode/audio-warm/get.js +84 -84
  101. package/routes/transcode/session-file/get.js +52 -52
  102. package/routes/transcode/variant-file/get.js +95 -95
  103. package/routes/transcode/variant-warm/get.js +84 -84
  104. package/scripts/render-run-graph.js +173 -173
  105. package/server.js +721 -720
  106. package/services/encode/CoverageMap.js +428 -428
  107. package/services/encode/CushionReport.js +345 -345
  108. package/services/encode/EncodeAdmission.js +332 -332
  109. package/services/encode/EncodeOrchestrator.js +1217 -1217
  110. package/services/encode/EncodePlan.js +1366 -1366
  111. package/services/encode/EncodeRun.js +776 -776
  112. package/services/encode/EncodeRuns.js +1229 -1229
  113. package/services/encode/Encoder.js +142 -142
  114. package/services/encode/EncoderSelection.js +38 -38
  115. package/services/encode/LocalObservations.js +359 -359
  116. package/services/encode/NvencEncoder.js +50 -50
  117. package/services/encode/OutputOpening.js +755 -755
  118. package/services/encode/OutputTimes.js +579 -579
  119. package/services/encode/QsvEncoder.js +58 -58
  120. package/services/encode/Renditions.js +2162 -2162
  121. package/services/encode/RunProgress.js +84 -84
  122. package/services/encode/SegmentDemand.js +95 -95
  123. package/services/encode/SoftwareEncoder.js +121 -121
  124. package/services/encode/V4l2m2mEncoder.js +53 -53
  125. package/services/encode/VaapiEncoder.js +66 -66
  126. package/services/encode/args.js +416 -416
  127. package/services/encode/available-share.js +89 -89
  128. package/services/encode/calibration.js +361 -361
  129. package/services/encode/contention.js +128 -128
  130. package/services/encode/decode-cost-fit.js +363 -363
  131. package/services/encode/encode-exit.js +114 -114
  132. package/services/encode/encode-run-state.js +551 -551
  133. package/services/encode/encoder-readings.js +45 -45
  134. package/services/encode/fingerprint.js +322 -322
  135. package/services/encode/host-load.js +174 -174
  136. package/services/encode/hwaccel.js +1894 -1894
  137. package/services/encode/index.js +9 -9
  138. package/services/encode/learned-median.js +90 -90
  139. package/services/encode/output/EncodedOutput.js +57 -57
  140. package/services/encode/output/Output.js +131 -131
  141. package/services/encode/output/OutputCatalog.js +414 -414
  142. package/services/encode/output/OutputSpec.js +390 -390
  143. package/services/encode/output/Timeline.js +308 -308
  144. package/services/encode/output/cut-grid.js +107 -107
  145. package/services/encode/output/index.js +20 -20
  146. package/services/encode/output/ladder.js +32 -32
  147. package/services/encode/output/playlists.js +284 -284
  148. package/services/encode/output/rates.js +125 -125
  149. package/services/encode/output-key-format.js +21 -21
  150. package/services/encode/quality/EncodeCost.js +1083 -1083
  151. package/services/encode/quality/HostLoad.js +525 -525
  152. package/services/encode/quality/HostTimings.js +249 -249
  153. package/services/encode/quality/OutputQualityState.js +28 -28
  154. package/services/encode/quality/QualityController.js +935 -935
  155. package/services/encode/quality/QualityOffer.js +416 -416
  156. package/services/encode/quality/drain-threat.js +47 -47
  157. package/services/encode/quality/link-budget.js +254 -254
  158. package/services/encode/quality/output-format.js +266 -266
  159. package/services/encode/quality/serving-output.js +216 -216
  160. package/services/encode/quality/visible-rung.js +57 -57
  161. package/services/encode/run-budget.js +97 -97
  162. package/services/encode/run-command.js +747 -747
  163. package/services/encode/run-costs.js +155 -155
  164. package/services/encode/run-inputs.js +42 -42
  165. package/services/encode/segment-formats/fmp4.js +437 -437
  166. package/services/encode/segment-formats/index.js +77 -77
  167. package/services/encode/segment-formats/init-compat.js +324 -324
  168. package/services/encode/segment-formats/mp4-boxes.js +311 -311
  169. package/services/encode/segment-formats/mpegts.js +134 -134
  170. package/services/encode/start-stop-cost.js +178 -178
  171. package/services/encode/throughput.js +97 -97
  172. package/services/encode/torrent-cost.js +88 -88
  173. package/services/identity/installation-id.js +38 -38
  174. package/services/media/ContainerOrchestrator.js +149 -149
  175. package/services/media/KeyframeTables.js +316 -316
  176. package/services/media/SourceFile.js +298 -298
  177. package/services/media/SubtitleCues.js +520 -520
  178. package/services/media/SubtitleOrchestrator.js +167 -167
  179. package/services/media/audio-inventory.js +212 -212
  180. package/services/media/container/AviContainer.js +266 -266
  181. package/services/media/container/Container.js +603 -603
  182. package/services/media/container/ContainerFactory.js +56 -56
  183. package/services/media/container/KeyframeTable.js +272 -272
  184. package/services/media/container/MatroskaContainer.js +1494 -1494
  185. package/services/media/container/Mp4Container.js +1244 -1244
  186. package/services/media/container/SubtitleFileContainer.js +322 -322
  187. package/services/media/container/ebml-reader.js +159 -159
  188. package/services/media/ffmpeg-banner.js +260 -260
  189. package/services/media/keyframe-probe.js +157 -157
  190. package/services/media/media-info-probe.js +93 -93
  191. package/services/media/playback-planner.js +856 -848
  192. package/services/media/tracks/AudioTrack.js +131 -131
  193. package/services/media/tracks/ContainerTrack.js +75 -75
  194. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  195. package/services/media/tracks/SubtitleTrack.js +38 -38
  196. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  197. package/services/media/tracks/VideoTrack.js +33 -33
  198. package/services/media/tracks/index.js +15 -15
  199. package/services/media/tracks/language-detect.js +174 -174
  200. package/services/server/OutputLifecycle.js +418 -418
  201. package/services/server/SegmentServing.js +1146 -1146
  202. package/services/server/ViewerRequests.js +522 -522
  203. package/services/server/WaitLedger.js +142 -142
  204. package/services/server/controllers/PlaybackController.js +36 -36
  205. package/services/server/controllers/SubtitleController.js +109 -109
  206. package/services/server/http-responses.js +60 -60
  207. package/services/server/transcode-session-files.js +325 -325
  208. package/services/server/wire-outputs.js +983 -983
  209. package/services/storage/Diagnostics.js +202 -202
  210. package/services/storage/MachineBudget.js +235 -235
  211. package/services/storage/allowance.js +107 -107
  212. package/services/storage/core-dumps.js +106 -106
  213. package/services/storage/files/CompletedFiles.js +391 -391
  214. package/services/storage/files/piece-from-whole-file.js +118 -118
  215. package/services/storage/free.js +71 -71
  216. package/services/storage/keep.js +48 -48
  217. package/services/storage/machine-memory.js +56 -56
  218. package/services/storage/memory-report.js +575 -575
  219. package/services/storage/piece-store/piece-disk-store.js +516 -516
  220. package/services/storage/piece-store/piece-lru.js +495 -495
  221. package/services/storage/piece-store/shared-piece-store.js +1880 -1880
  222. package/services/storage/returns.js +103 -103
  223. package/services/storage/segment-store/SegmentStore.js +1196 -1196
  224. package/services/storage/wire.js +175 -175
  225. package/services/torrent/Contents.js +324 -324
  226. package/services/torrent/demand/DemandRegister.js +202 -202
  227. package/services/torrent/demand/Urgency.js +137 -137
  228. package/services/torrent/demand/Window.js +118 -118
  229. package/services/torrent/demand/index.js +8 -8
  230. package/services/torrent/demand/pieces.js +140 -140
  231. package/services/torrent/download/SwarmSelection.js +386 -386
  232. package/services/torrent/download/registry.js +102 -102
  233. package/services/torrent/download/withdraw-claim.js +80 -80
  234. package/services/torrent/files.js +455 -455
  235. package/services/torrent/naming.js +619 -619
  236. package/services/torrent/piece-store-of.js +29 -29
  237. package/services/torrent/supply-margin.js +262 -262
  238. package/services/torrent/torrent-pool.js +2675 -2635
  239. package/services/torrent/torrent-source-key.js +38 -38
  240. package/services/torrent/worker/background-fill.js +213 -213
  241. package/services/torrent/worker/channel.js +271 -271
  242. package/services/torrent/worker/client.js +765 -760
  243. package/services/torrent/worker/fastest-wires.js +342 -342
  244. package/services/torrent/worker/handle-state.js +23 -23
  245. package/services/torrent/worker/held-bytes.js +155 -155
  246. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  247. package/services/torrent/worker/piece-reader.js +1078 -1078
  248. package/services/torrent/worker/pool-adapter.js +365 -350
  249. package/services/torrent/worker/protocol.js +184 -182
  250. package/services/torrent/worker/resume-warm.js +107 -107
  251. package/services/torrent/worker/webrtc-shim.js +219 -219
  252. package/services/torrent/worker/worker.js +1129 -1116
  253. package/services/transport/data-channel-handler.js +1356 -1356
  254. package/services/transport/delivery-probe.js +842 -842
  255. package/services/transport/health-collector.js +53 -53
  256. package/services/transport/nat-classifier.js +231 -231
  257. package/services/transport/packet-witness.js +808 -808
  258. package/services/transport/port-mapper.js +235 -235
  259. package/services/transport/registry-api.js +81 -81
  260. package/services/transport/tunnel-client.js +447 -447
  261. package/services/transport/usrsctp-state.js +175 -175
  262. package/services/transport/webrtc-manager.js +484 -484
  263. package/services/viewer/Assignments.js +340 -340
  264. package/services/viewer/PriorityMap.js +390 -390
  265. package/services/viewer/PriorityOrchestrator.js +325 -325
  266. package/services/viewer/Viewer.js +657 -657
  267. package/services/viewer/Viewers.js +368 -368
  268. package/services/viewer/active-output.js +37 -37
  269. package/services/viewer/audio-start.js +46 -46
  270. package/services/viewer/buffer-trend.js +81 -81
  271. package/services/viewer/choices.js +647 -647
  272. package/services/viewer/positions.js +126 -126
  273. package/services/viewer/report-intake.js +46 -46
  274. package/store/source-registry.js +73 -73
  275. package/test/asset-paths.test.js +28 -28
  276. package/test/audio-inventory.test.js +176 -176
  277. package/test/audio-track-bitrate.test.js +40 -40
  278. package/test/auto-quality-step.test.js +707 -707
  279. package/test/available-share.test.js +76 -76
  280. package/test/background-fill.test.js +190 -190
  281. package/test/client-log-file.test.js +110 -110
  282. package/test/completed-files.test.js +115 -115
  283. package/test/concurrent-cost.test.js +148 -148
  284. package/test/container-media-info.test.js +228 -228
  285. package/test/contention.test.js +76 -76
  286. package/test/core-dumps.test.js +57 -57
  287. package/test/coverage-follows-the-disk.test.js +228 -228
  288. package/test/coverage-map.test.js +195 -195
  289. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  290. package/test/cut-times-timeline.test.js +65 -65
  291. package/test/cuts-follow-published-grid.test.js +109 -109
  292. package/test/data-channel-frame.test.js +63 -63
  293. package/test/dead-torrent-handle.test.js +52 -52
  294. package/test/declared-rates.test.js +116 -116
  295. package/test/declared-tracks.test.js +35 -35
  296. package/test/decode-cost-fit.test.js +202 -202
  297. package/test/decode-cost.test.js +462 -462
  298. package/test/delivery-probe.test.js +393 -393
  299. package/test/demand-register.test.js +206 -206
  300. package/test/dht-bootstrap.test.js +72 -72
  301. package/test/drain-threat.test.js +65 -65
  302. package/test/duplicate-add.test.js +48 -48
  303. package/test/encode-admission.test.js +146 -146
  304. package/test/encode-cost.test.js +283 -283
  305. package/test/encode-exit.test.js +111 -111
  306. package/test/encode-orchestrator.test.js +419 -419
  307. package/test/encode-plan-viewers.test.js +718 -718
  308. package/test/encode-plan.test.js +545 -545
  309. package/test/encode-run-state.test.js +314 -314
  310. package/test/encode-run.test.js +161 -161
  311. package/test/encode-slope.test.js +46 -46
  312. package/test/encoded-output.test.js +39 -39
  313. package/test/encoder-kinds.test.js +122 -122
  314. package/test/encoder-readings.test.js +46 -46
  315. package/test/every-claimant-is-told-its-share.test.js +152 -152
  316. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  317. package/test/failed-start-breaker.test.js +202 -202
  318. package/test/fastest-wires.test.js +143 -143
  319. package/test/ffmpeg-stream-counts.test.js +78 -78
  320. package/test/file-edges.test.js +191 -191
  321. package/test/fmp4-self-contained.test.js +80 -80
  322. package/test/fmp4-timestamps.test.js +140 -140
  323. package/test/fmp4-track-completeness.test.js +41 -41
  324. package/test/format-keyed-outputs.test.js +78 -78
  325. package/test/health-metrics.test.js +38 -38
  326. package/test/held-request-width.test.js +157 -157
  327. package/test/helpers/encode-run.js +136 -136
  328. package/test/helpers/held-file.js +29 -29
  329. package/test/helpers/manager.js +57 -57
  330. package/test/helpers/output-spec.js +63 -63
  331. package/test/host-load.test.js +68 -68
  332. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  333. package/test/init-compat.test.js +219 -219
  334. package/test/input-lost-quiets-the-plan.test.js +261 -261
  335. package/test/input-unavailable.test.js +48 -48
  336. package/test/installation-id.test.js +39 -39
  337. package/test/keeping-period.test.js +83 -83
  338. package/test/keyframe-index-accuracy.test.js +188 -188
  339. package/test/keyframe-table-budget.test.js +102 -102
  340. package/test/keyframes-belong-to-the-file.test.js +249 -249
  341. package/test/learned-median.test.js +37 -37
  342. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  343. package/test/limit-row-by-area.test.js +60 -60
  344. package/test/link-budget.test.js +111 -111
  345. package/test/link-probe.test.js +56 -56
  346. package/test/local-encoding-data.test.js +273 -273
  347. package/test/logger-repeats.test.js +134 -134
  348. package/test/look-ahead-coverage.test.js +56 -56
  349. package/test/manager-surface.test.js +292 -292
  350. package/test/matroska-cues-track.test.js +192 -192
  351. package/test/memory-budget.test.js +390 -390
  352. package/test/move-cost.test.js +216 -216
  353. package/test/mp4-subtitles.test.js +173 -173
  354. package/test/one-authority.test.js +285 -285
  355. package/test/one-budget.test.js +149 -149
  356. package/test/one-output-one-session.test.js +204 -204
  357. package/test/one-resource-divided.test.js +185 -185
  358. package/test/one-serving-output-per-height.test.js +458 -458
  359. package/test/orchestrator-wired.test.js +206 -206
  360. package/test/output-catalog.test.js +39 -39
  361. package/test/output-shape.test.js +21 -21
  362. package/test/output-spec.test.js +251 -251
  363. package/test/output-speed.test.js +88 -88
  364. package/test/outputs-being-prepared.test.js +40 -40
  365. package/test/packet-witness-ring.test.js +236 -236
  366. package/test/packet-witness.test.js +148 -148
  367. package/test/piece-disk-store.test.js +381 -381
  368. package/test/piece-from-whole-file.test.js +129 -129
  369. package/test/piece-lru.test.js +319 -319
  370. package/test/piece-reader.test.js +162 -162
  371. package/test/piece-selection-offset.test.js +85 -85
  372. package/test/piece-store-eviction.test.js +560 -560
  373. package/test/piece-store-never-refuses.test.js +153 -153
  374. package/test/piece-store-reservations.test.js +318 -318
  375. package/test/piece-store-slow-disk.test.js +159 -159
  376. package/test/piece-tail.test.js +97 -97
  377. package/test/plan-host-timings.test.js +68 -68
  378. package/test/plan-is-steady.test.js +225 -225
  379. package/test/priority-map-download.test.js +174 -174
  380. package/test/priority-map-emptied.test.js +204 -204
  381. package/test/priority-map-per-output.test.js +215 -215
  382. package/test/priority-map.test.js +163 -163
  383. package/test/probe-wedge-certainty.test.js +60 -60
  384. package/test/produced-copy-choice.test.js +213 -213
  385. package/test/published-timeline.test.js +83 -83
  386. package/test/quality-offer.test.js +162 -162
  387. package/test/quality-variants.test.js +1229 -1229
  388. package/test/rate-control-in-the-output.test.js +180 -180
  389. package/test/read-survives-withdrawal.test.js +164 -164
  390. package/test/read-window.test.js +365 -365
  391. package/test/resume-warm.test.js +39 -39
  392. package/test/run-graph-drift.test.js +37 -37
  393. package/test/run-inputs.test.js +80 -80
  394. package/test/run-intervals.test.js +100 -100
  395. package/test/run-position-follows-published-grid.test.js +89 -89
  396. package/test/run-progress.test.js +65 -65
  397. package/test/same-height-switch.test.js +664 -664
  398. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  399. package/test/seek-landing.test.js +109 -109
  400. package/test/seek-target-not-superseded.test.js +195 -195
  401. package/test/segment-closed-on-both-branches.test.js +96 -96
  402. package/test/segment-cut-times.test.js +63 -63
  403. package/test/segment-demand.test.js +80 -80
  404. package/test/segment-hold-supersede.test.js +91 -91
  405. package/test/segment-judged-by-its-own-header.test.js +221 -221
  406. package/test/segment-serve-wiring.test.js +475 -475
  407. package/test/segment-store-eviction.test.js +247 -247
  408. package/test/segment-store.test.js +323 -323
  409. package/test/segment-true-start.test.js +86 -86
  410. package/test/segments-are-shared.test.js +176 -176
  411. package/test/send-chunk-bytes.test.js +86 -86
  412. package/test/serving-existing-output.test.js +102 -102
  413. package/test/serving-output.test.js +102 -102
  414. package/test/shared-piece-store.test.js +310 -310
  415. package/test/sidecar-naming.test.js +142 -142
  416. package/test/source-file.test.js +133 -133
  417. package/test/source-files-route.test.js +103 -103
  418. package/test/split-a-stretch.test.js +139 -139
  419. package/test/stale-request-after-seek.test.js +158 -158
  420. package/test/startup-readings.test.js +124 -124
  421. package/test/stream-route.test.js +182 -182
  422. package/test/subtitle-cue-framing.test.js +200 -200
  423. package/test/subtitle-cue-source.test.js +102 -102
  424. package/test/subtitle-cue-walk.test.js +370 -370
  425. package/test/subtitle-cursor.test.js +63 -63
  426. package/test/subtitle-defaults.test.js +118 -118
  427. package/test/subtitle-language.test.js +252 -252
  428. package/test/subtitle-track-numbering.test.js +386 -386
  429. package/test/supply-margin.test.js +177 -177
  430. package/test/swarm-follows-readers.test.js +174 -174
  431. package/test/swarm-reach.test.js +130 -130
  432. package/test/swarm-selection.test.js +220 -220
  433. package/test/tail-duplication.test.js +167 -167
  434. package/test/the-channel-has-both-ends.test.js +91 -91
  435. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  436. package/test/thread-pool.test.js +53 -53
  437. package/test/timeline.test.js +84 -84
  438. package/test/torrent-contents.test.js +235 -235
  439. package/test/torrent-cost.test.js +71 -71
  440. package/test/torrent-files.test.js +221 -221
  441. package/test/torrent-naming.test.js +255 -255
  442. package/test/tracks-begin-together.test.js +209 -209
  443. package/test/tunnel-renewal.test.js +167 -167
  444. package/test/two-viewers-one-picture.test.js +407 -407
  445. package/test/upload-hurry.test.js +186 -186
  446. package/test/usrsctp-state.test.js +131 -131
  447. package/test/video-facts.test.js +102 -102
  448. package/test/viewer-assignments.test.js +143 -143
  449. package/test/viewer-outputs.test.js +285 -285
  450. package/test/viewer-presence.test.js +116 -116
  451. package/test/viewer-requests-admission.test.js +89 -89
  452. package/test/viewer-states.test.js +189 -189
  453. package/test/viewer-subtitle-subscription.test.js +96 -96
  454. package/test/viewer.test.js +139 -139
  455. package/test/viewing-generations.test.js +390 -390
  456. package/test/visible-rung.test.js +37 -37
  457. package/test/wait-ledger.test.js +100 -100
  458. package/test/webrtc-shim.test.js +149 -149
  459. package/test/wedge-certainty.test.js +131 -131
  460. package/test/withdraw-piece-claim.test.js +212 -212
  461. package/test/worker-channel.test.js +158 -158
  462. package/utils/client-log-file.js +151 -151
  463. package/utils/logger.js +312 -312
  464. package/utils/parse-range.js +25 -25
  465. package/utils/perf.js +59 -59
@@ -1,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
+ }