@torrent-tv/proxy 2.87.21 → 2.88.1
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 +2242 -2234
- 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 +1 -1
- 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 +50 -50
- 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/files/get.js +106 -100
- 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 +173 -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 +84 -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 +758 -758
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2184 -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 +416 -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 +1141 -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 +254 -254
- package/services/encode/quality/output-format.js +315 -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/mp4-boxes.js +515 -515
- 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 +245 -245
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -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 +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +339 -322
- 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 +131 -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/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/playback-readiness.js +696 -696
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +992 -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/Contents.js +382 -324
- 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 -0
- 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-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 -0
- 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/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 +111 -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 +204 -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 +1229 -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 +102 -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/source-file.test.js +133 -133
- package/test/source-files-route.test.js +12 -2
- 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-metadata.test.js +15 -0
- 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 +407 -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,414 +1,414 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The one catalog of the outputs that exist, and every question about
|
|
3
|
-
* them.
|
|
4
|
-
*
|
|
5
|
-
* It replaces two classes that held two views of one collection: a registry of
|
|
6
|
-
* the live outputs and their entry times, and a set of queries walking that
|
|
7
|
-
* same registry — which outputs belong to one file, which is the picture and
|
|
8
|
-
* which are its steps and soundtracks, what a step's height is. An output is
|
|
9
|
-
* found by what it IS, never by a list of ids somebody keeps.
|
|
10
|
-
*
|
|
11
|
-
* Presence is the only statement that an output exists. Creation and access
|
|
12
|
-
* times describe the catalog entry, not the media, the viewer or the encoder,
|
|
13
|
-
* so they are kept here and never copied onto the output. What has been
|
|
14
|
-
* produced is not here either: that is the segment store's, and which runs are
|
|
15
|
-
* going is the encoding orchestrator's.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { variantHeightsFor } from "./ladder.js";
|
|
19
|
-
|
|
20
|
-
import { masterRateArgs } from "./rates.js";
|
|
21
|
-
|
|
22
|
-
export class OutputCatalog {
|
|
23
|
-
#steps = new WeakSet();
|
|
24
|
-
/** The heights each picture can be spliced to, derived once from its file. @type {WeakMap<object, number[]>} */
|
|
25
|
-
#splicable = new WeakMap();
|
|
26
|
-
|
|
27
|
-
#byId = new Map();
|
|
28
|
-
#lifetime = new Map();
|
|
29
|
-
#now;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @param {object} [params]
|
|
33
|
-
* @param {() => number} [params.now]
|
|
34
|
-
* @param {(output: object) => number} [params.fileLengthOf] - How many bytes a
|
|
35
|
-
* source file is, which the torrent reports.
|
|
36
|
-
* @param {(address: string) => { index: number, size: number }} [params.largestPieceOf]
|
|
37
|
-
* The biggest piece an output has made, which the disk knows.
|
|
38
|
-
*/
|
|
39
|
-
constructor({
|
|
40
|
-
now = Date.now,
|
|
41
|
-
fileLengthOf = () => 0,
|
|
42
|
-
largestPieceOf = () => ({ index: -1, size: 0 })
|
|
43
|
-
} = {}) {
|
|
44
|
-
this.#now = now;
|
|
45
|
-
// Two facts this catalog needs and does not own, taken as plain functions
|
|
46
|
-
// so nothing of their layers is held here: the torrent's byte length and
|
|
47
|
-
// the store's largest piece. The bitrate limit is not among them any more:
|
|
48
|
-
// it is part of the output itself (`OutputSpec`).
|
|
49
|
-
this.fileLengthOf = fileLengthOf;
|
|
50
|
-
this.largestPieceOf = largestPieceOf;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
set(id, output) {
|
|
54
|
-
const known = this.#byId.get(id);
|
|
55
|
-
this.#byId.set(id, output);
|
|
56
|
-
if (known !== output) {
|
|
57
|
-
const at = this.#now();
|
|
58
|
-
this.#lifetime.set(id, { startedAt: at, lastAccessedAt: at });
|
|
59
|
-
}
|
|
60
|
-
return this;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
get(id) {
|
|
64
|
-
return this.#byId.get(id);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
has(id) {
|
|
68
|
-
return this.#byId.has(id);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
delete(id) {
|
|
72
|
-
this.#lifetime.delete(id);
|
|
73
|
-
return this.#byId.delete(id);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
clear() {
|
|
77
|
-
this.#lifetime.clear();
|
|
78
|
-
this.#byId.clear();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
touch(outputOrId, at = this.#now()) {
|
|
82
|
-
const id = typeof outputOrId === "string" ? outputOrId : outputOrId?.id;
|
|
83
|
-
const lifetime = this.#lifetime.get(id);
|
|
84
|
-
if (!lifetime || !Number.isFinite(at)) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
lifetime.lastAccessedAt = at;
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
startedAt(outputOrId) {
|
|
92
|
-
const id = typeof outputOrId === "string" ? outputOrId : outputOrId?.id;
|
|
93
|
-
return this.#lifetime.get(id)?.startedAt ?? null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
lastAccessedAt(outputOrId) {
|
|
97
|
-
const id = typeof outputOrId === "string" ? outputOrId : outputOrId?.id;
|
|
98
|
-
return this.#lifetime.get(id)?.lastAccessedAt ?? null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
expiredBefore(cutoff) {
|
|
102
|
-
const ids = [];
|
|
103
|
-
for (const [id, lifetime] of this.#lifetime) {
|
|
104
|
-
if (lifetime.lastAccessedAt < cutoff) {
|
|
105
|
-
ids.push(id);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return ids;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
keys() {
|
|
112
|
-
return this.#byId.keys();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
values() {
|
|
116
|
-
return this.#byId.values();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
entries() {
|
|
120
|
-
return this.#byId.entries();
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
get size() {
|
|
124
|
-
return this.#byId.size;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
[Symbol.iterator]() {
|
|
128
|
-
return this.#byId[Symbol.iterator]();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Every live record producing ONE output.
|
|
133
|
-
*
|
|
134
|
-
* The output is the address the encoding layer works in: two sessions whose
|
|
135
|
-
* output parameters agree ARE the same output, so what one of them measured
|
|
136
|
-
* about the machine is true of the other.
|
|
137
|
-
*
|
|
138
|
-
* @param {string} address
|
|
139
|
-
* @returns {object[]}
|
|
140
|
-
*/
|
|
141
|
-
outputsOn(address) {
|
|
142
|
-
const found = [];
|
|
143
|
-
for (const session of this.values()) {
|
|
144
|
-
if (session.outputKey === address) {
|
|
145
|
-
found.push(session);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return found;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Every live session of one file: the picture, its quality steps, and the
|
|
153
|
-
* soundtracks published separately.
|
|
154
|
-
*
|
|
155
|
-
* The file is what they share, so the file is what this asks about. Where the
|
|
156
|
-
* sums this feeds are concerned that is also the right question: two pictures
|
|
157
|
-
* of one file are two encoders on one machine whether or not anybody thinks
|
|
158
|
-
* of them as one film.
|
|
159
|
-
*
|
|
160
|
-
* @param {object} session
|
|
161
|
-
* @returns {object[]}
|
|
162
|
-
*/
|
|
163
|
-
familyOf(session) {
|
|
164
|
-
const family = [session];
|
|
165
|
-
const key = session?.file?.key;
|
|
166
|
-
for (const other of this.values()) {
|
|
167
|
-
if (other === session || other.file?.key !== key) {
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
family.push(other);
|
|
171
|
-
}
|
|
172
|
-
return family;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The soundtracks published separately for this picture, live ones only.
|
|
177
|
-
*
|
|
178
|
-
* @param {object} base
|
|
179
|
-
* @returns {object[]}
|
|
180
|
-
*/
|
|
181
|
-
renditionsOf(base) {
|
|
182
|
-
return this.familyOf(base).filter((session) => session !== base && session.spec.carries === "audio-only");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* The quality steps of this picture, live ones only.
|
|
187
|
-
*
|
|
188
|
-
* A step is a session made as one — `isStep`, written where it is created —
|
|
189
|
-
* and not merely "another session of this file that carries a picture". The
|
|
190
|
-
* difference is the second picture: one file can hold two — a browser that
|
|
191
|
-
* understands rendition groups and one that needs the sound muxed in produce
|
|
192
|
-
* two — and calling one of them a step of the other would let a switch away
|
|
193
|
-
* from a step stop the encoder of somebody else's picture.
|
|
194
|
-
*
|
|
195
|
-
* @param {object} base
|
|
196
|
-
* @returns {object[]}
|
|
197
|
-
*/
|
|
198
|
-
stepsOf(base) {
|
|
199
|
-
return this.familyOf(base).filter((session) => session !== base && this.#steps.has(session));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
markStep(output) {
|
|
203
|
-
this.#steps.add(output);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* The picture a step belongs to, or the session itself when it is not a step.
|
|
208
|
-
*
|
|
209
|
-
* How a session came to be is a fact about it, not a link to another session
|
|
210
|
-
* — so a step whose picture has gone answers for itself rather than following
|
|
211
|
-
* a dead reference, and nothing has to be cleaned from the other side when
|
|
212
|
-
* one of them ends.
|
|
213
|
-
*
|
|
214
|
-
* One file can carry two pictures at once, and then this returns whichever
|
|
215
|
-
* was made first. Everything asked of the answer is a fact of the FILE and of
|
|
216
|
-
* this host: what heights can be offered, what a step costs, when the budget
|
|
217
|
-
* last acted. Two pictures of one file answer all of those alike.
|
|
218
|
-
*
|
|
219
|
-
* @param {object} session
|
|
220
|
-
* @returns {object}
|
|
221
|
-
*/
|
|
222
|
-
pictureOf(session) {
|
|
223
|
-
if (!session || !this.#steps.has(session)) {
|
|
224
|
-
return session;
|
|
225
|
-
}
|
|
226
|
-
for (const other of this.familyOf(session)) {
|
|
227
|
-
if (!this.#steps.has(other) && other.spec.carries !== "audio-only") {
|
|
228
|
-
return other;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return session;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Whether this output has been SUPERSEDED by the step named here.
|
|
236
|
-
*
|
|
237
|
-
* A question about the shape of a film and nothing else: one picture, its
|
|
238
|
-
* steps, its soundtracks. A step supersedes only the picture it is a step of
|
|
239
|
-
* — never another step, never a soundtrack, which are produced or not
|
|
240
|
-
* according to whether anybody asked for them.
|
|
241
|
-
*
|
|
242
|
-
* The step arrives as a plain id. Which step is on somebody's screen is a fact
|
|
243
|
-
* about a PERSON, and this layer neither holds a person nor knows what one
|
|
244
|
-
* looks like; whoever asks holds both and states the id.
|
|
245
|
-
*
|
|
246
|
-
* It is asked while the priority map of one output is built, and it is what
|
|
247
|
-
* makes the difference between a picture somebody is watching and a picture
|
|
248
|
-
* they stepped off. Without it every output of a film was handed the film's
|
|
249
|
-
* whole map, the plan wanted an encoder on each, the session manager killed
|
|
250
|
-
* the ones it judged unwatched, and the viewer's own move started them again
|
|
251
|
-
* — the two authorities of 2026-09-08.
|
|
252
|
-
*
|
|
253
|
-
* @param {object} session
|
|
254
|
-
* @param {string | null} [stepOnScreen] - The step being watched, or null for
|
|
255
|
-
* "the height the film was opened at", which supersedes nothing.
|
|
256
|
-
* @returns {boolean}
|
|
257
|
-
*/
|
|
258
|
-
supersededBy(session, stepOnScreen = null) {
|
|
259
|
-
if (!session || this.#steps.has(session) || session.spec.carries === "audio-only") {
|
|
260
|
-
return false;
|
|
261
|
-
}
|
|
262
|
-
// The picture. A step of it on screen says outright that this is not what
|
|
263
|
-
// is being looked at; a step merely being made READY supersedes nothing,
|
|
264
|
-
// because through a warm-up both are genuinely being produced.
|
|
265
|
-
return typeof stepOnScreen === "string" && stepOnScreen !== session.id;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Which variant a session IS, as a height. Zero encode height means "keep the
|
|
270
|
-
* source", so the source's own height is the answer.
|
|
271
|
-
*
|
|
272
|
-
* Settled once and then kept, because it is a NAME — the player addresses the
|
|
273
|
-
* variant by it for the whole session, having fetched the master exactly
|
|
274
|
-
* once. The height a session encodes at is not stable: the realtime budget
|
|
275
|
-
* steps it down when the host cannot keep up. Deriving the name afresh each
|
|
276
|
-
* time would mean a downshift silently renames the variant the viewer is
|
|
277
|
-
* watching, and the next segment request under the old name would build a
|
|
278
|
-
* SECOND session at the height the host had just proved it could not manage.
|
|
279
|
-
* A downshift changes the picture inside the variant instead, which is what
|
|
280
|
-
* it has always done.
|
|
281
|
-
*
|
|
282
|
-
* @param {object} session
|
|
283
|
-
* @returns {number}
|
|
284
|
-
*/
|
|
285
|
-
variantHeightOf(session) {
|
|
286
|
-
if (Number.isInteger(session.variantHeight) && session.variantHeight > 0) {
|
|
287
|
-
return session.variantHeight;
|
|
288
|
-
}
|
|
289
|
-
const encodeHeight = Number(session.output.encodeHeight) || 0;
|
|
290
|
-
session.variantHeight = encodeHeight > 0
|
|
291
|
-
? encodeHeight
|
|
292
|
-
: Math.round(Number(session.file.height) || 0);
|
|
293
|
-
return session.variantHeight;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* The height a session's encoder is actually producing, or 0 when it produces
|
|
298
|
-
* no encoded picture of its own (a copy, or a soundtrack).
|
|
299
|
-
*
|
|
300
|
-
* A COPY must never be adopted: it costs no encoder at all, so handing it to
|
|
301
|
-
* a request for a re-encoded rung would give away the one thing this host can
|
|
302
|
-
* always serve.
|
|
303
|
-
*
|
|
304
|
-
* @param {object} session
|
|
305
|
-
* @returns {number}
|
|
306
|
-
*/
|
|
307
|
-
producedHeightOf(session) {
|
|
308
|
-
if (!session || !session.spec.transcodesVideo || session.spec.carries === "audio-only") {
|
|
309
|
-
return 0;
|
|
310
|
-
}
|
|
311
|
-
return Math.round(Number(session.output.encodeHeight) || 0);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* The heights this file's variants CAN be spliced at — a fact about the
|
|
316
|
-
* source and the cut grid, settled once and never moved.
|
|
317
|
-
*
|
|
318
|
-
* Separate from which of them are worth OFFERING to the viewer right now, on
|
|
319
|
-
* a machine whose load moves every five seconds. Both were the same list
|
|
320
|
-
* until 2026-08-18, and that is what broke playback outright: the browser is
|
|
321
|
-
* told at session creation that a master playlist exists, and 192 ms later —
|
|
322
|
-
* after the session's own encoder had started and the first supply reading
|
|
323
|
-
* had arrived — the live list had fallen from five rungs to one,
|
|
324
|
-
* `buildMasterPlaylist` returned null for having fewer than two, and the
|
|
325
|
-
* master answered 404 to the very session that had just published it. hls.js
|
|
326
|
-
* treats that as fatal and unrecoverable, so nothing played at all (session
|
|
327
|
-
* `4ef731d8`, "Moana (2016).mkv", 17:43:01).
|
|
328
|
-
*
|
|
329
|
-
* A live figure may decide what to offer. It may not decide whether a
|
|
330
|
-
* published document exists.
|
|
331
|
-
*
|
|
332
|
-
* @param {object} session
|
|
333
|
-
* @returns {number[]} Largest first.
|
|
334
|
-
*/
|
|
335
|
-
splicableHeights(session) {
|
|
336
|
-
const owner = this.pictureOf(session);
|
|
337
|
-
const known = this.#splicable.get(owner);
|
|
338
|
-
if (known) {
|
|
339
|
-
return known;
|
|
340
|
-
}
|
|
341
|
-
const heights = new Set(variantHeightsFor(Number(owner.file.height) || 0));
|
|
342
|
-
const own = this.variantHeightOf(owner);
|
|
343
|
-
if (own > 0) {
|
|
344
|
-
heights.add(own);
|
|
345
|
-
}
|
|
346
|
-
const sorted = [...heights].sort((left, right) => right - left);
|
|
347
|
-
this.#splicable.set(owner, sorted);
|
|
348
|
-
return sorted;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Whether this stream publishes a master playlist at all — that is, whether
|
|
353
|
-
* there is anything for a player to move BETWEEN.
|
|
354
|
-
*
|
|
355
|
-
* Asked in one place because two callers depend on the same answer and used
|
|
356
|
-
* to compute it differently: the builder refused a copied stream whose cut
|
|
357
|
-
* grid is a fiction, while the budget looked only at how many heights could
|
|
358
|
-
* in principle be spliced. A copy with no readable keyframe index therefore
|
|
359
|
-
* had requests recorded against it — asking a player with no variants to
|
|
360
|
-
* change variant, once every window, for the whole film.
|
|
361
|
-
*
|
|
362
|
-
* @param {object} session
|
|
363
|
-
* @returns {boolean}
|
|
364
|
-
*/
|
|
365
|
-
publishesVariants(session) {
|
|
366
|
-
const owner = this.pictureOf(session);
|
|
367
|
-
// A copy can only be cut where the source already has a keyframe, so a rung
|
|
368
|
-
// meant to splice into it has to be cut at exactly those times. A copy that
|
|
369
|
-
// fell back to an even grid ffmpeg does not cut on has nothing to align to.
|
|
370
|
-
if (!owner.spec.transcodesVideo && owner.timeline.cutGrid !== "keyframe") {
|
|
371
|
-
return false;
|
|
372
|
-
}
|
|
373
|
-
return this.splicableHeights(owner).length >= 2;
|
|
374
|
-
}
|
|
375
|
-
/**
|
|
376
|
-
* Everything the master playlist needs of a session except its soundtracks.
|
|
377
|
-
*
|
|
378
|
-
* The shape of the film — which heights can be spliced, how big the source
|
|
379
|
-
* is, how the pieces are packaged — and the rates it carries. All of it is a
|
|
380
|
-
* fact about the OUTPUT, and it was assembled at the call site in the session
|
|
381
|
-
* manager, which is the file that is being taken apart.
|
|
382
|
-
*
|
|
383
|
-
* The soundtracks are not here on purpose: which of them is marked default is
|
|
384
|
-
* the asking VIEWER'S business, and that belongs to whoever holds viewers.
|
|
385
|
-
*
|
|
386
|
-
* @param {object} session
|
|
387
|
-
* @returns {object}
|
|
388
|
-
*/
|
|
389
|
-
masterFactsOf(session) {
|
|
390
|
-
return {
|
|
391
|
-
playlistVersion: session.segmentFormat.playlistVersion,
|
|
392
|
-
heights: this.splicableHeights(session),
|
|
393
|
-
sourceWidth: Number(session.file?.width) || 0,
|
|
394
|
-
sourceHeight: Number(session.file?.height) || 0,
|
|
395
|
-
...masterRateArgs({
|
|
396
|
-
// FROM WHOEVER KNOWS IT. A source file does not carry its own byte
|
|
397
|
-
// length — the torrent reports it — and reading a field that does not
|
|
398
|
-
// exist is what declared every variant at the floor in 2.80.14.
|
|
399
|
-
fileLength: Number(this.fileLengthOf(session)) || 0,
|
|
400
|
-
durationSeconds: Number(session.file?.durationSeconds) || 0,
|
|
401
|
-
// The probe's own reading of the video stream, which is known from the
|
|
402
|
-
// moment the session exists and covers the gap before the torrent has
|
|
403
|
-
// reported a length.
|
|
404
|
-
streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
|
|
405
|
-
largest: this.largestPieceOf(session.outputKey ?? ""),
|
|
406
|
-
boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
|
|
407
|
-
producedHeight: this.producedHeightOf(session),
|
|
408
|
-
// The peak the encoder is held to, which is what BANDWIDTH declares.
|
|
409
|
-
// Zero where nothing bounds it — a copy, or a hardware encoder.
|
|
410
|
-
capKbps: Number(session.spec?.video?.encode?.rateControl?.maxrateKbps) || 0
|
|
411
|
-
})
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file The one catalog of the outputs that exist, and every question about
|
|
3
|
+
* them.
|
|
4
|
+
*
|
|
5
|
+
* It replaces two classes that held two views of one collection: a registry of
|
|
6
|
+
* the live outputs and their entry times, and a set of queries walking that
|
|
7
|
+
* same registry — which outputs belong to one file, which is the picture and
|
|
8
|
+
* which are its steps and soundtracks, what a step's height is. An output is
|
|
9
|
+
* found by what it IS, never by a list of ids somebody keeps.
|
|
10
|
+
*
|
|
11
|
+
* Presence is the only statement that an output exists. Creation and access
|
|
12
|
+
* times describe the catalog entry, not the media, the viewer or the encoder,
|
|
13
|
+
* so they are kept here and never copied onto the output. What has been
|
|
14
|
+
* produced is not here either: that is the segment store's, and which runs are
|
|
15
|
+
* going is the encoding orchestrator's.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { variantHeightsFor } from "./ladder.js";
|
|
19
|
+
|
|
20
|
+
import { masterRateArgs } from "./rates.js";
|
|
21
|
+
|
|
22
|
+
export class OutputCatalog {
|
|
23
|
+
#steps = new WeakSet();
|
|
24
|
+
/** The heights each picture can be spliced to, derived once from its file. @type {WeakMap<object, number[]>} */
|
|
25
|
+
#splicable = new WeakMap();
|
|
26
|
+
|
|
27
|
+
#byId = new Map();
|
|
28
|
+
#lifetime = new Map();
|
|
29
|
+
#now;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} [params]
|
|
33
|
+
* @param {() => number} [params.now]
|
|
34
|
+
* @param {(output: object) => number} [params.fileLengthOf] - How many bytes a
|
|
35
|
+
* source file is, which the torrent reports.
|
|
36
|
+
* @param {(address: string) => { index: number, size: number }} [params.largestPieceOf]
|
|
37
|
+
* The biggest piece an output has made, which the disk knows.
|
|
38
|
+
*/
|
|
39
|
+
constructor({
|
|
40
|
+
now = Date.now,
|
|
41
|
+
fileLengthOf = () => 0,
|
|
42
|
+
largestPieceOf = () => ({ index: -1, size: 0 })
|
|
43
|
+
} = {}) {
|
|
44
|
+
this.#now = now;
|
|
45
|
+
// Two facts this catalog needs and does not own, taken as plain functions
|
|
46
|
+
// so nothing of their layers is held here: the torrent's byte length and
|
|
47
|
+
// the store's largest piece. The bitrate limit is not among them any more:
|
|
48
|
+
// it is part of the output itself (`OutputSpec`).
|
|
49
|
+
this.fileLengthOf = fileLengthOf;
|
|
50
|
+
this.largestPieceOf = largestPieceOf;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
set(id, output) {
|
|
54
|
+
const known = this.#byId.get(id);
|
|
55
|
+
this.#byId.set(id, output);
|
|
56
|
+
if (known !== output) {
|
|
57
|
+
const at = this.#now();
|
|
58
|
+
this.#lifetime.set(id, { startedAt: at, lastAccessedAt: at });
|
|
59
|
+
}
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get(id) {
|
|
64
|
+
return this.#byId.get(id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
has(id) {
|
|
68
|
+
return this.#byId.has(id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
delete(id) {
|
|
72
|
+
this.#lifetime.delete(id);
|
|
73
|
+
return this.#byId.delete(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
clear() {
|
|
77
|
+
this.#lifetime.clear();
|
|
78
|
+
this.#byId.clear();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
touch(outputOrId, at = this.#now()) {
|
|
82
|
+
const id = typeof outputOrId === "string" ? outputOrId : outputOrId?.id;
|
|
83
|
+
const lifetime = this.#lifetime.get(id);
|
|
84
|
+
if (!lifetime || !Number.isFinite(at)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
lifetime.lastAccessedAt = at;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
startedAt(outputOrId) {
|
|
92
|
+
const id = typeof outputOrId === "string" ? outputOrId : outputOrId?.id;
|
|
93
|
+
return this.#lifetime.get(id)?.startedAt ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lastAccessedAt(outputOrId) {
|
|
97
|
+
const id = typeof outputOrId === "string" ? outputOrId : outputOrId?.id;
|
|
98
|
+
return this.#lifetime.get(id)?.lastAccessedAt ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
expiredBefore(cutoff) {
|
|
102
|
+
const ids = [];
|
|
103
|
+
for (const [id, lifetime] of this.#lifetime) {
|
|
104
|
+
if (lifetime.lastAccessedAt < cutoff) {
|
|
105
|
+
ids.push(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return ids;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
keys() {
|
|
112
|
+
return this.#byId.keys();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
values() {
|
|
116
|
+
return this.#byId.values();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
entries() {
|
|
120
|
+
return this.#byId.entries();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get size() {
|
|
124
|
+
return this.#byId.size;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
[Symbol.iterator]() {
|
|
128
|
+
return this.#byId[Symbol.iterator]();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Every live record producing ONE output.
|
|
133
|
+
*
|
|
134
|
+
* The output is the address the encoding layer works in: two sessions whose
|
|
135
|
+
* output parameters agree ARE the same output, so what one of them measured
|
|
136
|
+
* about the machine is true of the other.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} address
|
|
139
|
+
* @returns {object[]}
|
|
140
|
+
*/
|
|
141
|
+
outputsOn(address) {
|
|
142
|
+
const found = [];
|
|
143
|
+
for (const session of this.values()) {
|
|
144
|
+
if (session.outputKey === address) {
|
|
145
|
+
found.push(session);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every live session of one file: the picture, its quality steps, and the
|
|
153
|
+
* soundtracks published separately.
|
|
154
|
+
*
|
|
155
|
+
* The file is what they share, so the file is what this asks about. Where the
|
|
156
|
+
* sums this feeds are concerned that is also the right question: two pictures
|
|
157
|
+
* of one file are two encoders on one machine whether or not anybody thinks
|
|
158
|
+
* of them as one film.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} session
|
|
161
|
+
* @returns {object[]}
|
|
162
|
+
*/
|
|
163
|
+
familyOf(session) {
|
|
164
|
+
const family = [session];
|
|
165
|
+
const key = session?.file?.key;
|
|
166
|
+
for (const other of this.values()) {
|
|
167
|
+
if (other === session || other.file?.key !== key) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
family.push(other);
|
|
171
|
+
}
|
|
172
|
+
return family;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The soundtracks published separately for this picture, live ones only.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} base
|
|
179
|
+
* @returns {object[]}
|
|
180
|
+
*/
|
|
181
|
+
renditionsOf(base) {
|
|
182
|
+
return this.familyOf(base).filter((session) => session !== base && session.spec.carries === "audio-only");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The quality steps of this picture, live ones only.
|
|
187
|
+
*
|
|
188
|
+
* A step is a session made as one — `isStep`, written where it is created —
|
|
189
|
+
* and not merely "another session of this file that carries a picture". The
|
|
190
|
+
* difference is the second picture: one file can hold two — a browser that
|
|
191
|
+
* understands rendition groups and one that needs the sound muxed in produce
|
|
192
|
+
* two — and calling one of them a step of the other would let a switch away
|
|
193
|
+
* from a step stop the encoder of somebody else's picture.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} base
|
|
196
|
+
* @returns {object[]}
|
|
197
|
+
*/
|
|
198
|
+
stepsOf(base) {
|
|
199
|
+
return this.familyOf(base).filter((session) => session !== base && this.#steps.has(session));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
markStep(output) {
|
|
203
|
+
this.#steps.add(output);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The picture a step belongs to, or the session itself when it is not a step.
|
|
208
|
+
*
|
|
209
|
+
* How a session came to be is a fact about it, not a link to another session
|
|
210
|
+
* — so a step whose picture has gone answers for itself rather than following
|
|
211
|
+
* a dead reference, and nothing has to be cleaned from the other side when
|
|
212
|
+
* one of them ends.
|
|
213
|
+
*
|
|
214
|
+
* One file can carry two pictures at once, and then this returns whichever
|
|
215
|
+
* was made first. Everything asked of the answer is a fact of the FILE and of
|
|
216
|
+
* this host: what heights can be offered, what a step costs, when the budget
|
|
217
|
+
* last acted. Two pictures of one file answer all of those alike.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} session
|
|
220
|
+
* @returns {object}
|
|
221
|
+
*/
|
|
222
|
+
pictureOf(session) {
|
|
223
|
+
if (!session || !this.#steps.has(session)) {
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
for (const other of this.familyOf(session)) {
|
|
227
|
+
if (!this.#steps.has(other) && other.spec.carries !== "audio-only") {
|
|
228
|
+
return other;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return session;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether this output has been SUPERSEDED by the step named here.
|
|
236
|
+
*
|
|
237
|
+
* A question about the shape of a film and nothing else: one picture, its
|
|
238
|
+
* steps, its soundtracks. A step supersedes only the picture it is a step of
|
|
239
|
+
* — never another step, never a soundtrack, which are produced or not
|
|
240
|
+
* according to whether anybody asked for them.
|
|
241
|
+
*
|
|
242
|
+
* The step arrives as a plain id. Which step is on somebody's screen is a fact
|
|
243
|
+
* about a PERSON, and this layer neither holds a person nor knows what one
|
|
244
|
+
* looks like; whoever asks holds both and states the id.
|
|
245
|
+
*
|
|
246
|
+
* It is asked while the priority map of one output is built, and it is what
|
|
247
|
+
* makes the difference between a picture somebody is watching and a picture
|
|
248
|
+
* they stepped off. Without it every output of a film was handed the film's
|
|
249
|
+
* whole map, the plan wanted an encoder on each, the session manager killed
|
|
250
|
+
* the ones it judged unwatched, and the viewer's own move started them again
|
|
251
|
+
* — the two authorities of 2026-09-08.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} session
|
|
254
|
+
* @param {string | null} [stepOnScreen] - The step being watched, or null for
|
|
255
|
+
* "the height the film was opened at", which supersedes nothing.
|
|
256
|
+
* @returns {boolean}
|
|
257
|
+
*/
|
|
258
|
+
supersededBy(session, stepOnScreen = null) {
|
|
259
|
+
if (!session || this.#steps.has(session) || session.spec.carries === "audio-only") {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
// The picture. A step of it on screen says outright that this is not what
|
|
263
|
+
// is being looked at; a step merely being made READY supersedes nothing,
|
|
264
|
+
// because through a warm-up both are genuinely being produced.
|
|
265
|
+
return typeof stepOnScreen === "string" && stepOnScreen !== session.id;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Which variant a session IS, as a height. Zero encode height means "keep the
|
|
270
|
+
* source", so the source's own height is the answer.
|
|
271
|
+
*
|
|
272
|
+
* Settled once and then kept, because it is a NAME — the player addresses the
|
|
273
|
+
* variant by it for the whole session, having fetched the master exactly
|
|
274
|
+
* once. The height a session encodes at is not stable: the realtime budget
|
|
275
|
+
* steps it down when the host cannot keep up. Deriving the name afresh each
|
|
276
|
+
* time would mean a downshift silently renames the variant the viewer is
|
|
277
|
+
* watching, and the next segment request under the old name would build a
|
|
278
|
+
* SECOND session at the height the host had just proved it could not manage.
|
|
279
|
+
* A downshift changes the picture inside the variant instead, which is what
|
|
280
|
+
* it has always done.
|
|
281
|
+
*
|
|
282
|
+
* @param {object} session
|
|
283
|
+
* @returns {number}
|
|
284
|
+
*/
|
|
285
|
+
variantHeightOf(session) {
|
|
286
|
+
if (Number.isInteger(session.variantHeight) && session.variantHeight > 0) {
|
|
287
|
+
return session.variantHeight;
|
|
288
|
+
}
|
|
289
|
+
const encodeHeight = Number(session.output.encodeHeight) || 0;
|
|
290
|
+
session.variantHeight = encodeHeight > 0
|
|
291
|
+
? encodeHeight
|
|
292
|
+
: Math.round(Number(session.file.height) || 0);
|
|
293
|
+
return session.variantHeight;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The height a session's encoder is actually producing, or 0 when it produces
|
|
298
|
+
* no encoded picture of its own (a copy, or a soundtrack).
|
|
299
|
+
*
|
|
300
|
+
* A COPY must never be adopted: it costs no encoder at all, so handing it to
|
|
301
|
+
* a request for a re-encoded rung would give away the one thing this host can
|
|
302
|
+
* always serve.
|
|
303
|
+
*
|
|
304
|
+
* @param {object} session
|
|
305
|
+
* @returns {number}
|
|
306
|
+
*/
|
|
307
|
+
producedHeightOf(session) {
|
|
308
|
+
if (!session || !session.spec.transcodesVideo || session.spec.carries === "audio-only") {
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
return Math.round(Number(session.output.encodeHeight) || 0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The heights this file's variants CAN be spliced at — a fact about the
|
|
316
|
+
* source and the cut grid, settled once and never moved.
|
|
317
|
+
*
|
|
318
|
+
* Separate from which of them are worth OFFERING to the viewer right now, on
|
|
319
|
+
* a machine whose load moves every five seconds. Both were the same list
|
|
320
|
+
* until 2026-08-18, and that is what broke playback outright: the browser is
|
|
321
|
+
* told at session creation that a master playlist exists, and 192 ms later —
|
|
322
|
+
* after the session's own encoder had started and the first supply reading
|
|
323
|
+
* had arrived — the live list had fallen from five rungs to one,
|
|
324
|
+
* `buildMasterPlaylist` returned null for having fewer than two, and the
|
|
325
|
+
* master answered 404 to the very session that had just published it. hls.js
|
|
326
|
+
* treats that as fatal and unrecoverable, so nothing played at all (session
|
|
327
|
+
* `4ef731d8`, "Moana (2016).mkv", 17:43:01).
|
|
328
|
+
*
|
|
329
|
+
* A live figure may decide what to offer. It may not decide whether a
|
|
330
|
+
* published document exists.
|
|
331
|
+
*
|
|
332
|
+
* @param {object} session
|
|
333
|
+
* @returns {number[]} Largest first.
|
|
334
|
+
*/
|
|
335
|
+
splicableHeights(session) {
|
|
336
|
+
const owner = this.pictureOf(session);
|
|
337
|
+
const known = this.#splicable.get(owner);
|
|
338
|
+
if (known) {
|
|
339
|
+
return known;
|
|
340
|
+
}
|
|
341
|
+
const heights = new Set(variantHeightsFor(Number(owner.file.height) || 0));
|
|
342
|
+
const own = this.variantHeightOf(owner);
|
|
343
|
+
if (own > 0) {
|
|
344
|
+
heights.add(own);
|
|
345
|
+
}
|
|
346
|
+
const sorted = [...heights].sort((left, right) => right - left);
|
|
347
|
+
this.#splicable.set(owner, sorted);
|
|
348
|
+
return sorted;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Whether this stream publishes a master playlist at all — that is, whether
|
|
353
|
+
* there is anything for a player to move BETWEEN.
|
|
354
|
+
*
|
|
355
|
+
* Asked in one place because two callers depend on the same answer and used
|
|
356
|
+
* to compute it differently: the builder refused a copied stream whose cut
|
|
357
|
+
* grid is a fiction, while the budget looked only at how many heights could
|
|
358
|
+
* in principle be spliced. A copy with no readable keyframe index therefore
|
|
359
|
+
* had requests recorded against it — asking a player with no variants to
|
|
360
|
+
* change variant, once every window, for the whole film.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} session
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
publishesVariants(session) {
|
|
366
|
+
const owner = this.pictureOf(session);
|
|
367
|
+
// A copy can only be cut where the source already has a keyframe, so a rung
|
|
368
|
+
// meant to splice into it has to be cut at exactly those times. A copy that
|
|
369
|
+
// fell back to an even grid ffmpeg does not cut on has nothing to align to.
|
|
370
|
+
if (!owner.spec.transcodesVideo && owner.timeline.cutGrid !== "keyframe") {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
return this.splicableHeights(owner).length >= 2;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Everything the master playlist needs of a session except its soundtracks.
|
|
377
|
+
*
|
|
378
|
+
* The shape of the film — which heights can be spliced, how big the source
|
|
379
|
+
* is, how the pieces are packaged — and the rates it carries. All of it is a
|
|
380
|
+
* fact about the OUTPUT, and it was assembled at the call site in the session
|
|
381
|
+
* manager, which is the file that is being taken apart.
|
|
382
|
+
*
|
|
383
|
+
* The soundtracks are not here on purpose: which of them is marked default is
|
|
384
|
+
* the asking VIEWER'S business, and that belongs to whoever holds viewers.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} session
|
|
387
|
+
* @returns {object}
|
|
388
|
+
*/
|
|
389
|
+
masterFactsOf(session) {
|
|
390
|
+
return {
|
|
391
|
+
playlistVersion: session.segmentFormat.playlistVersion,
|
|
392
|
+
heights: this.splicableHeights(session),
|
|
393
|
+
sourceWidth: Number(session.file?.width) || 0,
|
|
394
|
+
sourceHeight: Number(session.file?.height) || 0,
|
|
395
|
+
...masterRateArgs({
|
|
396
|
+
// FROM WHOEVER KNOWS IT. A source file does not carry its own byte
|
|
397
|
+
// length — the torrent reports it — and reading a field that does not
|
|
398
|
+
// exist is what declared every variant at the floor in 2.80.14.
|
|
399
|
+
fileLength: Number(this.fileLengthOf(session)) || 0,
|
|
400
|
+
durationSeconds: Number(session.file?.durationSeconds) || 0,
|
|
401
|
+
// The probe's own reading of the video stream, which is known from the
|
|
402
|
+
// moment the session exists and covers the gap before the torrent has
|
|
403
|
+
// reported a length.
|
|
404
|
+
streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
|
|
405
|
+
largest: this.largestPieceOf(session.outputKey ?? ""),
|
|
406
|
+
boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
|
|
407
|
+
producedHeight: this.producedHeightOf(session),
|
|
408
|
+
// The peak the encoder is held to, which is what BANDWIDTH declares.
|
|
409
|
+
// Zero where nothing bounds it — a copy, or a hardware encoder.
|
|
410
|
+
capKbps: Number(session.spec?.video?.encode?.rateControl?.maxrateKbps) || 0
|
|
411
|
+
})
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|