@torrent-tv/proxy 2.89.6 → 2.89.7

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