@torrent-tv/proxy 2.87.21 → 2.88.0

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 (474) 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 +4 -0
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/diagnostics/sctpstate.gdb +31 -31
  19. package/bin/cli.js +684 -684
  20. package/bin/thread-pool.js +30 -30
  21. package/biome.json +411 -411
  22. package/docs/browser-proxy-contract.md +133 -133
  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/audio-tracks/post.js +29 -29
  85. package/routes/api/playback-plan/post.js +53 -53
  86. package/routes/api/sources/files/get.js +106 -100
  87. package/routes/api/sources/post.js +36 -36
  88. package/routes/api/sources/stats/get.js +132 -132
  89. package/routes/api/sources/warm/post.js +217 -217
  90. package/routes/api/subtitles/get.js +230 -230
  91. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  92. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  93. package/routes/api/transcode-sessions/post.js +173 -173
  94. package/routes/api/transcode-sessions/progress/get.js +27 -27
  95. package/routes/api/transcode-sessions/release/post.js +41 -41
  96. package/routes/api/transcode-sessions/seek/post.js +63 -63
  97. package/routes/health/get.js +13 -13
  98. package/routes/healthz/get.js +13 -13
  99. package/routes/stream/get.js +380 -380
  100. package/routes/transcode/audio-file/get.js +64 -64
  101. package/routes/transcode/audio-warm/get.js +84 -84
  102. package/routes/transcode/session-file/get.js +52 -52
  103. package/routes/transcode/variant-file/get.js +95 -95
  104. package/routes/transcode/variant-warm/get.js +84 -84
  105. package/scripts/render-run-graph.js +173 -173
  106. package/server.js +725 -725
  107. package/services/encode/CoverageMap.js +428 -428
  108. package/services/encode/CushionReport.js +345 -345
  109. package/services/encode/EncodeAdmission.js +376 -376
  110. package/services/encode/EncodeOrchestrator.js +1217 -1217
  111. package/services/encode/EncodePlan.js +1366 -1366
  112. package/services/encode/EncodeRun.js +813 -813
  113. package/services/encode/EncodeRuns.js +1235 -1235
  114. package/services/encode/Encoder.js +142 -142
  115. package/services/encode/EncoderSelection.js +38 -38
  116. package/services/encode/LocalObservations.js +359 -359
  117. package/services/encode/NvencEncoder.js +50 -50
  118. package/services/encode/OutputOpening.js +758 -758
  119. package/services/encode/OutputTimes.js +579 -579
  120. package/services/encode/QsvEncoder.js +58 -58
  121. package/services/encode/Renditions.js +2184 -2184
  122. package/services/encode/RunProgress.js +84 -84
  123. package/services/encode/SegmentDemand.js +95 -95
  124. package/services/encode/SoftwareEncoder.js +121 -121
  125. package/services/encode/V4l2m2mEncoder.js +53 -53
  126. package/services/encode/VaapiEncoder.js +66 -66
  127. package/services/encode/args.js +416 -416
  128. package/services/encode/available-share.js +89 -89
  129. package/services/encode/calibration.js +361 -361
  130. package/services/encode/contention.js +128 -128
  131. package/services/encode/decode-cost-fit.js +363 -363
  132. package/services/encode/encode-exit.js +114 -114
  133. package/services/encode/encode-run-state.js +551 -551
  134. package/services/encode/encoder-readings.js +45 -45
  135. package/services/encode/fingerprint.js +322 -322
  136. package/services/encode/host-load.js +174 -174
  137. package/services/encode/hwaccel.js +1894 -1894
  138. package/services/encode/index.js +9 -9
  139. package/services/encode/learned-median.js +90 -90
  140. package/services/encode/output/EncodedOutput.js +57 -57
  141. package/services/encode/output/Output.js +131 -131
  142. package/services/encode/output/OutputCatalog.js +414 -414
  143. package/services/encode/output/OutputSpec.js +390 -390
  144. package/services/encode/output/Timeline.js +308 -308
  145. package/services/encode/output/cut-grid.js +107 -107
  146. package/services/encode/output/index.js +20 -20
  147. package/services/encode/output/ladder.js +32 -32
  148. package/services/encode/output/playlists.js +284 -284
  149. package/services/encode/output/rates.js +125 -125
  150. package/services/encode/output-key-format.js +21 -21
  151. package/services/encode/quality/EncodeCost.js +1141 -1141
  152. package/services/encode/quality/HostLoad.js +525 -525
  153. package/services/encode/quality/HostTimings.js +249 -249
  154. package/services/encode/quality/OutputQualityState.js +28 -28
  155. package/services/encode/quality/QualityController.js +935 -935
  156. package/services/encode/quality/QualityOffer.js +416 -416
  157. package/services/encode/quality/drain-threat.js +47 -47
  158. package/services/encode/quality/link-budget.js +254 -254
  159. package/services/encode/quality/output-format.js +315 -315
  160. package/services/encode/quality/serving-output.js +216 -216
  161. package/services/encode/quality/visible-rung.js +57 -57
  162. package/services/encode/run-budget.js +97 -97
  163. package/services/encode/run-command.js +754 -754
  164. package/services/encode/run-costs.js +155 -155
  165. package/services/encode/run-inputs.js +42 -42
  166. package/services/encode/segment-formats/fmp4.js +456 -456
  167. package/services/encode/segment-formats/index.js +85 -85
  168. package/services/encode/segment-formats/init-compat.js +324 -324
  169. package/services/encode/segment-formats/mp4-boxes.js +515 -515
  170. package/services/encode/segment-formats/mpegts.js +134 -134
  171. package/services/encode/start-stop-cost.js +178 -178
  172. package/services/encode/throughput.js +97 -97
  173. package/services/encode/torrent-cost.js +88 -88
  174. package/services/identity/installation-id.js +38 -38
  175. package/services/media/ContainerOrchestrator.js +149 -149
  176. package/services/media/KeyframeTables.js +316 -316
  177. package/services/media/SourceFile.js +298 -298
  178. package/services/media/SubtitleCues.js +520 -520
  179. package/services/media/SubtitleOrchestrator.js +167 -167
  180. package/services/media/audio-inventory.js +245 -245
  181. package/services/media/container/AviContainer.js +266 -266
  182. package/services/media/container/Container.js +603 -603
  183. package/services/media/container/ContainerFactory.js +56 -56
  184. package/services/media/container/KeyframeTable.js +272 -272
  185. package/services/media/container/MatroskaContainer.js +1494 -1494
  186. package/services/media/container/Mp4Container.js +1244 -1244
  187. package/services/media/container/SubtitleFileContainer.js +322 -322
  188. package/services/media/container/ebml-reader.js +159 -159
  189. package/services/media/ffmpeg-banner.js +260 -260
  190. package/services/media/keyframe-probe.js +157 -157
  191. package/services/media/media-info-probe.js +93 -93
  192. package/services/media/playback-planner.js +931 -931
  193. package/services/media/tracks/AudioTrack.js +131 -131
  194. package/services/media/tracks/ContainerTrack.js +75 -75
  195. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  196. package/services/media/tracks/SubtitleTrack.js +38 -38
  197. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  198. package/services/media/tracks/VideoTrack.js +33 -33
  199. package/services/media/tracks/index.js +15 -15
  200. package/services/media/tracks/language-detect.js +174 -174
  201. package/services/server/OutputLifecycle.js +418 -418
  202. package/services/server/SegmentServing.js +1150 -1150
  203. package/services/server/ViewerRequests.js +760 -760
  204. package/services/server/WaitLedger.js +142 -142
  205. package/services/server/controllers/PlaybackController.js +40 -40
  206. package/services/server/controllers/SubtitleController.js +109 -109
  207. package/services/server/http-responses.js +60 -60
  208. package/services/server/playback-readiness.js +696 -696
  209. package/services/server/transcode-session-files.js +325 -325
  210. package/services/server/wire-outputs.js +992 -992
  211. package/services/storage/Diagnostics.js +202 -202
  212. package/services/storage/MachineBudget.js +235 -235
  213. package/services/storage/allowance.js +107 -107
  214. package/services/storage/core-dumps.js +106 -106
  215. package/services/storage/files/CompletedFiles.js +391 -391
  216. package/services/storage/files/piece-from-whole-file.js +118 -118
  217. package/services/storage/free.js +71 -71
  218. package/services/storage/keep.js +48 -48
  219. package/services/storage/machine-memory.js +56 -56
  220. package/services/storage/memory-report.js +575 -575
  221. package/services/storage/piece-store/piece-disk-store.js +539 -539
  222. package/services/storage/piece-store/piece-lru.js +495 -495
  223. package/services/storage/piece-store/shared-piece-store.js +1931 -1931
  224. package/services/storage/returns.js +103 -103
  225. package/services/storage/segment-store/SegmentStore.js +1240 -1240
  226. package/services/storage/wire.js +175 -175
  227. package/services/torrent/Contents.js +382 -324
  228. package/services/torrent/demand/DemandRegister.js +202 -202
  229. package/services/torrent/demand/Urgency.js +137 -137
  230. package/services/torrent/demand/Window.js +118 -118
  231. package/services/torrent/demand/index.js +8 -8
  232. package/services/torrent/demand/pieces.js +140 -140
  233. package/services/torrent/download/SwarmSelection.js +386 -386
  234. package/services/torrent/download/registry.js +102 -102
  235. package/services/torrent/download/withdraw-claim.js +80 -80
  236. package/services/torrent/episode-naming.js +182 -0
  237. package/services/torrent/files.js +455 -455
  238. package/services/torrent/naming.js +619 -619
  239. package/services/torrent/piece-store-of.js +29 -29
  240. package/services/torrent/supply-margin.js +262 -262
  241. package/services/torrent/torrent-pool.js +2675 -2675
  242. package/services/torrent/worker/background-fill.js +213 -213
  243. package/services/torrent/worker/channel.js +271 -271
  244. package/services/torrent/worker/client.js +765 -765
  245. package/services/torrent/worker/fastest-wires.js +342 -342
  246. package/services/torrent/worker/handle-state.js +23 -23
  247. package/services/torrent/worker/held-bytes.js +155 -155
  248. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  249. package/services/torrent/worker/piece-reader.js +1078 -1078
  250. package/services/torrent/worker/pool-adapter.js +371 -371
  251. package/services/torrent/worker/protocol.js +184 -184
  252. package/services/torrent/worker/resume-warm.js +107 -107
  253. package/services/torrent/worker/webrtc-shim.js +219 -219
  254. package/services/torrent/worker/worker.js +1129 -1129
  255. package/services/transport/data-channel-handler.js +1369 -1369
  256. package/services/transport/delivery-probe.js +842 -842
  257. package/services/transport/health-collector.js +53 -53
  258. package/services/transport/nat-classifier.js +231 -231
  259. package/services/transport/packet-witness.js +808 -808
  260. package/services/transport/port-mapper.js +235 -235
  261. package/services/transport/registry-api.js +81 -81
  262. package/services/transport/tunnel-client.js +447 -447
  263. package/services/transport/usrsctp-state.js +175 -175
  264. package/services/transport/webrtc-manager.js +484 -484
  265. package/services/viewer/Assignments.js +340 -340
  266. package/services/viewer/PriorityMap.js +390 -390
  267. package/services/viewer/PriorityOrchestrator.js +325 -325
  268. package/services/viewer/Viewer.js +691 -691
  269. package/services/viewer/Viewers.js +368 -368
  270. package/services/viewer/active-output.js +37 -37
  271. package/services/viewer/audio-start.js +46 -46
  272. package/services/viewer/buffer-trend.js +81 -81
  273. package/services/viewer/choices.js +647 -647
  274. package/services/viewer/positions.js +126 -126
  275. package/services/viewer/report-intake.js +46 -46
  276. package/store/source-registry.js +71 -71
  277. package/test/asset-paths.test.js +28 -28
  278. package/test/audio-inventory.test.js +212 -212
  279. package/test/audio-track-bitrate.test.js +40 -40
  280. package/test/auto-quality-step.test.js +707 -707
  281. package/test/available-share.test.js +76 -76
  282. package/test/background-fill.test.js +190 -190
  283. package/test/client-log-file.test.js +110 -110
  284. package/test/completed-files.test.js +115 -115
  285. package/test/concurrent-cost.test.js +148 -148
  286. package/test/container-media-info.test.js +228 -228
  287. package/test/contention.test.js +76 -76
  288. package/test/core-dumps.test.js +57 -57
  289. package/test/coverage-follows-the-disk.test.js +228 -228
  290. package/test/coverage-map.test.js +195 -195
  291. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  292. package/test/cut-times-timeline.test.js +65 -65
  293. package/test/cuts-follow-published-grid.test.js +109 -109
  294. package/test/data-channel-frame.test.js +63 -63
  295. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  296. package/test/dead-torrent-handle.test.js +52 -52
  297. package/test/declared-rates.test.js +116 -116
  298. package/test/declared-tracks.test.js +43 -43
  299. package/test/decode-cost-fit.test.js +202 -202
  300. package/test/decode-cost.test.js +462 -462
  301. package/test/delivery-probe.test.js +393 -393
  302. package/test/demand-register.test.js +206 -206
  303. package/test/dht-bootstrap.test.js +72 -72
  304. package/test/drain-threat.test.js +65 -65
  305. package/test/duplicate-add.test.js +48 -48
  306. package/test/encode-admission.test.js +146 -146
  307. package/test/encode-cost.test.js +283 -283
  308. package/test/encode-exit.test.js +111 -111
  309. package/test/encode-orchestrator.test.js +419 -419
  310. package/test/encode-plan-viewers.test.js +718 -718
  311. package/test/encode-plan.test.js +545 -545
  312. package/test/encode-run-state.test.js +314 -314
  313. package/test/encode-run.test.js +162 -162
  314. package/test/encode-slope.test.js +46 -46
  315. package/test/encoded-output.test.js +39 -39
  316. package/test/encoder-kinds.test.js +122 -122
  317. package/test/encoder-readings.test.js +46 -46
  318. package/test/episode-naming.test.js +131 -0
  319. package/test/every-claimant-is-told-its-share.test.js +152 -152
  320. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  321. package/test/failed-start-breaker.test.js +202 -202
  322. package/test/fastest-wires.test.js +143 -143
  323. package/test/ffmpeg-stream-counts.test.js +78 -78
  324. package/test/file-edges.test.js +191 -191
  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 +111 -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 +204 -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 +1229 -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 +102 -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/source-file.test.js +133 -133
  424. package/test/source-files-route.test.js +12 -2
  425. package/test/source-registry.test.js +18 -18
  426. package/test/split-a-stretch.test.js +139 -139
  427. package/test/stale-request-after-seek.test.js +158 -158
  428. package/test/startup-readings.test.js +124 -124
  429. package/test/stream-route.test.js +209 -209
  430. package/test/subtitle-cue-framing.test.js +200 -200
  431. package/test/subtitle-cue-source.test.js +102 -102
  432. package/test/subtitle-cue-walk.test.js +370 -370
  433. package/test/subtitle-cursor.test.js +63 -63
  434. package/test/subtitle-defaults.test.js +118 -118
  435. package/test/subtitle-language.test.js +252 -252
  436. package/test/subtitle-track-numbering.test.js +386 -386
  437. package/test/supply-margin.test.js +177 -177
  438. package/test/swarm-follows-readers.test.js +174 -174
  439. package/test/swarm-reach.test.js +130 -130
  440. package/test/swarm-selection.test.js +220 -220
  441. package/test/tail-duplication.test.js +167 -167
  442. package/test/the-channel-has-both-ends.test.js +91 -91
  443. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  444. package/test/thread-pool.test.js +53 -53
  445. package/test/timeline.test.js +84 -84
  446. package/test/torrent-contents.test.js +235 -235
  447. package/test/torrent-cost.test.js +71 -71
  448. package/test/torrent-files.test.js +221 -221
  449. package/test/torrent-naming.test.js +255 -255
  450. package/test/tracks-begin-together.test.js +209 -209
  451. package/test/tunnel-renewal.test.js +167 -167
  452. package/test/two-viewers-one-picture.test.js +407 -407
  453. package/test/upload-hurry.test.js +186 -186
  454. package/test/usrsctp-state.test.js +131 -131
  455. package/test/video-facts.test.js +102 -102
  456. package/test/viewer-assignments.test.js +143 -143
  457. package/test/viewer-outputs.test.js +285 -285
  458. package/test/viewer-presence.test.js +116 -116
  459. package/test/viewer-requests-admission.test.js +89 -89
  460. package/test/viewer-states.test.js +189 -189
  461. package/test/viewer-subtitle-subscription.test.js +96 -96
  462. package/test/viewer.test.js +139 -139
  463. package/test/viewing-generations.test.js +390 -390
  464. package/test/visible-rung.test.js +37 -37
  465. package/test/wait-ledger.test.js +100 -100
  466. package/test/webrtc-shim.test.js +149 -149
  467. package/test/wedge-certainty.test.js +131 -131
  468. package/test/withdraw-piece-claim.test.js +212 -212
  469. package/test/worker-channel.test.js +158 -158
  470. package/utils/client-log-file.js +151 -151
  471. package/utils/logger.js +312 -312
  472. package/utils/parse-range.js +25 -25
  473. package/utils/perf.js +59 -59
  474. 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
+ }