@torrent-tv/proxy 2.87.10 → 2.87.11
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 +2186 -2181
- 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 +124 -101
- 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 +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- 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 +136 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- 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 +361 -361
- 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/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- 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 +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2184 -2162
- 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 +1110 -1083
- 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 +266 -266
- 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 +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- 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/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 +322 -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 +1146 -1146
- package/services/server/ViewerRequests.js +703 -522
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/playback-readiness.js +620 -0
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +989 -983
- 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 +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -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/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/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- 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/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- 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 +675 -657
- 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-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/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 +161 -161
- 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/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-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 +381 -381
- 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 +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- 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 +323 -0
- 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 +103 -103
- 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 +182 -182
- 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 +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,418 +1,418 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How an output ends.
|
|
3
|
-
*
|
|
4
|
-
* Disposed when nobody is left on it and it has stood idle past its time, or all
|
|
5
|
-
* at once when the proxy stops; its encoders stopped, its viewers told, its
|
|
6
|
-
* statement of accuracy said. At startup the segments an earlier process left
|
|
7
|
-
* behind are adopted where their address still names what they hold.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { logger } from "../../utils/logger.js";
|
|
11
|
-
import { isOutputName } from "../encode/output/index.js";
|
|
12
|
-
import { IDLE_KEEP_MS } from "../storage/keep.js";
|
|
13
|
-
/**
|
|
14
|
-
* How long produced segments are kept after the last request for them.
|
|
15
|
-
*
|
|
16
|
-
* Long on purpose, and deliberately not the session TTL: an output outlives
|
|
17
|
-
* every session on it, and the reason to keep it is that somebody may ask
|
|
18
|
-
* again — the viewer who closed the tab, or one who has not arrived yet and
|
|
19
|
-
* will find the film already encoded. Reclaiming space is the allowance below,
|
|
20
|
-
* not this; this only stops something nobody has touched all day from sitting
|
|
21
|
-
* there for the life of the process.
|
|
22
|
-
*/
|
|
23
|
-
const SEGMENT_STORE_IDLE_MS = IDLE_KEEP_MS;
|
|
24
|
-
/**
|
|
25
|
-
* Wait for a child process to exit, with a hard timeout fallback.
|
|
26
|
-
*
|
|
27
|
-
* @param {import("node:child_process").ChildProcess} child
|
|
28
|
-
* @param {number} [timeoutMs=2000]
|
|
29
|
-
* @returns {Promise<void>}
|
|
30
|
-
*/
|
|
31
|
-
function waitForChildExit(child, timeoutMs = 2_000) {
|
|
32
|
-
return new Promise((resolve) => {
|
|
33
|
-
let settled = false;
|
|
34
|
-
const finish = () => {
|
|
35
|
-
if (settled) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
settled = true;
|
|
39
|
-
resolve();
|
|
40
|
-
};
|
|
41
|
-
child.once("exit", finish);
|
|
42
|
-
setTimeout(finish, timeoutMs);
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Whether a child process has genuinely exited. `ChildProcess.killed` only
|
|
47
|
-
* means `.kill()` was called — the process can stay alive well after that
|
|
48
|
-
* (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
|
|
49
|
-
* only set once the `exit` event has actually fired, so this is the reliable
|
|
50
|
-
* check before treating a directory/file as free for a new process to use.
|
|
51
|
-
*
|
|
52
|
-
* @param {import("node:child_process").ChildProcess} child
|
|
53
|
-
* @returns {boolean}
|
|
54
|
-
*/
|
|
55
|
-
function hasChildExited(child) {
|
|
56
|
-
return child.exitCode !== null || child.signalCode !== null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export class OutputLifecycle {
|
|
60
|
-
/** Whether a room check is already queued for this turn. */
|
|
61
|
-
#roomCheckQueued = false;
|
|
62
|
-
|
|
63
|
-
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
64
|
-
#host;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* @param {object} host - `invalidateWaits`, `segmentFormatOfKey`, `viewerSegmentsOn`, `budgetTimer`, `cleanupTimer`, `encodeRuns`, `keyframeTables`, `machineBudget`, `outputTimes`, `outputs`, `returns`, `segmentStore`, `sessionTtlMs`, `sourceFiles`, `timelines`, `viewers`
|
|
68
|
-
*/
|
|
69
|
-
constructor(host) {
|
|
70
|
-
this.#host = host;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Kill the ffmpeg process, remove it from all maps, and delete the temp dir.
|
|
75
|
-
*
|
|
76
|
-
* @param {string} sessionId
|
|
77
|
-
* @returns {Promise<void>}
|
|
78
|
-
*/
|
|
79
|
-
async disposeSession(sessionId) {
|
|
80
|
-
const session = this.#host.outputs.get(sessionId);
|
|
81
|
-
if (!session) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
this.#host.invalidateWaits(session);
|
|
85
|
-
this.#host.outputs.delete(sessionId);
|
|
86
|
-
this.#host.outputTimes.logIndexAccuracy(session);
|
|
87
|
-
|
|
88
|
-
// The chain that used to close here is gone. A picture session releasing a
|
|
89
|
-
// consumer it held on every quality step and every soundtrack was a film
|
|
90
|
-
// object in disguise: one part of a film deciding when another part dies,
|
|
91
|
-
// which is exactly what the criterion refuses — the parts are born at
|
|
92
|
-
// different times, die at different times and are addressed separately.
|
|
93
|
-
//
|
|
94
|
-
// What replaces it is the two sets. A step or a soundtrack nobody is
|
|
95
|
-
// watching has no viewers, so the plan stops its encoders on the next pass
|
|
96
|
-
// — that rule is `EncodePlan`'s and needs no list here — and what it made
|
|
97
|
-
// stays servable until the disk budget says otherwise, which is what a
|
|
98
|
-
// viewer coming back a minute later depends on.
|
|
99
|
-
// A step that has gone must stop being answered with. Nothing has to reach
|
|
100
|
-
// back into another session's map to arrange that: what the file records is
|
|
101
|
-
// a HEIGHT, and the lookup finds no live session producing it, so the next
|
|
102
|
-
// request builds one. What does have to be forgotten is what a VIEWER was
|
|
103
|
-
// watching, because their next request would resolve a session that no
|
|
104
|
-
// longer exists.
|
|
105
|
-
for (const [consumerId] of [...this.#host.viewers.forOutput(session)]) {
|
|
106
|
-
this.viewerLeaves(session, consumerId);
|
|
107
|
-
}
|
|
108
|
-
this.#host.viewers.outputGone(session.id);
|
|
109
|
-
|
|
110
|
-
// Whether the process is still RUNNING, not whether anyone has called kill
|
|
111
|
-
// on it: `.killed` means only that a signal was sent, and a run that ended
|
|
112
|
-
// by itself — the file watched through, or a failure — was never killed at
|
|
113
|
-
// all. Asked the old way, every idle session on disposal signalled a dead
|
|
114
|
-
// pid and claimed to be stopping a run that had already ended.
|
|
115
|
-
for (const run of this.#host.encodeRuns.liveRunsOf(session)) {
|
|
116
|
-
const disposingProcess = run.process;
|
|
117
|
-
if (!disposingProcess || hasChildExited(disposingProcess)) {
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
// A run ends with the session, and it records that itself: left where it
|
|
121
|
-
// was, its state would go on claiming a process that can be signalled and
|
|
122
|
-
// an input that is being read, about a session that no longer exists.
|
|
123
|
-
// `stop` also continues it first, since a suspended process does not act
|
|
124
|
-
// on SIGTERM until it is let go.
|
|
125
|
-
run.stop("the session was disposed");
|
|
126
|
-
await waitForChildExit(disposingProcess);
|
|
127
|
-
}
|
|
128
|
-
this.#host.encodeRuns.forgetEncodingOfGone(session);
|
|
129
|
-
// The segments are NOT removed here, and that is the point of the address
|
|
130
|
-
// change. They belong to the output, not to this session: another viewer
|
|
131
|
-
// may be playing them right now, the viewer who just left may come back,
|
|
132
|
-
// and a viewer who never had a session on this proxy may open the same film
|
|
133
|
-
// a minute from now and find the work already done. A session ending says
|
|
134
|
-
// nothing about any of that.
|
|
135
|
-
//
|
|
136
|
-
// What decides instead is when the material was last READ, and how much
|
|
137
|
-
// room there is — `segmentStore.enforce`, run by the same timer that
|
|
138
|
-
// expires sessions.
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Dispose all sessions that have been idle longer than `sessionTtlMs`.
|
|
143
|
-
* Called automatically on the cleanup interval.
|
|
144
|
-
*
|
|
145
|
-
* @returns {Promise<void>}
|
|
146
|
-
*/
|
|
147
|
-
async cleanupExpired() {
|
|
148
|
-
const idsToDispose = this.#host.outputs.expiredBefore(Date.now() - this.#host.sessionTtlMs);
|
|
149
|
-
for (const sessionId of idsToDispose) {
|
|
150
|
-
// ASSIGNMENTS ONLY, NOT PRESENCE. An output nobody has read for the whole
|
|
151
|
-
// idle period goes even when a paused viewer is still registered on it —
|
|
152
|
-
// whether a pause should hold it longer is roadmap item 75's question, and
|
|
153
|
-
// asking `stillNeeded` here would answer it by accident. What must not
|
|
154
|
-
// happen is taking the output away while a response is still being sent
|
|
155
|
-
// from it, or while a request made against it may still be repeated.
|
|
156
|
-
const output = this.#host.outputs.get(sessionId);
|
|
157
|
-
if (output && this.#host.viewers.assignmentsHold(output)) {
|
|
158
|
-
logger.info(`transcode ${sessionId} idle past its time, kept: assignments still hold it`);
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
await this.disposeSession(sessionId);
|
|
162
|
-
}
|
|
163
|
-
// A timeline nobody is reading any more. It is small — two arrays of a few
|
|
164
|
-
// thousand numbers — but nothing removed it, and a proxy that has served a
|
|
165
|
-
// hundred films would have held a hundred of them for the life of the
|
|
166
|
-
// process. An unbounded map that only ever grows is the shape of half the
|
|
167
|
-
// memory faults recorded in this repository.
|
|
168
|
-
const timelinesInUse = new Set();
|
|
169
|
-
for (const session of this.#host.outputs.values()) {
|
|
170
|
-
if (session.timeline) {
|
|
171
|
-
timelinesInUse.add(session.timeline);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
this.#host.timelines.forgetUnused(timelinesInUse);
|
|
175
|
-
const filesInUse = new Set();
|
|
176
|
-
const keyframesInUse = new Set();
|
|
177
|
-
for (const session of this.#host.outputs.values()) {
|
|
178
|
-
if (session.file) {
|
|
179
|
-
filesInUse.add(session.file);
|
|
180
|
-
}
|
|
181
|
-
if (session.spec?.audio) {
|
|
182
|
-
filesInUse.add(this.#host.sourceFiles.get(session.file.sourceKey, session.spec.audioFileIndex));
|
|
183
|
-
}
|
|
184
|
-
if (session.keyframes) {
|
|
185
|
-
keyframesInUse.add(session.keyframes);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
this.#host.sourceFiles.forgetUnused(filesInUse);
|
|
189
|
-
this.#host.keyframeTables.forgetUnused(keyframesInUse);
|
|
190
|
-
// The segments outlive every session on them, so what they cost is decided
|
|
191
|
-
// here rather than by anybody's departure: how long ago each output was
|
|
192
|
-
// last read, and how much room the disk has for the lot.
|
|
193
|
-
// The room is the disk owner's to divide; this asks what the share is now.
|
|
194
|
-
await this.#host.machineBudget.revise();
|
|
195
|
-
// What viewers actually do, beside the period that stands in for it. Said
|
|
196
|
-
// where it can be read against the disk figures rather than on its own.
|
|
197
|
-
const returns = this.#host.returns.describe(IDLE_KEEP_MS);
|
|
198
|
-
if (returns !== null) {
|
|
199
|
-
logger.info(returns);
|
|
200
|
-
}
|
|
201
|
-
this.keepWithinRoom();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Keep the produced segments within the share of the disk they are given,
|
|
206
|
-
* removing what the viewers want least, and drop what nobody has read for
|
|
207
|
-
* the keeping period.
|
|
208
|
-
*
|
|
209
|
-
* Asked when a segment is published — that is the moment the store grows —
|
|
210
|
-
* and on the cleanup pass, which is what notices time passing. Asked only on
|
|
211
|
-
* the timer, the store could run past its share by half a minute of encoding.
|
|
212
|
-
*
|
|
213
|
-
* @returns {void}
|
|
214
|
-
*/
|
|
215
|
-
keepWithinRoom() {
|
|
216
|
-
this.#host.segmentStore.enforce({
|
|
217
|
-
idleMs: SEGMENT_STORE_IDLE_MS,
|
|
218
|
-
maxBytes: this.#host.machineBudget.segmentBytes(),
|
|
219
|
-
viewersAt: (key) => this.#host.viewerSegmentsOn(key)
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* {@link keepWithinRoom} once for a burst of publications: several outputs
|
|
225
|
-
* closing a segment in the same turn ask once.
|
|
226
|
-
*
|
|
227
|
-
* @returns {void}
|
|
228
|
-
*/
|
|
229
|
-
keepWithinRoomSoon() {
|
|
230
|
-
if (this.#roomCheckQueued) {
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
this.#roomCheckQueued = true;
|
|
234
|
-
setImmediate(() => {
|
|
235
|
-
this.#roomCheckQueued = false;
|
|
236
|
-
this.keepWithinRoom();
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Stop the cleanup timer, dispose all active sessions, and attempt to
|
|
242
|
-
* remove the shared temp root directory if it is empty.
|
|
243
|
-
* Called by Fastify's `onClose` hook during graceful shutdown.
|
|
244
|
-
*
|
|
245
|
-
* @returns {Promise<void>}
|
|
246
|
-
*/
|
|
247
|
-
async disposeAll() {
|
|
248
|
-
clearInterval(this.#host.cleanupTimer);
|
|
249
|
-
clearInterval(this.#host.budgetTimer);
|
|
250
|
-
const activeIds = Array.from(this.#host.outputs.keys());
|
|
251
|
-
for (const sessionId of activeIds) {
|
|
252
|
-
await this.disposeSession(sessionId);
|
|
253
|
-
}
|
|
254
|
-
// Everything this process owns, root included. See SegmentStore.dropAll.
|
|
255
|
-
this.#host.segmentStore.dropAll("the proxy is shutting down");
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Take back what a previous life of this process left on the disk.
|
|
260
|
-
*
|
|
261
|
-
* Called once at startup, and it is the only record there is of an encoder
|
|
262
|
-
* that ended without anything recording why: when the kernel kills this
|
|
263
|
-
* process no exit handler runs, nothing is cleared up, and — measured on the
|
|
264
|
-
* addon host 2026-09-04 — the files survive, because `/tmp` there is on the
|
|
265
|
-
* overlay filesystem rather than in memory.
|
|
266
|
-
*
|
|
267
|
-
* What survived is kept rather than thrown away. A copied segment's bytes
|
|
268
|
-
* depend only on the source, so it is as good as it was; re-encoding it would
|
|
269
|
-
* cost the machine that is already the scarce thing.
|
|
270
|
-
*
|
|
271
|
-
* @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
|
|
272
|
-
*/
|
|
273
|
-
adoptSegmentsLeftBehind() {
|
|
274
|
-
// Which container the segments are in is stated by the key itself; a key
|
|
275
|
-
// that cannot say is a directory that goes (`encode/output-key-format.js`).
|
|
276
|
-
return this.#host.segmentStore.adoptWhatSurvived((key) => this.#host.segmentFormatOfKey(key));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Remove a consumer from a session. Disposes the session when the last
|
|
281
|
-
* consumer leaves.
|
|
282
|
-
*
|
|
283
|
-
* @param {string} sessionId
|
|
284
|
-
* @param {string} [consumerId=""]
|
|
285
|
-
* @param {string} [reason=""] - Human-readable reason shown in logs.
|
|
286
|
-
* @returns {Promise<boolean>} `false` if the session was not found.
|
|
287
|
-
*/
|
|
288
|
-
async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
|
|
289
|
-
if (!isOutputName(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
292
|
-
const session = this.#host.outputs.get(sessionId);
|
|
293
|
-
if (!session) {
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
// And everything that was true of them alone, in EVERY output of this film
|
|
297
|
-
// they were watching — not only in the one the browser addresses. A viewer
|
|
298
|
-
// watches a picture, a quality step and a soundtrack; the browser knows one
|
|
299
|
-
// id of the three, so subtracting them here from that one left them counted
|
|
300
|
-
// as watching the other two. This is the half of the relation the viewer
|
|
301
|
-
// holds, and it exists for exactly this question.
|
|
302
|
-
for (const outputId of this.#host.viewers.watching(consumerId)) {
|
|
303
|
-
const output = this.#host.outputs.get(outputId);
|
|
304
|
-
if (output) {
|
|
305
|
-
this.viewerLeaves(output, consumerId);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
this.viewerLeaves(session, consumerId);
|
|
309
|
-
this.#host.outputs.touch(session);
|
|
310
|
-
const remaining = this.#host.viewers.forOutput(session).size;
|
|
311
|
-
const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
|
|
312
|
-
logger.info(
|
|
313
|
-
`consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
|
|
314
|
-
`remaining=${remaining}`
|
|
315
|
-
);
|
|
316
|
-
if (remaining > 0) {
|
|
317
|
-
return true;
|
|
318
|
-
}
|
|
319
|
-
// Read before the picture goes, because a family is found through the file
|
|
320
|
-
// the sessions share and a disposed session is no longer among them.
|
|
321
|
-
const family = this.#host.outputs.familyOf(session).filter((other) => other !== session);
|
|
322
|
-
// Nobody is watching it, but that is only half of whether it is needed: a
|
|
323
|
-
// response already begun is still being sent from it, and another viewer's
|
|
324
|
-
// request made against it moments ago may still be repeated. The same
|
|
325
|
-
// question every other disposal asks, asked here too — this was the one
|
|
326
|
-
// ordinary release that did not. Kept, it goes by the idle expiry once the
|
|
327
|
-
// assignments lapse; its steps and soundtracks are still asked below, each
|
|
328
|
-
// on its own, because this picture being held says nothing about them.
|
|
329
|
-
if (this.#host.viewers.stillNeeded(session)) {
|
|
330
|
-
logger.info(
|
|
331
|
-
`transcode ${session.id} kept after its last viewer left: assignments still hold it ` +
|
|
332
|
-
`(it goes by the idle expiry once they lapse)`
|
|
333
|
-
);
|
|
334
|
-
} else {
|
|
335
|
-
await this.disposeSession(sessionId);
|
|
336
|
-
}
|
|
337
|
-
// The quality steps and the soundtracks this picture had made. Nobody
|
|
338
|
-
// outside this class knows their ids — the browser holds one id for the
|
|
339
|
-
// whole film — so nothing else can ever let go of them, and each holds a
|
|
340
|
-
// consumer, a claim on the torrent, a directory and, until the plan's next
|
|
341
|
-
// pass, a live encoder. Left alone they would sit until the idle timer
|
|
342
|
-
// noticed, half an hour later.
|
|
343
|
-
//
|
|
344
|
-
// The rule is the viewers and not the picture: an output with somebody
|
|
345
|
-
// still watching stays, whoever made it. That is what makes this different
|
|
346
|
-
// from the chain of links it replaced — a picture ending is not what kills
|
|
347
|
-
// a soundtrack; having no listeners is. Whether anybody is left is asked of
|
|
348
|
-
// the viewer registry alone: the family's own made-up claim on what it made
|
|
349
|
-
// was a second answer to that question and is gone. What an output
|
|
350
|
-
// produced stays in the segment store under its key, so disposing it here
|
|
351
|
-
// loses no made segment.
|
|
352
|
-
for (const output of family) {
|
|
353
|
-
if (!this.#host.outputs.has(output.id) || this.#host.viewers.stillNeeded(output)) {
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
logger.info(
|
|
357
|
-
`transcode ${output.id} disposed: nobody is watching it and the picture it belongs to has ended`
|
|
358
|
-
);
|
|
359
|
-
await this.disposeSession(output.id);
|
|
360
|
-
}
|
|
361
|
-
return true;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* This person has gone, and their connection is what said so.
|
|
366
|
-
*
|
|
367
|
-
* Departure is a fact about the PERSON, not about one of the three outputs
|
|
368
|
-
* the browser happens to hold an id for, and their connection knows it before
|
|
369
|
-
* any output does. Until 2026-09-05 nothing carried it: the only exits were
|
|
370
|
-
* the browser's own `release`, which a killed tab never sends, and a silence
|
|
371
|
-
* long enough to be called an absence, which a paused viewer produces without
|
|
372
|
-
* having gone anywhere.
|
|
373
|
-
*
|
|
374
|
-
* Every output they were watching is told, and one with nobody left is
|
|
375
|
-
* disposed by the same path a normal release takes.
|
|
376
|
-
*
|
|
377
|
-
* @param {string} consumerId
|
|
378
|
-
* @param {string} [because]
|
|
379
|
-
* @returns {Promise<number>} How many outputs they were let go of.
|
|
380
|
-
*/
|
|
381
|
-
async viewerHasGone(consumerId, because = "their connection closed") {
|
|
382
|
-
if (typeof consumerId !== "string" || consumerId.length === 0) {
|
|
383
|
-
return 0;
|
|
384
|
-
}
|
|
385
|
-
const watched = this.#host.viewers.get(consumerId)?.outputs;
|
|
386
|
-
if (!watched || watched.size === 0) {
|
|
387
|
-
return 0;
|
|
388
|
-
}
|
|
389
|
-
// Copied before anything is released: releasing walks the same set.
|
|
390
|
-
const outputs = [...watched];
|
|
391
|
-
for (const outputId of outputs) {
|
|
392
|
-
await this.releaseSessionConsumer(outputId, consumerId, because);
|
|
393
|
-
}
|
|
394
|
-
return outputs.length;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* This viewer is no longer watching this output.
|
|
399
|
-
*
|
|
400
|
-
* The relation is removed from `Viewer.outputs`, its only stored form. The
|
|
401
|
-
* next plan derives who still watches the output from that set, so production
|
|
402
|
-
* no longer contains a claim for this viewer.
|
|
403
|
-
*
|
|
404
|
-
* @param {HlsSession} output
|
|
405
|
-
* @param {string} consumerId
|
|
406
|
-
* @returns {boolean} Whether they had been watching it.
|
|
407
|
-
*/
|
|
408
|
-
viewerLeaves(output, consumerId) {
|
|
409
|
-
if (!output) {
|
|
410
|
-
return false;
|
|
411
|
-
}
|
|
412
|
-
// Nothing to release in the encoding: it holds one map per output, built
|
|
413
|
-
// from where the viewers are, and the map that arrives next simply does not
|
|
414
|
-
// have this one in it. A name to release was the last place a viewer
|
|
415
|
-
// appeared inside the encoding at all.
|
|
416
|
-
return this.#host.viewers.leaves(output, consumerId);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* How an output ends.
|
|
3
|
+
*
|
|
4
|
+
* Disposed when nobody is left on it and it has stood idle past its time, or all
|
|
5
|
+
* at once when the proxy stops; its encoders stopped, its viewers told, its
|
|
6
|
+
* statement of accuracy said. At startup the segments an earlier process left
|
|
7
|
+
* behind are adopted where their address still names what they hold.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logger } from "../../utils/logger.js";
|
|
11
|
+
import { isOutputName } from "../encode/output/index.js";
|
|
12
|
+
import { IDLE_KEEP_MS } from "../storage/keep.js";
|
|
13
|
+
/**
|
|
14
|
+
* How long produced segments are kept after the last request for them.
|
|
15
|
+
*
|
|
16
|
+
* Long on purpose, and deliberately not the session TTL: an output outlives
|
|
17
|
+
* every session on it, and the reason to keep it is that somebody may ask
|
|
18
|
+
* again — the viewer who closed the tab, or one who has not arrived yet and
|
|
19
|
+
* will find the film already encoded. Reclaiming space is the allowance below,
|
|
20
|
+
* not this; this only stops something nobody has touched all day from sitting
|
|
21
|
+
* there for the life of the process.
|
|
22
|
+
*/
|
|
23
|
+
const SEGMENT_STORE_IDLE_MS = IDLE_KEEP_MS;
|
|
24
|
+
/**
|
|
25
|
+
* Wait for a child process to exit, with a hard timeout fallback.
|
|
26
|
+
*
|
|
27
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
28
|
+
* @param {number} [timeoutMs=2000]
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
function waitForChildExit(child, timeoutMs = 2_000) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let settled = false;
|
|
34
|
+
const finish = () => {
|
|
35
|
+
if (settled) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
settled = true;
|
|
39
|
+
resolve();
|
|
40
|
+
};
|
|
41
|
+
child.once("exit", finish);
|
|
42
|
+
setTimeout(finish, timeoutMs);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a child process has genuinely exited. `ChildProcess.killed` only
|
|
47
|
+
* means `.kill()` was called — the process can stay alive well after that
|
|
48
|
+
* (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
|
|
49
|
+
* only set once the `exit` event has actually fired, so this is the reliable
|
|
50
|
+
* check before treating a directory/file as free for a new process to use.
|
|
51
|
+
*
|
|
52
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
function hasChildExited(child) {
|
|
56
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class OutputLifecycle {
|
|
60
|
+
/** Whether a room check is already queued for this turn. */
|
|
61
|
+
#roomCheckQueued = false;
|
|
62
|
+
|
|
63
|
+
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
64
|
+
#host;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} host - `invalidateWaits`, `segmentFormatOfKey`, `viewerSegmentsOn`, `budgetTimer`, `cleanupTimer`, `encodeRuns`, `keyframeTables`, `machineBudget`, `outputTimes`, `outputs`, `returns`, `segmentStore`, `sessionTtlMs`, `sourceFiles`, `timelines`, `viewers`
|
|
68
|
+
*/
|
|
69
|
+
constructor(host) {
|
|
70
|
+
this.#host = host;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Kill the ffmpeg process, remove it from all maps, and delete the temp dir.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} sessionId
|
|
77
|
+
* @returns {Promise<void>}
|
|
78
|
+
*/
|
|
79
|
+
async disposeSession(sessionId) {
|
|
80
|
+
const session = this.#host.outputs.get(sessionId);
|
|
81
|
+
if (!session) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.#host.invalidateWaits(session);
|
|
85
|
+
this.#host.outputs.delete(sessionId);
|
|
86
|
+
this.#host.outputTimes.logIndexAccuracy(session);
|
|
87
|
+
|
|
88
|
+
// The chain that used to close here is gone. A picture session releasing a
|
|
89
|
+
// consumer it held on every quality step and every soundtrack was a film
|
|
90
|
+
// object in disguise: one part of a film deciding when another part dies,
|
|
91
|
+
// which is exactly what the criterion refuses — the parts are born at
|
|
92
|
+
// different times, die at different times and are addressed separately.
|
|
93
|
+
//
|
|
94
|
+
// What replaces it is the two sets. A step or a soundtrack nobody is
|
|
95
|
+
// watching has no viewers, so the plan stops its encoders on the next pass
|
|
96
|
+
// — that rule is `EncodePlan`'s and needs no list here — and what it made
|
|
97
|
+
// stays servable until the disk budget says otherwise, which is what a
|
|
98
|
+
// viewer coming back a minute later depends on.
|
|
99
|
+
// A step that has gone must stop being answered with. Nothing has to reach
|
|
100
|
+
// back into another session's map to arrange that: what the file records is
|
|
101
|
+
// a HEIGHT, and the lookup finds no live session producing it, so the next
|
|
102
|
+
// request builds one. What does have to be forgotten is what a VIEWER was
|
|
103
|
+
// watching, because their next request would resolve a session that no
|
|
104
|
+
// longer exists.
|
|
105
|
+
for (const [consumerId] of [...this.#host.viewers.forOutput(session)]) {
|
|
106
|
+
this.viewerLeaves(session, consumerId);
|
|
107
|
+
}
|
|
108
|
+
this.#host.viewers.outputGone(session.id);
|
|
109
|
+
|
|
110
|
+
// Whether the process is still RUNNING, not whether anyone has called kill
|
|
111
|
+
// on it: `.killed` means only that a signal was sent, and a run that ended
|
|
112
|
+
// by itself — the file watched through, or a failure — was never killed at
|
|
113
|
+
// all. Asked the old way, every idle session on disposal signalled a dead
|
|
114
|
+
// pid and claimed to be stopping a run that had already ended.
|
|
115
|
+
for (const run of this.#host.encodeRuns.liveRunsOf(session)) {
|
|
116
|
+
const disposingProcess = run.process;
|
|
117
|
+
if (!disposingProcess || hasChildExited(disposingProcess)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// A run ends with the session, and it records that itself: left where it
|
|
121
|
+
// was, its state would go on claiming a process that can be signalled and
|
|
122
|
+
// an input that is being read, about a session that no longer exists.
|
|
123
|
+
// `stop` also continues it first, since a suspended process does not act
|
|
124
|
+
// on SIGTERM until it is let go.
|
|
125
|
+
run.stop("the session was disposed");
|
|
126
|
+
await waitForChildExit(disposingProcess);
|
|
127
|
+
}
|
|
128
|
+
this.#host.encodeRuns.forgetEncodingOfGone(session);
|
|
129
|
+
// The segments are NOT removed here, and that is the point of the address
|
|
130
|
+
// change. They belong to the output, not to this session: another viewer
|
|
131
|
+
// may be playing them right now, the viewer who just left may come back,
|
|
132
|
+
// and a viewer who never had a session on this proxy may open the same film
|
|
133
|
+
// a minute from now and find the work already done. A session ending says
|
|
134
|
+
// nothing about any of that.
|
|
135
|
+
//
|
|
136
|
+
// What decides instead is when the material was last READ, and how much
|
|
137
|
+
// room there is — `segmentStore.enforce`, run by the same timer that
|
|
138
|
+
// expires sessions.
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Dispose all sessions that have been idle longer than `sessionTtlMs`.
|
|
143
|
+
* Called automatically on the cleanup interval.
|
|
144
|
+
*
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async cleanupExpired() {
|
|
148
|
+
const idsToDispose = this.#host.outputs.expiredBefore(Date.now() - this.#host.sessionTtlMs);
|
|
149
|
+
for (const sessionId of idsToDispose) {
|
|
150
|
+
// ASSIGNMENTS ONLY, NOT PRESENCE. An output nobody has read for the whole
|
|
151
|
+
// idle period goes even when a paused viewer is still registered on it —
|
|
152
|
+
// whether a pause should hold it longer is roadmap item 75's question, and
|
|
153
|
+
// asking `stillNeeded` here would answer it by accident. What must not
|
|
154
|
+
// happen is taking the output away while a response is still being sent
|
|
155
|
+
// from it, or while a request made against it may still be repeated.
|
|
156
|
+
const output = this.#host.outputs.get(sessionId);
|
|
157
|
+
if (output && this.#host.viewers.assignmentsHold(output)) {
|
|
158
|
+
logger.info(`transcode ${sessionId} idle past its time, kept: assignments still hold it`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
await this.disposeSession(sessionId);
|
|
162
|
+
}
|
|
163
|
+
// A timeline nobody is reading any more. It is small — two arrays of a few
|
|
164
|
+
// thousand numbers — but nothing removed it, and a proxy that has served a
|
|
165
|
+
// hundred films would have held a hundred of them for the life of the
|
|
166
|
+
// process. An unbounded map that only ever grows is the shape of half the
|
|
167
|
+
// memory faults recorded in this repository.
|
|
168
|
+
const timelinesInUse = new Set();
|
|
169
|
+
for (const session of this.#host.outputs.values()) {
|
|
170
|
+
if (session.timeline) {
|
|
171
|
+
timelinesInUse.add(session.timeline);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
this.#host.timelines.forgetUnused(timelinesInUse);
|
|
175
|
+
const filesInUse = new Set();
|
|
176
|
+
const keyframesInUse = new Set();
|
|
177
|
+
for (const session of this.#host.outputs.values()) {
|
|
178
|
+
if (session.file) {
|
|
179
|
+
filesInUse.add(session.file);
|
|
180
|
+
}
|
|
181
|
+
if (session.spec?.audio) {
|
|
182
|
+
filesInUse.add(this.#host.sourceFiles.get(session.file.sourceKey, session.spec.audioFileIndex));
|
|
183
|
+
}
|
|
184
|
+
if (session.keyframes) {
|
|
185
|
+
keyframesInUse.add(session.keyframes);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
this.#host.sourceFiles.forgetUnused(filesInUse);
|
|
189
|
+
this.#host.keyframeTables.forgetUnused(keyframesInUse);
|
|
190
|
+
// The segments outlive every session on them, so what they cost is decided
|
|
191
|
+
// here rather than by anybody's departure: how long ago each output was
|
|
192
|
+
// last read, and how much room the disk has for the lot.
|
|
193
|
+
// The room is the disk owner's to divide; this asks what the share is now.
|
|
194
|
+
await this.#host.machineBudget.revise();
|
|
195
|
+
// What viewers actually do, beside the period that stands in for it. Said
|
|
196
|
+
// where it can be read against the disk figures rather than on its own.
|
|
197
|
+
const returns = this.#host.returns.describe(IDLE_KEEP_MS);
|
|
198
|
+
if (returns !== null) {
|
|
199
|
+
logger.info(returns);
|
|
200
|
+
}
|
|
201
|
+
this.keepWithinRoom();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Keep the produced segments within the share of the disk they are given,
|
|
206
|
+
* removing what the viewers want least, and drop what nobody has read for
|
|
207
|
+
* the keeping period.
|
|
208
|
+
*
|
|
209
|
+
* Asked when a segment is published — that is the moment the store grows —
|
|
210
|
+
* and on the cleanup pass, which is what notices time passing. Asked only on
|
|
211
|
+
* the timer, the store could run past its share by half a minute of encoding.
|
|
212
|
+
*
|
|
213
|
+
* @returns {void}
|
|
214
|
+
*/
|
|
215
|
+
keepWithinRoom() {
|
|
216
|
+
this.#host.segmentStore.enforce({
|
|
217
|
+
idleMs: SEGMENT_STORE_IDLE_MS,
|
|
218
|
+
maxBytes: this.#host.machineBudget.segmentBytes(),
|
|
219
|
+
viewersAt: (key) => this.#host.viewerSegmentsOn(key)
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* {@link keepWithinRoom} once for a burst of publications: several outputs
|
|
225
|
+
* closing a segment in the same turn ask once.
|
|
226
|
+
*
|
|
227
|
+
* @returns {void}
|
|
228
|
+
*/
|
|
229
|
+
keepWithinRoomSoon() {
|
|
230
|
+
if (this.#roomCheckQueued) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.#roomCheckQueued = true;
|
|
234
|
+
setImmediate(() => {
|
|
235
|
+
this.#roomCheckQueued = false;
|
|
236
|
+
this.keepWithinRoom();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Stop the cleanup timer, dispose all active sessions, and attempt to
|
|
242
|
+
* remove the shared temp root directory if it is empty.
|
|
243
|
+
* Called by Fastify's `onClose` hook during graceful shutdown.
|
|
244
|
+
*
|
|
245
|
+
* @returns {Promise<void>}
|
|
246
|
+
*/
|
|
247
|
+
async disposeAll() {
|
|
248
|
+
clearInterval(this.#host.cleanupTimer);
|
|
249
|
+
clearInterval(this.#host.budgetTimer);
|
|
250
|
+
const activeIds = Array.from(this.#host.outputs.keys());
|
|
251
|
+
for (const sessionId of activeIds) {
|
|
252
|
+
await this.disposeSession(sessionId);
|
|
253
|
+
}
|
|
254
|
+
// Everything this process owns, root included. See SegmentStore.dropAll.
|
|
255
|
+
this.#host.segmentStore.dropAll("the proxy is shutting down");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Take back what a previous life of this process left on the disk.
|
|
260
|
+
*
|
|
261
|
+
* Called once at startup, and it is the only record there is of an encoder
|
|
262
|
+
* that ended without anything recording why: when the kernel kills this
|
|
263
|
+
* process no exit handler runs, nothing is cleared up, and — measured on the
|
|
264
|
+
* addon host 2026-09-04 — the files survive, because `/tmp` there is on the
|
|
265
|
+
* overlay filesystem rather than in memory.
|
|
266
|
+
*
|
|
267
|
+
* What survived is kept rather than thrown away. A copied segment's bytes
|
|
268
|
+
* depend only on the source, so it is as good as it was; re-encoding it would
|
|
269
|
+
* cost the machine that is already the scarce thing.
|
|
270
|
+
*
|
|
271
|
+
* @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
|
|
272
|
+
*/
|
|
273
|
+
adoptSegmentsLeftBehind() {
|
|
274
|
+
// Which container the segments are in is stated by the key itself; a key
|
|
275
|
+
// that cannot say is a directory that goes (`encode/output-key-format.js`).
|
|
276
|
+
return this.#host.segmentStore.adoptWhatSurvived((key) => this.#host.segmentFormatOfKey(key));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Remove a consumer from a session. Disposes the session when the last
|
|
281
|
+
* consumer leaves.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} sessionId
|
|
284
|
+
* @param {string} [consumerId=""]
|
|
285
|
+
* @param {string} [reason=""] - Human-readable reason shown in logs.
|
|
286
|
+
* @returns {Promise<boolean>} `false` if the session was not found.
|
|
287
|
+
*/
|
|
288
|
+
async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
|
|
289
|
+
if (!isOutputName(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
const session = this.#host.outputs.get(sessionId);
|
|
293
|
+
if (!session) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
// And everything that was true of them alone, in EVERY output of this film
|
|
297
|
+
// they were watching — not only in the one the browser addresses. A viewer
|
|
298
|
+
// watches a picture, a quality step and a soundtrack; the browser knows one
|
|
299
|
+
// id of the three, so subtracting them here from that one left them counted
|
|
300
|
+
// as watching the other two. This is the half of the relation the viewer
|
|
301
|
+
// holds, and it exists for exactly this question.
|
|
302
|
+
for (const outputId of this.#host.viewers.watching(consumerId)) {
|
|
303
|
+
const output = this.#host.outputs.get(outputId);
|
|
304
|
+
if (output) {
|
|
305
|
+
this.viewerLeaves(output, consumerId);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this.viewerLeaves(session, consumerId);
|
|
309
|
+
this.#host.outputs.touch(session);
|
|
310
|
+
const remaining = this.#host.viewers.forOutput(session).size;
|
|
311
|
+
const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
|
|
312
|
+
logger.info(
|
|
313
|
+
`consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
|
|
314
|
+
`remaining=${remaining}`
|
|
315
|
+
);
|
|
316
|
+
if (remaining > 0) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
// Read before the picture goes, because a family is found through the file
|
|
320
|
+
// the sessions share and a disposed session is no longer among them.
|
|
321
|
+
const family = this.#host.outputs.familyOf(session).filter((other) => other !== session);
|
|
322
|
+
// Nobody is watching it, but that is only half of whether it is needed: a
|
|
323
|
+
// response already begun is still being sent from it, and another viewer's
|
|
324
|
+
// request made against it moments ago may still be repeated. The same
|
|
325
|
+
// question every other disposal asks, asked here too — this was the one
|
|
326
|
+
// ordinary release that did not. Kept, it goes by the idle expiry once the
|
|
327
|
+
// assignments lapse; its steps and soundtracks are still asked below, each
|
|
328
|
+
// on its own, because this picture being held says nothing about them.
|
|
329
|
+
if (this.#host.viewers.stillNeeded(session)) {
|
|
330
|
+
logger.info(
|
|
331
|
+
`transcode ${session.id} kept after its last viewer left: assignments still hold it ` +
|
|
332
|
+
`(it goes by the idle expiry once they lapse)`
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
await this.disposeSession(sessionId);
|
|
336
|
+
}
|
|
337
|
+
// The quality steps and the soundtracks this picture had made. Nobody
|
|
338
|
+
// outside this class knows their ids — the browser holds one id for the
|
|
339
|
+
// whole film — so nothing else can ever let go of them, and each holds a
|
|
340
|
+
// consumer, a claim on the torrent, a directory and, until the plan's next
|
|
341
|
+
// pass, a live encoder. Left alone they would sit until the idle timer
|
|
342
|
+
// noticed, half an hour later.
|
|
343
|
+
//
|
|
344
|
+
// The rule is the viewers and not the picture: an output with somebody
|
|
345
|
+
// still watching stays, whoever made it. That is what makes this different
|
|
346
|
+
// from the chain of links it replaced — a picture ending is not what kills
|
|
347
|
+
// a soundtrack; having no listeners is. Whether anybody is left is asked of
|
|
348
|
+
// the viewer registry alone: the family's own made-up claim on what it made
|
|
349
|
+
// was a second answer to that question and is gone. What an output
|
|
350
|
+
// produced stays in the segment store under its key, so disposing it here
|
|
351
|
+
// loses no made segment.
|
|
352
|
+
for (const output of family) {
|
|
353
|
+
if (!this.#host.outputs.has(output.id) || this.#host.viewers.stillNeeded(output)) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
logger.info(
|
|
357
|
+
`transcode ${output.id} disposed: nobody is watching it and the picture it belongs to has ended`
|
|
358
|
+
);
|
|
359
|
+
await this.disposeSession(output.id);
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* This person has gone, and their connection is what said so.
|
|
366
|
+
*
|
|
367
|
+
* Departure is a fact about the PERSON, not about one of the three outputs
|
|
368
|
+
* the browser happens to hold an id for, and their connection knows it before
|
|
369
|
+
* any output does. Until 2026-09-05 nothing carried it: the only exits were
|
|
370
|
+
* the browser's own `release`, which a killed tab never sends, and a silence
|
|
371
|
+
* long enough to be called an absence, which a paused viewer produces without
|
|
372
|
+
* having gone anywhere.
|
|
373
|
+
*
|
|
374
|
+
* Every output they were watching is told, and one with nobody left is
|
|
375
|
+
* disposed by the same path a normal release takes.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} consumerId
|
|
378
|
+
* @param {string} [because]
|
|
379
|
+
* @returns {Promise<number>} How many outputs they were let go of.
|
|
380
|
+
*/
|
|
381
|
+
async viewerHasGone(consumerId, because = "their connection closed") {
|
|
382
|
+
if (typeof consumerId !== "string" || consumerId.length === 0) {
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
const watched = this.#host.viewers.get(consumerId)?.outputs;
|
|
386
|
+
if (!watched || watched.size === 0) {
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
// Copied before anything is released: releasing walks the same set.
|
|
390
|
+
const outputs = [...watched];
|
|
391
|
+
for (const outputId of outputs) {
|
|
392
|
+
await this.releaseSessionConsumer(outputId, consumerId, because);
|
|
393
|
+
}
|
|
394
|
+
return outputs.length;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* This viewer is no longer watching this output.
|
|
399
|
+
*
|
|
400
|
+
* The relation is removed from `Viewer.outputs`, its only stored form. The
|
|
401
|
+
* next plan derives who still watches the output from that set, so production
|
|
402
|
+
* no longer contains a claim for this viewer.
|
|
403
|
+
*
|
|
404
|
+
* @param {HlsSession} output
|
|
405
|
+
* @param {string} consumerId
|
|
406
|
+
* @returns {boolean} Whether they had been watching it.
|
|
407
|
+
*/
|
|
408
|
+
viewerLeaves(output, consumerId) {
|
|
409
|
+
if (!output) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
// Nothing to release in the encoding: it holds one map per output, built
|
|
413
|
+
// from where the viewers are, and the map that arrives next simply does not
|
|
414
|
+
// have this one in it. A name to release was the last place a viewer
|
|
415
|
+
// appeared inside the encoding at all.
|
|
416
|
+
return this.#host.viewers.leaves(output, consumerId);
|
|
417
|
+
}
|
|
418
|
+
}
|