@torrent-tv/proxy 2.88.3 → 2.88.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +2259 -2250
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/browser-proxy-contract.md +23 -4
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -217
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +138 -138
- package/routes/api/transcode-sessions/post.js +178 -173
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +380 -380
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +95 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/server.js +725 -725
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +376 -376
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +813 -813
- package/services/encode/EncodeRuns.js +1235 -1235
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +783 -758
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2282 -2184
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +418 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1150 -1141
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +349 -254
- package/services/encode/quality/output-format.js +319 -315
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +754 -754
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +456 -456
- package/services/encode/segment-formats/index.js +85 -85
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +298 -245
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +609 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1344 -1244
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +931 -931
- package/services/media/tracks/AudioTrack.js +143 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/audio-bound.js +233 -0
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1150 -1150
- package/services/server/ViewerRequests.js +760 -760
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +995 -992
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +539 -539
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1931 -1931
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1240 -1240
- package/services/storage/wire.js +175 -175
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/episode-naming.js +182 -182
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2675 -2675
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +765 -765
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +371 -371
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/torrent/worker/worker.js +1129 -1129
- package/services/transport/data-channel-handler.js +1369 -1369
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +691 -691
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-buffer-continuity.test.js +70 -70
- package/test/audio-inventory.test.js +212 -212
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/data-channel-viewer-lifecycle.test.js +92 -92
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +43 -43
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +162 -162
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/episode-naming.test.js +131 -131
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fixtures/audio-frame-boundary.json +1 -1
- package/test/fmp4-presentation-clock.test.js +147 -147
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +112 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +270 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +401 -401
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +595 -595
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +403 -403
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/playback-readiness.test.js +453 -453
- package/test/playback-start-regressions.test.js +99 -99
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1257 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +105 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/soundtrack-rate-bound.test.js +289 -0
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +113 -113
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +209 -209
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +504 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- 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
|
+
}
|