@torrent-tv/proxy 2.88.2 → 2.88.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 (475) 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 +2259 -2246
  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 +23 -4
  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 -29
  85. package/routes/api/playback-plan/post.js +53 -53
  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 -217
  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 +138 -138
  92. package/routes/api/transcode-sessions/post.js +178 -173
  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 +380 -380
  99. package/routes/transcode/audio-file/get.js +64 -64
  100. package/routes/transcode/audio-warm/get.js +95 -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 +725 -725
  106. package/services/encode/CoverageMap.js +428 -428
  107. package/services/encode/CushionReport.js +345 -345
  108. package/services/encode/EncodeAdmission.js +376 -376
  109. package/services/encode/EncodeOrchestrator.js +1217 -1217
  110. package/services/encode/EncodePlan.js +1366 -1366
  111. package/services/encode/EncodeRun.js +813 -813
  112. package/services/encode/EncodeRuns.js +1235 -1235
  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 +783 -758
  118. package/services/encode/OutputTimes.js +579 -579
  119. package/services/encode/QsvEncoder.js +58 -58
  120. package/services/encode/Renditions.js +2282 -2184
  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 +418 -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 +1150 -1141
  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 +349 -254
  158. package/services/encode/quality/output-format.js +319 -315
  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 +754 -754
  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 +456 -456
  166. package/services/encode/segment-formats/index.js +85 -85
  167. package/services/encode/segment-formats/init-compat.js +324 -324
  168. package/services/encode/segment-formats/mp4-boxes.js +21 -6
  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 +298 -245
  180. package/services/media/container/AviContainer.js +266 -266
  181. package/services/media/container/Container.js +609 -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 +1344 -1244
  186. package/services/media/container/ebml-reader.js +159 -159
  187. package/services/media/ffmpeg-banner.js +260 -260
  188. package/services/media/keyframe-probe.js +157 -157
  189. package/services/media/media-info-probe.js +93 -93
  190. package/services/media/playback-planner.js +931 -931
  191. package/services/media/tracks/AudioTrack.js +143 -131
  192. package/services/media/tracks/ContainerTrack.js +75 -75
  193. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  194. package/services/media/tracks/SubtitleTrack.js +38 -38
  195. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  196. package/services/media/tracks/VideoTrack.js +33 -33
  197. package/services/media/tracks/audio-bound.js +233 -0
  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 +1150 -1150
  202. package/services/server/ViewerRequests.js +760 -760
  203. package/services/server/WaitLedger.js +142 -142
  204. package/services/server/controllers/PlaybackController.js +40 -40
  205. package/services/server/controllers/SubtitleController.js +109 -109
  206. package/services/server/http-responses.js +60 -60
  207. package/services/server/playback-readiness.js +13 -8
  208. package/services/server/transcode-session-files.js +325 -325
  209. package/services/server/wire-outputs.js +995 -992
  210. package/services/storage/Diagnostics.js +202 -202
  211. package/services/storage/MachineBudget.js +235 -235
  212. package/services/storage/allowance.js +107 -107
  213. package/services/storage/core-dumps.js +106 -106
  214. package/services/storage/files/CompletedFiles.js +391 -391
  215. package/services/storage/files/piece-from-whole-file.js +118 -118
  216. package/services/storage/free.js +71 -71
  217. package/services/storage/keep.js +48 -48
  218. package/services/storage/machine-memory.js +56 -56
  219. package/services/storage/memory-report.js +575 -575
  220. package/services/storage/piece-store/piece-disk-store.js +539 -539
  221. package/services/storage/piece-store/piece-lru.js +495 -495
  222. package/services/storage/piece-store/shared-piece-store.js +1931 -1931
  223. package/services/storage/returns.js +103 -103
  224. package/services/storage/segment-store/SegmentStore.js +1240 -1240
  225. package/services/storage/wire.js +175 -175
  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/episode-naming.js +182 -182
  235. package/services/torrent/files.js +455 -455
  236. package/services/torrent/naming.js +619 -619
  237. package/services/torrent/piece-store-of.js +29 -29
  238. package/services/torrent/supply-margin.js +262 -262
  239. package/services/torrent/torrent-pool.js +2675 -2675
  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 -765
  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 +371 -371
  249. package/services/torrent/worker/protocol.js +184 -184
  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 -1129
  253. package/services/transport/data-channel-handler.js +1369 -1369
  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 +691 -691
  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 +71 -71
  275. package/test/asset-paths.test.js +28 -28
  276. package/test/audio-buffer-continuity.test.js +70 -0
  277. package/test/audio-inventory.test.js +212 -212
  278. package/test/audio-track-bitrate.test.js +40 -40
  279. package/test/auto-quality-step.test.js +707 -707
  280. package/test/available-share.test.js +76 -76
  281. package/test/background-fill.test.js +190 -190
  282. package/test/client-log-file.test.js +110 -110
  283. package/test/completed-files.test.js +115 -115
  284. package/test/concurrent-cost.test.js +148 -148
  285. package/test/container-media-info.test.js +228 -228
  286. package/test/contention.test.js +76 -76
  287. package/test/core-dumps.test.js +57 -57
  288. package/test/coverage-follows-the-disk.test.js +228 -228
  289. package/test/coverage-map.test.js +195 -195
  290. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  291. package/test/cut-times-timeline.test.js +65 -65
  292. package/test/cuts-follow-published-grid.test.js +109 -109
  293. package/test/data-channel-frame.test.js +63 -63
  294. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  295. package/test/dead-torrent-handle.test.js +52 -52
  296. package/test/declared-rates.test.js +116 -116
  297. package/test/declared-tracks.test.js +43 -43
  298. package/test/decode-cost-fit.test.js +202 -202
  299. package/test/decode-cost.test.js +462 -462
  300. package/test/delivery-probe.test.js +393 -393
  301. package/test/demand-register.test.js +206 -206
  302. package/test/dht-bootstrap.test.js +72 -72
  303. package/test/drain-threat.test.js +65 -65
  304. package/test/duplicate-add.test.js +48 -48
  305. package/test/encode-admission.test.js +146 -146
  306. package/test/encode-cost.test.js +283 -283
  307. package/test/encode-exit.test.js +111 -111
  308. package/test/encode-orchestrator.test.js +419 -419
  309. package/test/encode-plan-viewers.test.js +718 -718
  310. package/test/encode-plan.test.js +545 -545
  311. package/test/encode-run-state.test.js +314 -314
  312. package/test/encode-run.test.js +162 -162
  313. package/test/encode-slope.test.js +46 -46
  314. package/test/encoded-output.test.js +39 -39
  315. package/test/encoder-kinds.test.js +122 -122
  316. package/test/encoder-readings.test.js +46 -46
  317. package/test/episode-naming.test.js +131 -131
  318. package/test/every-claimant-is-told-its-share.test.js +152 -152
  319. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  320. package/test/failed-start-breaker.test.js +202 -202
  321. package/test/fastest-wires.test.js +143 -143
  322. package/test/ffmpeg-stream-counts.test.js +78 -78
  323. package/test/file-edges.test.js +191 -191
  324. package/test/fixtures/audio-frame-boundary.json +1 -0
  325. package/test/fmp4-presentation-clock.test.js +147 -147
  326. package/test/fmp4-self-contained.test.js +80 -80
  327. package/test/fmp4-timestamps.test.js +140 -140
  328. package/test/fmp4-track-completeness.test.js +41 -41
  329. package/test/format-keyed-outputs.test.js +78 -78
  330. package/test/health-metrics.test.js +38 -38
  331. package/test/held-request-width.test.js +157 -157
  332. package/test/helpers/encode-run.js +136 -136
  333. package/test/helpers/held-file.js +29 -29
  334. package/test/helpers/manager.js +57 -57
  335. package/test/helpers/output-spec.js +63 -63
  336. package/test/host-load.test.js +68 -68
  337. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  338. package/test/init-compat.test.js +219 -219
  339. package/test/input-lost-quiets-the-plan.test.js +261 -261
  340. package/test/input-unavailable.test.js +48 -48
  341. package/test/installation-id.test.js +39 -39
  342. package/test/keeping-period.test.js +83 -83
  343. package/test/keyframe-index-accuracy.test.js +188 -188
  344. package/test/keyframe-table-budget.test.js +102 -102
  345. package/test/keyframes-belong-to-the-file.test.js +249 -249
  346. package/test/learned-median.test.js +37 -37
  347. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  348. package/test/limit-row-by-area.test.js +60 -60
  349. package/test/link-budget.test.js +112 -111
  350. package/test/link-probe.test.js +56 -56
  351. package/test/local-encoding-data.test.js +273 -273
  352. package/test/logger-repeats.test.js +134 -134
  353. package/test/look-ahead-coverage.test.js +56 -56
  354. package/test/manager-surface.test.js +292 -292
  355. package/test/matroska-cues-track.test.js +192 -192
  356. package/test/memory-budget.test.js +390 -390
  357. package/test/move-cost.test.js +216 -216
  358. package/test/mp4-subtitles.test.js +173 -173
  359. package/test/one-authority.test.js +285 -285
  360. package/test/one-budget.test.js +149 -149
  361. package/test/one-output-one-session.test.js +270 -204
  362. package/test/one-resource-divided.test.js +185 -185
  363. package/test/one-serving-output-per-height.test.js +458 -458
  364. package/test/orchestrator-wired.test.js +206 -206
  365. package/test/output-catalog.test.js +39 -39
  366. package/test/output-shape.test.js +21 -21
  367. package/test/output-spec.test.js +251 -251
  368. package/test/output-speed.test.js +88 -88
  369. package/test/outputs-being-prepared.test.js +40 -40
  370. package/test/packet-witness-ring.test.js +236 -236
  371. package/test/packet-witness.test.js +148 -148
  372. package/test/piece-disk-store.test.js +401 -401
  373. package/test/piece-from-whole-file.test.js +129 -129
  374. package/test/piece-lru.test.js +319 -319
  375. package/test/piece-reader.test.js +162 -162
  376. package/test/piece-selection-offset.test.js +85 -85
  377. package/test/piece-store-eviction.test.js +595 -595
  378. package/test/piece-store-never-refuses.test.js +153 -153
  379. package/test/piece-store-reservations.test.js +403 -403
  380. package/test/piece-store-slow-disk.test.js +159 -159
  381. package/test/piece-tail.test.js +97 -97
  382. package/test/plan-host-timings.test.js +68 -68
  383. package/test/plan-is-steady.test.js +225 -225
  384. package/test/playback-readiness.test.js +453 -453
  385. package/test/playback-start-regressions.test.js +99 -99
  386. package/test/priority-map-download.test.js +174 -174
  387. package/test/priority-map-emptied.test.js +204 -204
  388. package/test/priority-map-per-output.test.js +215 -215
  389. package/test/priority-map.test.js +163 -163
  390. package/test/probe-wedge-certainty.test.js +60 -60
  391. package/test/produced-copy-choice.test.js +213 -213
  392. package/test/published-timeline.test.js +83 -83
  393. package/test/quality-offer.test.js +162 -162
  394. package/test/quality-variants.test.js +1257 -1229
  395. package/test/rate-control-in-the-output.test.js +180 -180
  396. package/test/read-survives-withdrawal.test.js +164 -164
  397. package/test/read-window.test.js +365 -365
  398. package/test/resume-warm.test.js +39 -39
  399. package/test/run-graph-drift.test.js +37 -37
  400. package/test/run-inputs.test.js +80 -80
  401. package/test/run-intervals.test.js +100 -100
  402. package/test/run-position-follows-published-grid.test.js +89 -89
  403. package/test/run-progress.test.js +65 -65
  404. package/test/same-height-switch.test.js +664 -664
  405. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  406. package/test/seek-landing.test.js +109 -109
  407. package/test/seek-target-not-superseded.test.js +195 -195
  408. package/test/segment-closed-on-both-branches.test.js +96 -96
  409. package/test/segment-cut-times.test.js +63 -63
  410. package/test/segment-demand.test.js +80 -80
  411. package/test/segment-hold-supersede.test.js +91 -91
  412. package/test/segment-judged-by-its-own-header.test.js +221 -221
  413. package/test/segment-serve-wiring.test.js +475 -475
  414. package/test/segment-store-eviction.test.js +247 -247
  415. package/test/segment-store.test.js +323 -323
  416. package/test/segment-true-start.test.js +86 -86
  417. package/test/segments-are-shared.test.js +176 -176
  418. package/test/send-chunk-bytes.test.js +86 -86
  419. package/test/serving-existing-output.test.js +105 -102
  420. package/test/serving-output.test.js +102 -102
  421. package/test/shared-piece-store.test.js +310 -310
  422. package/test/sidecar-naming.test.js +142 -142
  423. package/test/soundtrack-rate-bound.test.js +289 -0
  424. package/test/source-file.test.js +133 -133
  425. package/test/source-files-route.test.js +113 -113
  426. package/test/source-registry.test.js +18 -18
  427. package/test/split-a-stretch.test.js +139 -139
  428. package/test/stale-request-after-seek.test.js +158 -158
  429. package/test/startup-readings.test.js +124 -124
  430. package/test/stream-route.test.js +209 -209
  431. package/test/subtitle-cue-framing.test.js +200 -200
  432. package/test/subtitle-cue-source.test.js +102 -102
  433. package/test/subtitle-cue-walk.test.js +370 -370
  434. package/test/subtitle-cursor.test.js +63 -63
  435. package/test/subtitle-defaults.test.js +118 -118
  436. package/test/subtitle-language.test.js +252 -252
  437. package/test/subtitle-track-numbering.test.js +386 -386
  438. package/test/supply-margin.test.js +177 -177
  439. package/test/swarm-follows-readers.test.js +174 -174
  440. package/test/swarm-reach.test.js +130 -130
  441. package/test/swarm-selection.test.js +220 -220
  442. package/test/tail-duplication.test.js +167 -167
  443. package/test/the-channel-has-both-ends.test.js +91 -91
  444. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  445. package/test/thread-pool.test.js +53 -53
  446. package/test/timeline.test.js +84 -84
  447. package/test/torrent-contents.test.js +235 -235
  448. package/test/torrent-cost.test.js +71 -71
  449. package/test/torrent-files.test.js +221 -221
  450. package/test/torrent-naming.test.js +255 -255
  451. package/test/tracks-begin-together.test.js +209 -209
  452. package/test/tunnel-renewal.test.js +167 -167
  453. package/test/two-viewers-one-picture.test.js +504 -407
  454. package/test/upload-hurry.test.js +186 -186
  455. package/test/usrsctp-state.test.js +131 -131
  456. package/test/video-facts.test.js +102 -102
  457. package/test/viewer-assignments.test.js +143 -143
  458. package/test/viewer-outputs.test.js +285 -285
  459. package/test/viewer-presence.test.js +116 -116
  460. package/test/viewer-requests-admission.test.js +89 -89
  461. package/test/viewer-states.test.js +189 -189
  462. package/test/viewer-subtitle-subscription.test.js +96 -96
  463. package/test/viewer.test.js +139 -139
  464. package/test/viewing-generations.test.js +390 -390
  465. package/test/visible-rung.test.js +37 -37
  466. package/test/wait-ledger.test.js +100 -100
  467. package/test/webrtc-shim.test.js +149 -149
  468. package/test/wedge-certainty.test.js +131 -131
  469. package/test/withdraw-piece-claim.test.js +212 -212
  470. package/test/worker-channel.test.js +158 -158
  471. package/utils/client-log-file.js +151 -151
  472. package/utils/logger.js +312 -312
  473. package/utils/parse-range.js +25 -25
  474. package/utils/perf.js +59 -59
  475. package/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
+ }