@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,1150 +1,1150 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Answering one request for a file of an output: a playlist, the init segment
|
|
3
|
-
* or a segment.
|
|
4
|
-
*
|
|
5
|
-
* A segment is served from the store when it is made and whole; otherwise the
|
|
6
|
-
* request is held until it is, or until nobody wants it any more, and a hold
|
|
7
|
-
* that lasts says why. A request is an operation on material that exists; where
|
|
8
|
-
* encoders work is the plan's, and a request does not move one.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { createReadStream } from "node:fs";
|
|
12
|
-
import { access, readFile, stat, unlink } from "node:fs/promises";
|
|
13
|
-
import { Readable } from "node:stream";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { logger } from "../../utils/logger.js";
|
|
16
|
-
import { isOutputName, PLAYLIST_FILE_NAME } from "../encode/output/index.js";
|
|
17
|
-
// The index of variants. Served from the same route as the media playlist, so
|
|
18
|
-
// it needs no path of its own.
|
|
19
|
-
export const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
|
|
20
|
-
// Read segment files in large blocks so the body is delivered to the data
|
|
21
|
-
// channel in few, big chunks. On a busy ARM host the in-process WebTorrent
|
|
22
|
-
// hashing starves the event loop in bursts, so fewer read iterations means
|
|
23
|
-
// far less time lost between chunks while serving the first segments.
|
|
24
|
-
const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
25
|
-
/**
|
|
26
|
-
* Resolve after a given number of milliseconds.
|
|
27
|
-
*
|
|
28
|
-
* @param {number} ms
|
|
29
|
-
* @returns {Promise<void>}
|
|
30
|
-
*/
|
|
31
|
-
function delay(ms) {
|
|
32
|
-
return new Promise((resolve) => {
|
|
33
|
-
setTimeout(resolve, ms);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Guard against path traversal by restricting file names to the known
|
|
38
|
-
* playlist and segment patterns produced by ffmpeg. Which segment names are
|
|
39
|
-
* legal depends on the active container, so the format decides.
|
|
40
|
-
*
|
|
41
|
-
* @param {string} fileName
|
|
42
|
-
* @param {import("../encode/segment-formats/index.js").SegmentFormat} segmentFormat
|
|
43
|
-
* @returns {boolean}
|
|
44
|
-
*/
|
|
45
|
-
function isSafeFileName(fileName, segmentFormat) {
|
|
46
|
-
return (
|
|
47
|
-
fileName === PLAYLIST_FILE_NAME ||
|
|
48
|
-
fileName === MASTER_PLAYLIST_FILE_NAME ||
|
|
49
|
-
(segmentFormat.initFileName !== null && fileName === segmentFormat.initFileName) ||
|
|
50
|
-
segmentFormat.isSegmentFileName(fileName)
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Whether this output is cut at times we hand the muxer, rather than at a
|
|
55
|
-
* duration it chooses for itself.
|
|
56
|
-
*
|
|
57
|
-
* A property of the output and not of a run: the cut grid and the branch decide
|
|
58
|
-
* it, so every run of one output answers alike. It decides how a segment is
|
|
59
|
-
* judged finished — see getFileStream.
|
|
60
|
-
*
|
|
61
|
-
* @param {object} session
|
|
62
|
-
* @returns {boolean}
|
|
63
|
-
*/
|
|
64
|
-
function cutsAtGivenTimes(session) {
|
|
65
|
-
const explicit = session?.segmentFormat?.explicitTimesMuxerArgs?.() ?? null;
|
|
66
|
-
if (!explicit) {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
return !session.spec.transcodesVideo || session.timeline?.cutGrid === "keyframe";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export class SegmentServing {
|
|
73
|
-
/** Serving state keyed by the output object it belongs to. */
|
|
74
|
-
#states = new WeakMap();
|
|
75
|
-
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
76
|
-
#host;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* @param {object} host - `buildMasterPlaylist`, `declaredTracks`, `publishedGridFor`, `runStartTimeFor`, `cushion`, `encodeOrchestrator`, `encodeRuns`, `hostTimings`, `lookaheadSeconds`, `outputTimes`, `outputs`, `segmentStore`, `startupWaitMs`, `viewers`
|
|
80
|
-
*/
|
|
81
|
-
constructor(host) {
|
|
82
|
-
this.#host = host;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
#stateFor(output) {
|
|
86
|
-
let state = this.#states.get(output);
|
|
87
|
-
if (!state) {
|
|
88
|
-
state = {
|
|
89
|
-
holdExplainedAt: new Map(),
|
|
90
|
-
waitEpoch: 0,
|
|
91
|
-
waitListeners: new Set()
|
|
92
|
-
};
|
|
93
|
-
this.#states.set(output, state);
|
|
94
|
-
}
|
|
95
|
-
return state;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
invalidateWaits(output) {
|
|
99
|
-
const state = this.#stateFor(output);
|
|
100
|
-
state.waitEpoch += 1;
|
|
101
|
-
for (const wake of state.waitListeners) wake(true);
|
|
102
|
-
state.waitListeners.clear();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Open a read stream for an HLS segment or playlist file from a session.
|
|
107
|
-
*
|
|
108
|
-
* @param {string} sessionId
|
|
109
|
-
* @param {string} fileName - Must match the playlist or segment name pattern.
|
|
110
|
-
* @param {{ consumerId?: string }} [options] -
|
|
111
|
-
* `consumerId` says WHICH viewer is asking, so a session
|
|
112
|
-
* shared by several of them can tell their positions apart; absent from a
|
|
113
|
-
* browser or a transport that does not carry it, and then everything falls
|
|
114
|
-
* back to the one shared position.
|
|
115
|
-
* @returns {Promise<
|
|
116
|
-
* | { kind: "not-found" }
|
|
117
|
-
* | { kind: "warming-up" }
|
|
118
|
-
* | { kind: "failed"; message: string }
|
|
119
|
-
* | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
|
|
120
|
-
* >}
|
|
121
|
-
*/
|
|
122
|
-
async getFileStream(sessionId, fileName, options = {}) {
|
|
123
|
-
const consumerId = typeof options.consumerId === "string" ? options.consumerId : "";
|
|
124
|
-
if (!isOutputName(sessionId)) {
|
|
125
|
-
return { kind: "not-found" };
|
|
126
|
-
}
|
|
127
|
-
const session = this.#host.outputs.get(sessionId);
|
|
128
|
-
// The session is looked up BEFORE the name is validated, because what
|
|
129
|
-
// counts as a valid segment name depends on the container this session
|
|
130
|
-
// chose — `.mp4` for fMP4, `.ts` for MPEG-TS.
|
|
131
|
-
if (!session || !isSafeFileName(fileName, session.segmentFormat)) {
|
|
132
|
-
return { kind: "not-found" };
|
|
133
|
-
}
|
|
134
|
-
// WHETHER PRODUCTION FAILED IS NOT ASKED HERE. It explains a piece that is
|
|
135
|
-
// ABSENT and says nothing about one that is on disk: a segment under its
|
|
136
|
-
// served name is whole by construction, whoever wrote it and whatever has
|
|
137
|
-
// become of the encoder since. Asked at the door, as it was, a failed run
|
|
138
|
-
// answered 500 to every request for material it had already finished — the
|
|
139
|
-
// viewer lost what was made as well as what was not, and a seek back into
|
|
140
|
-
// it could not be served either. It is asked where the answer is needed:
|
|
141
|
-
// where the file is not there (`#holdForProduction`, and the init's own
|
|
142
|
-
// absent branch).
|
|
143
|
-
this.#host.outputs.touch(session);
|
|
144
|
-
|
|
145
|
-
// The index of variants. Served from here rather than a route of its own,
|
|
146
|
-
// because to a player it is simply another playlist under the session.
|
|
147
|
-
if (fileName === MASTER_PLAYLIST_FILE_NAME) {
|
|
148
|
-
const masterText = this.#host.buildMasterPlaylist(sessionId, consumerId);
|
|
149
|
-
if (!masterText) {
|
|
150
|
-
return { kind: "not-found" };
|
|
151
|
-
}
|
|
152
|
-
return {
|
|
153
|
-
kind: "file",
|
|
154
|
-
stream: Readable.from([masterText]),
|
|
155
|
-
contentType: "application/vnd.apple.mpegurl",
|
|
156
|
-
isPlaylist: true
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Serve the synthetic VOD playlist (full duration, terminated with
|
|
161
|
-
// #EXT-X-ENDLIST) so the player gets the correct total length and a fully
|
|
162
|
-
// seekable timeline up-front, independent of how far ffmpeg has encoded.
|
|
163
|
-
if (fileName === PLAYLIST_FILE_NAME && session.useSyntheticPlaylist) {
|
|
164
|
-
return {
|
|
165
|
-
kind: "file",
|
|
166
|
-
stream: Readable.from([session.playlistText]),
|
|
167
|
-
contentType: "application/vnd.apple.mpegurl",
|
|
168
|
-
isPlaylist: true
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// The init segment (fMP4 only; referenced by #EXT-X-MAP). Each seek-restart
|
|
173
|
-
// run REWRITES it, so cache the FIRST one and always serve that — the
|
|
174
|
-
// player fetches it once and never re-fetches, so it must stay stable for
|
|
175
|
-
// the session's lifetime. (What that costs, and why segments must therefore
|
|
176
|
-
// carry their own position, is documented in `encode/segment-formats/mp4-boxes.js`
|
|
177
|
-
// `stampSegmentStartTime`.)
|
|
178
|
-
//
|
|
179
|
-
// ffmpeg creates init.mp4 before it has finished writing the fMP4 header
|
|
180
|
-
// boxes into it (unlike segments, its write is not gated behind an atomic
|
|
181
|
-
// rename), so a read can race a moment where the file EXISTS but is still
|
|
182
|
-
// EMPTY. Root cause of a real incident: that empty read used to be cached
|
|
183
|
-
// as the output's init — a zero-length Buffer is still a truthy object,
|
|
184
|
-
// so a check on its presence treated it as "already resolved" and served
|
|
185
|
-
// the empty file for the rest of the session's life, permanently breaking
|
|
186
|
-
// playback (hls.js can never initialize its SourceBuffer from an empty
|
|
187
|
-
// init segment) while the transcode itself kept encoding normally. Guard
|
|
188
|
-
// on non-empty content on both the cache check and the fresh read, so an
|
|
189
|
-
// empty read is treated as not-yet-ready and the caller's long-poll keeps
|
|
190
|
-
// retrying until ffmpeg has actually written the header.
|
|
191
|
-
const { initFileName } = session.segmentFormat;
|
|
192
|
-
if (initFileName !== null && fileName === initFileName) {
|
|
193
|
-
const kept = this.#host.segmentStore.initOf(session.outputKey ?? "");
|
|
194
|
-
if (kept) {
|
|
195
|
-
return {
|
|
196
|
-
kind: "file",
|
|
197
|
-
stream: Readable.from([kept]),
|
|
198
|
-
contentType: session.segmentFormat.initContentType,
|
|
199
|
-
isPlaylist: false
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
try {
|
|
203
|
-
// With explicit cut times there is no init file: that muxer writes each
|
|
204
|
-
// piece self-contained, header and all. The header is identical in every
|
|
205
|
-
// piece, so the first one to exist supplies it.
|
|
206
|
-
const bytes = cutsAtGivenTimes(session)
|
|
207
|
-
? await this.#initFromFirstSegment(session)
|
|
208
|
-
: await readFile(path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), initFileName));
|
|
209
|
-
const standing = this.#host.segmentStore.keepInit(session.outputKey ?? "", bytes);
|
|
210
|
-
if (!standing) {
|
|
211
|
-
// Nothing usable yet. On the branch that lifts the header out of the
|
|
212
|
-
// first piece there is no file to be missing, so "not produced yet"
|
|
213
|
-
// arrives HERE and not in the catch below: `#initFromFirstSegment`
|
|
214
|
-
// answers null rather than throwing `ENOENT`. A production that has
|
|
215
|
-
// ended in an error is not going to produce one either, and saying
|
|
216
|
-
// "still warming up" to that holds the request for its whole deadline.
|
|
217
|
-
return this.#failedOrWarming(session);
|
|
218
|
-
}
|
|
219
|
-
return {
|
|
220
|
-
kind: "file",
|
|
221
|
-
stream: Readable.from([standing]),
|
|
222
|
-
contentType: session.segmentFormat.initContentType,
|
|
223
|
-
isPlaylist: false
|
|
224
|
-
};
|
|
225
|
-
} catch (error) {
|
|
226
|
-
if (error?.code === "ENOENT") {
|
|
227
|
-
// Not produced yet — the encode run started at session creation
|
|
228
|
-
// writes it early; the caller long-polls until it appears. Unless
|
|
229
|
-
// production has ended in an error, in which case nothing is going to
|
|
230
|
-
// write it and holding the request only spends the viewer's patience.
|
|
231
|
-
return this.#failedOrWarming(session);
|
|
232
|
-
}
|
|
233
|
-
logger.error(
|
|
234
|
-
`transcode ${session.id} could not serve ${initFileName}: ${error?.message ?? error}` +
|
|
235
|
-
(error?.stack ? `\n${error.stack}` : "")
|
|
236
|
-
);
|
|
237
|
-
return {
|
|
238
|
-
kind: "failed",
|
|
239
|
-
message: `Could not serve ${initFileName}: ${error?.message ?? String(error)}`
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Which run's copy answers, when several have written this name. Chosen by
|
|
245
|
-
// what the copies CARRY, not by which run is newest — see #chooseProducedCopy.
|
|
246
|
-
const filePath = this.#host.segmentStore.pathOfName(session.outputKey ?? "", fileName) ??
|
|
247
|
-
path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), fileName);
|
|
248
|
-
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
249
|
-
if (!isPlaylist) {
|
|
250
|
-
// A REQUEST SAYS THE VIEWER IS HERE, AND NOTHING ELSE. It does not say
|
|
251
|
-
// where they are — that is what they state themselves — and it steers no
|
|
252
|
-
// encoder: the segment either exists and is served, or does not and is
|
|
253
|
-
// waited for.
|
|
254
|
-
const requested = session.segmentFormat.segmentIndexFromName(fileName);
|
|
255
|
-
if (requested >= 0) {
|
|
256
|
-
this.#noteViewerSeen(session, consumerId);
|
|
257
|
-
// A viewer who has caught up must not wait out the monitor's interval —
|
|
258
|
-
// but only if they HAVE caught up, which is why this re-evaluates the
|
|
259
|
-
// same condition instead of resuming outright.
|
|
260
|
-
this.#host.cushion.reportCushionFor(session);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
// Whether the file is there is asked on its own, and nothing else shares
|
|
264
|
-
// this catch. Everything below is PREPARATION of a file that exists, and a
|
|
265
|
-
// failure there means something entirely different from "not produced yet"
|
|
266
|
-
// — but for one release the two were caught together, so an undeclared name
|
|
267
|
-
// in the fMP4 path read as "the segment is not ready". Every poll threw the
|
|
268
|
-
// same ReferenceError, every poll answered "wait", and playback never began
|
|
269
|
-
// on any file cut at keyframes (2.9.124; measured 2026-08-08: segment #0
|
|
270
|
-
// held for 45 281 ms with twelve finished segments on disk).
|
|
271
|
-
try {
|
|
272
|
-
await access(filePath);
|
|
273
|
-
} catch {
|
|
274
|
-
// Not produced yet.
|
|
275
|
-
return this.#holdForProduction(session, fileName, isPlaylist, options);
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
// Existing is not the same as finished. The `hls` muxer wrote each
|
|
279
|
-
// segment to a temporary name and renamed it once complete, so a file
|
|
280
|
-
// appearing WAS a finished segment. The `segment` muxer has no such
|
|
281
|
-
// option: the file appears when writing begins. Serving it then hands the
|
|
282
|
-
// player a truncated segment, which it rejects and then simply stops —
|
|
283
|
-
// observed as playback dying a few seconds in with the encoder still
|
|
284
|
-
// running happily ahead. A segment is finished once the NEXT one has been
|
|
285
|
-
// started, or once the run producing it has ended.
|
|
286
|
-
if (!isPlaylist && cutsAtGivenTimes(session)) {
|
|
287
|
-
// WHAT PROVES A PIECE IS WHOLE is the encoder's own word for it: it
|
|
288
|
-
// names each file on a channel of its own the moment it closes it, and
|
|
289
|
-
// the store keeps those names.
|
|
290
|
-
//
|
|
291
|
-
// What stood here instead was the existence of the NEXT file, with two
|
|
292
|
-
// exceptions bolted on because it is not true. It is never true of the
|
|
293
|
-
// last piece of a run — nothing is producing a next one — so the first
|
|
294
|
-
// segment of every run was held: measured 2026-08-09, #807 held while
|
|
295
|
-
// it lay on disk, and in August the same shape held #317 for 46 seconds
|
|
296
|
-
// and then answered 404 to a browser that had given up.
|
|
297
|
-
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
298
|
-
if (!this.#host.segmentStore.isClosed(session.outputKey ?? "", index)) {
|
|
299
|
-
this.#explainHold(session, fileName, "the encoder has not closed it yet");
|
|
300
|
-
return { kind: "warming-up" };
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Cold-start: the time from the create request to a playable first
|
|
305
|
-
// segment, measured once per output by the owner of host timings.
|
|
306
|
-
const coldStartMs = isPlaylist ? null : this.#host.hostTimings.noteSegmentServed(session);
|
|
307
|
-
if (coldStartMs !== null) {
|
|
308
|
-
// Data is flowing again, so the next loss starts its backoff afresh
|
|
309
|
-
// rather than inheriting the delay of the last one.
|
|
310
|
-
this.#host.encodeRuns.resetInputRetry(session);
|
|
311
|
-
logger.info(`cold-start ${sessionId.slice(0, 8)}: first-segment ready +${coldStartMs}ms`);
|
|
312
|
-
}
|
|
313
|
-
// Formats whose segments need correcting before they are valid against
|
|
314
|
-
// the session's cached init are read whole and passed through the format
|
|
315
|
-
// module; the rest stream straight off disk.
|
|
316
|
-
if (!isPlaylist && session.segmentFormat.needsSegmentRewrite) {
|
|
317
|
-
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
318
|
-
const raw = await readFile(filePath);
|
|
319
|
-
// Self-contained pieces carry the init header; a media segment must not.
|
|
320
|
-
const bytes = cutsAtGivenTimes(session) && session.segmentFormat.stripInit
|
|
321
|
-
? session.segmentFormat.stripInit(raw)
|
|
322
|
-
: raw;
|
|
323
|
-
// WHOLE OR SHORT IS A JUDGEMENT ABOUT BYTES, and the format that
|
|
324
|
-
// knows how to read them makes it (`judgeTracks`). What is done about
|
|
325
|
-
// the answer is this path's business and stays here: a whole piece is
|
|
326
|
-
// served, a short one is removed so it can be made again.
|
|
327
|
-
const verdict = session.segmentFormat.judgeTracks?.(raw, bytes, this.#host.segmentStore.initOf(session.outputKey ?? "")) ?? null;
|
|
328
|
-
if (verdict && !verdict.whole) {
|
|
329
|
-
logger.warn(
|
|
330
|
-
`transcode ${session.id} segment #${index} is short of a track — ` +
|
|
331
|
-
`${filePath}, ${raw.length} bytes on disk, ${bytes.length} of body, ` +
|
|
332
|
-
`${verdict.fragmentTracks} track(s) in its fragments against ` +
|
|
333
|
-
`${verdict.sessionTracks} the session's header declares and ` +
|
|
334
|
-
`${verdict.ownTracks} its own declares — ${verdict.because}`
|
|
335
|
-
);
|
|
336
|
-
if (!verdict.serve) {
|
|
337
|
-
try {
|
|
338
|
-
await unlink(filePath);
|
|
339
|
-
} catch {
|
|
340
|
-
// Already gone: either way nothing to do.
|
|
341
|
-
}
|
|
342
|
-
// What the store remembers of this directory is stale the moment a
|
|
343
|
-
// file is taken out of it.
|
|
344
|
-
this.#host.segmentStore.forget(session.outputKey ?? "");
|
|
345
|
-
return { kind: "warming-up" };
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
// Where this segment REALLY begins, taken from the piece itself, and
|
|
349
|
-
// only from the playlist when the piece does not say.
|
|
350
|
-
//
|
|
351
|
-
// The playlist's own answer is built from the container's keyframe
|
|
352
|
-
// index, and an index can be wrong: measured 2026-08-06 on a Matroska
|
|
353
|
-
// file whose index claimed a keyframe at 157.99 s where the real ones
|
|
354
|
-
// were 153.82 and 164.247. ffmpeg cut at 153.82, and stamping that
|
|
355
|
-
// picture with 157.99 told the player it belonged four seconds later
|
|
356
|
-
// than it did — while subtitles, extracted straight from the source,
|
|
357
|
-
// kept the true times. Speech and text drifted apart by 4.17 s.
|
|
358
|
-
//
|
|
359
|
-
// Read from `raw`, before the header is stripped: the position lives
|
|
360
|
-
// in an empty edit in the piece's own `moov`, which `stripInit`
|
|
361
|
-
// removes. Identical to the playlist's figure whenever the index is
|
|
362
|
-
// honest, so nothing changes for a well-formed file.
|
|
363
|
-
const trueStart = cutsAtGivenTimes(session)
|
|
364
|
-
? session.segmentFormat.readSegmentStartSeconds?.(raw) ?? null
|
|
365
|
-
: null;
|
|
366
|
-
const declaredStart = this.#host.outputTimes.segmentStartTime(session, index);
|
|
367
|
-
if (trueStart !== null) {
|
|
368
|
-
this.#host.outputTimes.noteRunLanding(session, index, trueStart);
|
|
369
|
-
this.#host.outputTimes.noteIndexAccuracy(session, index, trueStart, declaredStart);
|
|
370
|
-
}
|
|
371
|
-
// WHERE THE PLAYER WAS TOLD THIS SEGMENT BEGINS, which is the playlist
|
|
372
|
-
// it holds and nothing else. The published text is fixed when the
|
|
373
|
-
// session is created; `#segmentStartTime` reads a table that a
|
|
374
|
-
// correction may since have moved, and a stamp taken from the moved
|
|
375
|
-
// table describes a timeline the player has never seen.
|
|
376
|
-
const publishedStart = this.#host.outputTimes.publishedStartTime(session, index);
|
|
377
|
-
// A player places a fragment by the playlist. If the bytes claim a
|
|
378
|
-
// different position, the fragment does not land where the fragment was
|
|
379
|
-
// expected, hls.js finds the range still unbuffered and asks for the
|
|
380
|
-
// same fragment again — for ever. Measured 2026-08-17: a seek to
|
|
381
|
-
// 1590.4 s produced audio segments #292/#293 whose own timeline said
|
|
382
|
-
// 1587.892 and 1592.692 against a playlist saying 1585.376 and
|
|
383
|
-
// 1590.585, and the browser fetched those two segments 1908 times each
|
|
384
|
-
// over ten minutes, every one of them served in 4 ms. The film was dead
|
|
385
|
-
// and no line said why.
|
|
386
|
-
//
|
|
387
|
-
// So the stamp follows the playlist whenever the two disagree by more
|
|
388
|
-
// than a player will bridge. hls.js bridges up to `maxBufferHole`,
|
|
389
|
-
// which it defaults to 0.5 s — that is the player's own published
|
|
390
|
-
// figure, not a number chosen here. Within it the file's own position
|
|
391
|
-
// is kept, because it is the honest one and it is what keeps speech and
|
|
392
|
-
// subtitles together on a file whose index is slightly out (2026-08-06,
|
|
393
|
-
// 4.17 s of drift on a Matroska index that lied).
|
|
394
|
-
// STAMPED WITH ITS OWN TRUE START, always. Two attempts at moving it
|
|
395
|
-
// toward the playlist both made things worse, and the reason is in what
|
|
396
|
-
// the first segment of a run is: it is not CUT at all — it begins where
|
|
397
|
-
// ffmpeg's seek landed. The picture must land on a keyframe; the sound
|
|
398
|
-
// needs none and starts at the instant asked for. So after every
|
|
399
|
-
// restart the two runs genuinely begin at different real times, and the
|
|
400
|
-
// whole run carries that difference (field 2026-08-17: the sound's
|
|
401
|
-
// #292 began at 1587.892 s and #293 at 1592.692 s — exactly one segment
|
|
402
|
-
// apart, the whole run shifted 2.5 s from the grid).
|
|
403
|
-
//
|
|
404
|
-
// Labelling each track with its own true time is therefore what keeps
|
|
405
|
-
// picture and sound together in real time. Moving them onto the
|
|
406
|
-
// published grid — separately (2.24.1) or by one family offset (2.25.0)
|
|
407
|
-
// — closes a gap that is real and opens one that is not: it desynced
|
|
408
|
-
// playback in the field within the hour, twice.
|
|
409
|
-
//
|
|
410
|
-
// What that leaves unsolved is the reason those attempts were made: a
|
|
411
|
-
// playlist that disagrees with the media by more than a player bridges
|
|
412
|
-
// makes hls.js refetch the same fragment for ever (1908 times each for
|
|
413
|
-
// two segments, measured). The answer to THAT is to make the published
|
|
414
|
-
// grid agree with where the runs really begin — not to relabel the
|
|
415
|
-
// media. Recorded as its own roadmap item rather than guessed at here.
|
|
416
|
-
const stampStart = trueStart ?? publishedStart;
|
|
417
|
-
if (trueStart !== null) {
|
|
418
|
-
this.#host.outputTimes.notePlaylistDisagreement(session, index, trueStart, publishedStart);
|
|
419
|
-
}
|
|
420
|
-
const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
|
|
421
|
-
startSeconds: stampStart,
|
|
422
|
-
rawBytes: raw,
|
|
423
|
-
initBytes: this.#host.segmentStore.initOf(session.outputKey ?? "")
|
|
424
|
-
});
|
|
425
|
-
this.#host.encodeRuns.noteRunProducedSegment(session, filePath);
|
|
426
|
-
return {
|
|
427
|
-
kind: "file",
|
|
428
|
-
stream: Readable.from([prepared]),
|
|
429
|
-
contentType: session.segmentFormat.segmentContentType,
|
|
430
|
-
isPlaylist: false
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
if (!isPlaylist) {
|
|
434
|
-
this.#host.encodeRuns.noteRunProducedSegment(session, filePath);
|
|
435
|
-
}
|
|
436
|
-
return {
|
|
437
|
-
kind: "file",
|
|
438
|
-
stream: isPlaylist
|
|
439
|
-
? createReadStream(filePath)
|
|
440
|
-
: createReadStream(filePath, { highWaterMark: SEGMENT_READ_HIGH_WATER_MARK }),
|
|
441
|
-
contentType: isPlaylist
|
|
442
|
-
? "application/vnd.apple.mpegurl"
|
|
443
|
-
: session.segmentFormat.segmentContentType,
|
|
444
|
-
isPlaylist
|
|
445
|
-
};
|
|
446
|
-
} catch (error) {
|
|
447
|
-
if (error?.code === "ENOENT") {
|
|
448
|
-
// The file went away between the check and the read — a leftover being
|
|
449
|
-
// removed so it can be produced again. Means exactly what never having
|
|
450
|
-
// existed means.
|
|
451
|
-
return this.#holdForProduction(session, fileName, isPlaylist, options);
|
|
452
|
-
}
|
|
453
|
-
// Anything else is a fault in producing the answer. Name it: a request
|
|
454
|
-
// answered "wait" for ever tells the viewer nothing and leaves no trace
|
|
455
|
-
// of what actually happened.
|
|
456
|
-
logger.error(
|
|
457
|
-
`transcode ${session.id} could not serve ${fileName}: ${error?.message ?? error}` +
|
|
458
|
-
(error?.stack ? `\n${error.stack}` : "")
|
|
459
|
-
);
|
|
460
|
-
return {
|
|
461
|
-
kind: "failed",
|
|
462
|
-
message: `Could not serve ${fileName}: ${error?.message ?? String(error)}`
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Whether a piece already given to a viewer can still be served from what is
|
|
469
|
-
* stored under the key of the output that made it, after that output has
|
|
470
|
-
* gone.
|
|
471
|
-
*
|
|
472
|
-
* WHY THIS EXISTS (roadmap item 97, step 11). A repeat of an address must be
|
|
473
|
-
* answered by what answered it the first time: the player holds that
|
|
474
|
-
* output's header, and a piece of another output under the same address may
|
|
475
|
-
* not decode under it. When the output itself has been disposed, its pieces
|
|
476
|
-
* and its header stay in the store until the disk needs the room — so the
|
|
477
|
-
* very bytes that were given can be given again.
|
|
478
|
-
*
|
|
479
|
-
* @param {string} goneKey
|
|
480
|
-
* @param {string} fileName
|
|
481
|
-
* @returns {boolean}
|
|
482
|
-
*/
|
|
483
|
-
hasStoredPiece(goneKey, fileName) {
|
|
484
|
-
const format = this.#host.segmentFormatOfKey(goneKey);
|
|
485
|
-
if (!format) {
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
if (format.initFileName !== null && fileName === format.initFileName) {
|
|
489
|
-
return Boolean(this.#host.segmentStore.initOf(goneKey));
|
|
490
|
-
}
|
|
491
|
-
if (!format.isSegmentFileName(fileName)) {
|
|
492
|
-
return false;
|
|
493
|
-
}
|
|
494
|
-
const index = format.segmentIndexFromName(fileName);
|
|
495
|
-
return this.#host.segmentStore.isClosed(goneKey, index) &&
|
|
496
|
-
Boolean(this.#host.segmentStore.pathOfName(goneKey, fileName));
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* That stored piece, prepared exactly as it would have been served while its
|
|
501
|
-
* output lived: its header stripped where the piece carries one, and stamped
|
|
502
|
-
* with where it truly begins or, where it does not say, where the playlist
|
|
503
|
-
* the player holds puts it.
|
|
504
|
-
*
|
|
505
|
-
* `like` is the picture the address belongs to. Every output interchangeable
|
|
506
|
-
* with it publishes one timeline, so its playlist is the gone output's too.
|
|
507
|
-
*
|
|
508
|
-
* @param {string} goneKey
|
|
509
|
-
* @param {string} likeId - The picture the address belongs to.
|
|
510
|
-
* @param {string} fileName
|
|
511
|
-
* @returns {Promise<{ kind: "file", stream: Readable, contentType: string, isPlaylist: false } | null>}
|
|
512
|
-
*/
|
|
513
|
-
async storedPieceOf(goneKey, likeId, fileName) {
|
|
514
|
-
const like = isOutputName(likeId) ? this.#host.outputs.get(likeId) : null;
|
|
515
|
-
const format = this.#host.segmentFormatOfKey(goneKey);
|
|
516
|
-
const spec = this.#host.specOfKey(goneKey);
|
|
517
|
-
if (!format || !spec || !like || !this.hasStoredPiece(goneKey, fileName)) {
|
|
518
|
-
return null;
|
|
519
|
-
}
|
|
520
|
-
const answer = (bytes, contentType) => ({
|
|
521
|
-
kind: "file",
|
|
522
|
-
stream: Readable.from([bytes]),
|
|
523
|
-
contentType,
|
|
524
|
-
isPlaylist: false
|
|
525
|
-
});
|
|
526
|
-
if (format.initFileName !== null && fileName === format.initFileName) {
|
|
527
|
-
return answer(this.#host.segmentStore.initOf(goneKey), format.initContentType);
|
|
528
|
-
}
|
|
529
|
-
const index = format.segmentIndexFromName(fileName);
|
|
530
|
-
let raw;
|
|
531
|
-
try {
|
|
532
|
-
raw = await readFile(this.#host.segmentStore.pathOfName(goneKey, fileName));
|
|
533
|
-
} catch {
|
|
534
|
-
return null;
|
|
535
|
-
}
|
|
536
|
-
if (!format.needsSegmentRewrite) {
|
|
537
|
-
return answer(raw, format.segmentContentType);
|
|
538
|
-
}
|
|
539
|
-
const view = { segmentFormat: format, spec, timeline: like.timeline };
|
|
540
|
-
const selfContained = cutsAtGivenTimes(view);
|
|
541
|
-
const bytes = selfContained && format.stripInit ? format.stripInit(raw) : raw;
|
|
542
|
-
const trueStart = selfContained ? format.readSegmentStartSeconds?.(raw) ?? null : null;
|
|
543
|
-
const prepared = format.prepareSegmentBytes(bytes, {
|
|
544
|
-
startSeconds: trueStart ?? this.#host.outputTimes.publishedStartTime(like, index),
|
|
545
|
-
rawBytes: raw,
|
|
546
|
-
initBytes: this.#host.segmentStore.initOf(goneKey)
|
|
547
|
-
});
|
|
548
|
-
logger.info(`[hold] ${fileName} served from the stored pieces of ${goneKey}, the output that first answered it`);
|
|
549
|
-
return answer(prepared, format.segmentContentType);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Whether a piece of `liveKey` may stand where a piece of `goneKey` was
|
|
554
|
-
* given: the two headers compared by the product's own rule
|
|
555
|
-
* (`init-compat.js`), which refuses anything it cannot show a decoder
|
|
556
|
-
* ignores. A header not yet made is no proof, and so is a refusal.
|
|
557
|
-
*
|
|
558
|
-
* @param {string} goneKey
|
|
559
|
-
* @param {string} liveKey
|
|
560
|
-
* @returns {{ compatible: boolean, differences: string[] }}
|
|
561
|
-
*/
|
|
562
|
-
headersCompatible(goneKey, liveKey) {
|
|
563
|
-
const verdict = this.#host.compareInits(
|
|
564
|
-
this.#host.segmentStore.initOf(goneKey),
|
|
565
|
-
this.#host.segmentStore.initOf(liveKey)
|
|
566
|
-
);
|
|
567
|
-
return { compatible: verdict.compatible === true, differences: verdict.differences ?? [] };
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* Say WHY a segment is being held, at most once every few seconds per file.
|
|
572
|
-
*
|
|
573
|
-
* A hold is silent today, and that silence has now cost three releases: a
|
|
574
|
-
* file that exists, a route that answers "not yet", and nothing anywhere
|
|
575
|
-
* saying which of the several reasons applied. Measured 2026-08-09: a run
|
|
576
|
-
* begun mid-file at segment #317 produced two minutes of video from #317
|
|
577
|
-
* upwards at 10.5x, and #317 itself was held 46 s and then answered 404 once
|
|
578
|
-
* the browser had given up — with not one line about the cause.
|
|
579
|
-
*
|
|
580
|
-
* @param {HlsSession} session
|
|
581
|
-
* @param {string} fileName
|
|
582
|
-
* @param {string} reason
|
|
583
|
-
* @returns {void}
|
|
584
|
-
*/
|
|
585
|
-
#explainHold(session, fileName, reason) {
|
|
586
|
-
const now = Date.now();
|
|
587
|
-
const state = this.#stateFor(session);
|
|
588
|
-
const last = state.holdExplainedAt.get(fileName) ?? 0;
|
|
589
|
-
if (now - last < 5_000) {
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
state.holdExplainedAt.set(fileName, now);
|
|
593
|
-
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
594
|
-
// What the encoder has actually DONE since it restarted. "Alive at the right
|
|
595
|
-
// index" was as far as the old line went, and it left the two possible
|
|
596
|
-
// causes indistinguishable: an encoder waiting for torrent pieces looks
|
|
597
|
-
// exactly like one that is encoding and simply has not finished. The
|
|
598
|
-
// difference is whether its position has moved at all.
|
|
599
|
-
// Where this run began, from the run — the same reckoning
|
|
600
|
-
// `processedSeconds` is counted in. A table lookup here can disagree with
|
|
601
|
-
// it by the distance between the two grids, which is enough to print a
|
|
602
|
-
// negative "produced" and send the reader after the torrent when the
|
|
603
|
-
// encoder is the subject.
|
|
604
|
-
const progress = this.#host.encodeRuns.progressOf(session, index);
|
|
605
|
-
const runStartSeconds = Number.isFinite(progress?.startPositionSeconds)
|
|
606
|
-
? progress.startPositionSeconds
|
|
607
|
-
: this.#host.runStartTimeFor(session, this.#host.encodeRuns.earliestStartOf(session) ?? 0);
|
|
608
|
-
const position = Number(progress?.processedSeconds);
|
|
609
|
-
const produced = Number.isFinite(position) ? position - runStartSeconds : null;
|
|
610
|
-
const speed = progress?.speed || "n/a";
|
|
611
|
-
logger.warn(
|
|
612
|
-
`transcode ${session.id} holding ${fileName}: ${reason} ` +
|
|
613
|
-
`(runs from #${this.#host.encodeRuns.earliestStartOf(session) ?? "?"}, viewer at #${this.#host.outputTimes.segmentIndexForTime(session, this.#host.viewerSecondsOn(session))}, ` +
|
|
614
|
-
`encoder ${this.#host.encodeRuns.liveRunsOf(session).length > 0 ? "alive" : "stopped"}, index #${index}, ` +
|
|
615
|
-
`produced ${produced === null ? "nothing yet — no position reported" : `${produced.toFixed(1)}s`} ` +
|
|
616
|
-
`at ${speed}${produced !== null && produced <= 0 ? " — the encoder has not moved, so it is waiting on its input" : ""})`
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* What an absent file answers when production has failed.
|
|
622
|
-
*
|
|
623
|
-
* @param {object} session
|
|
624
|
-
* @returns {{ kind: "failed", message: string } | { kind: "warming-up" }}
|
|
625
|
-
*/
|
|
626
|
-
#failedOrWarming(session) {
|
|
627
|
-
if (!this.#host.encodeRuns.hasFailed(session)) {
|
|
628
|
-
return { kind: "warming-up" };
|
|
629
|
-
}
|
|
630
|
-
return {
|
|
631
|
-
kind: "failed",
|
|
632
|
-
message: this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed for this transcode session."
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
#holdForProduction(session, fileName, isPlaylist, options) {
|
|
637
|
-
// The file is not there and production has ended in an error: nothing is
|
|
638
|
-
// going to write it, so the viewer is told instead of held.
|
|
639
|
-
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
640
|
-
return this.#failedOrWarming(session);
|
|
641
|
-
}
|
|
642
|
-
if (this.#host.encodeRuns.isWaitingForInput(session)) {
|
|
643
|
-
// The data went away and is being fetched again. Holding the request is
|
|
644
|
-
// the truthful answer: nothing is broken and there is nothing for the
|
|
645
|
-
// viewer to retry. Only what is NOT on disk is held for it — a piece that
|
|
646
|
-
// exists is whole, and it is served whatever the encoder is doing.
|
|
647
|
-
return { kind: "warming-up" };
|
|
648
|
-
}
|
|
649
|
-
/** @type {{ address: string, rank: number, topRank: number } | null} */
|
|
650
|
-
let ranked = null;
|
|
651
|
-
if (!isPlaylist) {
|
|
652
|
-
this.#explainHold(session, fileName, "the file is not on disk");
|
|
653
|
-
}
|
|
654
|
-
// A segment was requested that ffmpeg has not produced yet. Decide whether
|
|
655
|
-
// to wait for the current encode run to reach it or to restart the encoder
|
|
656
|
-
// at this position (server-side seeking). The caller long-polls.
|
|
657
|
-
if (!isPlaylist) {
|
|
658
|
-
const requestedIndex = session.segmentFormat.segmentIndexFromName(fileName);
|
|
659
|
-
// Unanswerable, and known to be: behind a run that only moves forward,
|
|
660
|
-
// too far behind for the repair to fetch it, and no seek on its way to
|
|
661
|
-
// move the encoder there. Holding it changes nothing about whether it can
|
|
662
|
-
// be produced — it only spends the player's patience.
|
|
663
|
-
//
|
|
664
|
-
// This is what a track change costs when it is held instead: measured
|
|
665
|
-
// 2026-08-15, hls.js asked the new track for segment #0 while the run was
|
|
666
|
-
// at #354, the request was held for the full minute, and only when it
|
|
667
|
-
// failed did the player move to the segment it actually needed — 63 s of
|
|
668
|
-
// spinner after a track that had been made ready in 7.
|
|
669
|
-
//
|
|
670
|
-
// WHETHER ANYBODY IS COMING FOR IT, asked of the encoding (`rankAt`,
|
|
671
|
-
// which also tells "in nobody's zone" from "no map yet"). The same walk
|
|
672
|
-
// was written out here and could answer only yes or no, so the RANK was
|
|
673
|
-
// discarded at the one point where a viewer measurably waits for a named
|
|
674
|
-
// segment; it goes back out with the answer, because the wait is measured
|
|
675
|
-
// by whoever holds the request.
|
|
676
|
-
const address = session.outputKey ?? "";
|
|
677
|
-
const { rank, topRank } = this.#host.encodeOrchestrator.rankAt(address, requestedIndex);
|
|
678
|
-
ranked = { address, rank, topRank };
|
|
679
|
-
const nobodyIsComing = topRank > 0 && rank === 0;
|
|
680
|
-
if (
|
|
681
|
-
Number.isFinite(requestedIndex) &&
|
|
682
|
-
requestedIndex < (this.#host.encodeRuns.earliestStartOf(session) ?? 0) &&
|
|
683
|
-
nobodyIsComing &&
|
|
684
|
-
this.#host.encodeRuns.liveRunsOf(session).length > 0
|
|
685
|
-
) {
|
|
686
|
-
logger.info(
|
|
687
|
-
`transcode ${session.id} segment #${requestedIndex} is ${(this.#host.encodeRuns.earliestStartOf(session) ?? 0) - requestedIndex} ` +
|
|
688
|
-
"segments behind the run and in nobody's zone; answered as absent rather than held"
|
|
689
|
-
);
|
|
690
|
-
return { kind: "not-found", ranked };
|
|
691
|
-
}
|
|
692
|
-
this.#noteWanted(session, requestedIndex);
|
|
693
|
-
}
|
|
694
|
-
return { kind: "warming-up", ranked };
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Record that a segment nobody has made yet is wanted, and say so when it lies
|
|
699
|
-
* behind every encoder of this output.
|
|
700
|
-
*
|
|
701
|
-
* A REQUEST STEERS NO ENCODER. This used to be where one did: a request far
|
|
702
|
-
* from the encode head restarted ffmpeg there, and in the field one seek
|
|
703
|
-
* produced nine restarts in a minute, because a player holds a couple of
|
|
704
|
-
* dozen requests open at once and none of them is "the one the viewer ended
|
|
705
|
-
* on". What is missing in front of a viewer is stated by the priority map,
|
|
706
|
-
* and placing encoders on it is the plan's work. What is left here is a
|
|
707
|
-
* record for the restart accounting and a line for whoever reads the log.
|
|
708
|
-
*
|
|
709
|
-
* @param {HlsSession} session
|
|
710
|
-
* @param {number} index
|
|
711
|
-
* @returns {void}
|
|
712
|
-
*/
|
|
713
|
-
#noteWanted(session, index) {
|
|
714
|
-
// When this segment was FIRST asked for and nobody was producing it. The
|
|
715
|
-
// restart itself costs 0.7-1.3 s (measured 2.9.132), while a seek costs
|
|
716
|
-
// 5-8 s end to end — so most of the wait happens before a restart is even
|
|
717
|
-
// decided on, and that is what this records.
|
|
718
|
-
this.#host.encodeRuns.noteWanted(session, index);
|
|
719
|
-
if (!this.#host.encodeRuns.isLive(session) || index < 0) {
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
const head = this.#host.encodeRuns.earliestStartOf(session) ?? 0;
|
|
723
|
-
if (index < head) {
|
|
724
|
-
// Nothing running goes backwards, so nothing running will make it.
|
|
725
|
-
this.#explainHold(
|
|
726
|
-
session,
|
|
727
|
-
session.segmentFormat.segmentFileName(index),
|
|
728
|
-
`it is behind the run (#${head}); where the viewers are is what places encoders`
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/**
|
|
734
|
-
* The init header, lifted out of the first segment that exists.
|
|
735
|
-
*
|
|
736
|
-
* Needed only on the explicit-cut path, where the muxer produces no init file
|
|
737
|
-
* of its own. Scans rather than assuming segment 0: a run started by a seek
|
|
738
|
-
* begins at whatever index the viewer asked for.
|
|
739
|
-
*
|
|
740
|
-
* @param {HlsSession} session
|
|
741
|
-
* @returns {Promise<Buffer | null>}
|
|
742
|
-
*/
|
|
743
|
-
|
|
744
|
-
async #initFromFirstSegment(session) {
|
|
745
|
-
if (typeof session.segmentFormat.extractInit !== "function") {
|
|
746
|
-
return null;
|
|
747
|
-
}
|
|
748
|
-
// How many tracks a complete header must declare is ANSWERED, not assumed.
|
|
749
|
-
//
|
|
750
|
-
// The probe already knows the source's stream list, and the output maps at
|
|
751
|
-
// most one of each (`-map 0:v:0? -map 0:a:0?`), so the count follows from
|
|
752
|
-
// what the source actually has. A film with no soundtrack expects one; an
|
|
753
|
-
// ordinary file expects two; neither is a convention.
|
|
754
|
-
//
|
|
755
|
-
// Deriving it from the produced pieces instead — the first version of this
|
|
756
|
-
// — reads correctly only once a piece carrying every track exists, and the
|
|
757
|
-
// whole point is the moment BEFORE that: early pieces written before the
|
|
758
|
-
// video was muxed would set the requirement to one and wave through exactly
|
|
759
|
-
// the header this exists to reject. The pieces are still consulted, but
|
|
760
|
-
// only as a floor: a piece carrying more than the probe led us to expect is
|
|
761
|
-
// evidence, and evidence outranks the probe.
|
|
762
|
-
const declared = this.#host.declaredTracks(session);
|
|
763
|
-
let expectedTracks = (declared.video ? 1 : 0) + (declared.audio ? 1 : 0);
|
|
764
|
-
if (expectedTracks === 0) {
|
|
765
|
-
// Nothing to consult. Fall back to the evidence, with its known lag.
|
|
766
|
-
expectedTracks = 1;
|
|
767
|
-
}
|
|
768
|
-
let best = null;
|
|
769
|
-
let bestTracks = 0;
|
|
770
|
-
/** @type {Map<string, Buffer>} Pieces read once and used for both passes. */
|
|
771
|
-
const pieces = new Map();
|
|
772
|
-
let names;
|
|
773
|
-
try {
|
|
774
|
-
names = this.producedNumbers(session).map((index) => session.segmentFormat.segmentFileName(index));
|
|
775
|
-
} catch {
|
|
776
|
-
return null;
|
|
777
|
-
}
|
|
778
|
-
// First pass: what do the produced pieces actually carry? The answer is the
|
|
779
|
-
// requirement — no assumption about the source is involved.
|
|
780
|
-
if (typeof session.segmentFormat.countSegmentTracks === "function") {
|
|
781
|
-
for (const name of names) {
|
|
782
|
-
try {
|
|
783
|
-
// The first copy WITH BYTES IN IT, not the first name: a run stopped
|
|
784
|
-
// with a piece open leaves an empty file under the same name, and
|
|
785
|
-
// taking that one skips a number whose header is sitting in the run
|
|
786
|
-
// before it.
|
|
787
|
-
const found = await this.#firstCopyWithBytes(session, name);
|
|
788
|
-
if (!found) {
|
|
789
|
-
continue;
|
|
790
|
-
}
|
|
791
|
-
const bytes = await readFile(found);
|
|
792
|
-
pieces.set(name, bytes);
|
|
793
|
-
expectedTracks = Math.max(expectedTracks, session.segmentFormat.countSegmentTracks(bytes));
|
|
794
|
-
} catch {
|
|
795
|
-
// Being written right now — it says nothing about the others.
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
for (const name of names) {
|
|
801
|
-
try {
|
|
802
|
-
const cached = pieces.get(name);
|
|
803
|
-
const found = cached ? name : await this.#firstCopyWithBytes(session, name);
|
|
804
|
-
if (!found) {
|
|
805
|
-
continue;
|
|
806
|
-
}
|
|
807
|
-
const ownInit = session.segmentFormat.extractInit(cached ?? await readFile(found));
|
|
808
|
-
const init = ownInit && session.segmentFormat.prepareSharedInit ?
|
|
809
|
-
session.segmentFormat.prepareSharedInit(ownInit) : ownInit;
|
|
810
|
-
if (!init || init.length === 0) {
|
|
811
|
-
continue;
|
|
812
|
-
}
|
|
813
|
-
// The requirement computed above is APPLIED here. It was computed and
|
|
814
|
-
// then ignored: this loop returned the first header it found, so a
|
|
815
|
-
// piece written before the video was muxed supplied an audio-only
|
|
816
|
-
// header — and that header is cached for the session's whole life,
|
|
817
|
-
// because the player fetches `#EXT-X-MAP` once. Measured 2026-08-11:
|
|
818
|
-
// `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — sound playing
|
|
819
|
-
// and no picture, for as long as the session lasted.
|
|
820
|
-
const tracks = typeof session.segmentFormat.countInitTracks === "function"
|
|
821
|
-
? session.segmentFormat.countInitTracks(init)
|
|
822
|
-
: expectedTracks;
|
|
823
|
-
if (tracks >= expectedTracks) {
|
|
824
|
-
return init;
|
|
825
|
-
}
|
|
826
|
-
if (tracks > bestTracks) {
|
|
827
|
-
best = init;
|
|
828
|
-
bestTracks = tracks;
|
|
829
|
-
}
|
|
830
|
-
} catch {
|
|
831
|
-
// Being written right now — try the next one.
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
if (best !== null) {
|
|
835
|
-
// Nothing carried the full set. The source is probably missing a stream;
|
|
836
|
-
// serving the richest header found is right, and saying so makes the
|
|
837
|
-
// other possibility — every piece so far written before the video was
|
|
838
|
-
// muxed — visible rather than silent.
|
|
839
|
-
logger.warn(
|
|
840
|
-
`transcode ${session.id} no piece declared ${expectedTracks} tracks; ` +
|
|
841
|
-
`serving an init with ${bestTracks}`
|
|
842
|
-
);
|
|
843
|
-
return best;
|
|
844
|
-
}
|
|
845
|
-
return best;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* A produced file that has anything in it, or null.
|
|
850
|
-
*
|
|
851
|
-
* For callers that need a file's CONTENTS and cannot judge them — deriving
|
|
852
|
-
* the session's header is the case, since the header is what judging would
|
|
853
|
-
* need. An empty file answers no question, so it is passed over.
|
|
854
|
-
*
|
|
855
|
-
* @param {HlsSession} session
|
|
856
|
-
* @param {string} fileName
|
|
857
|
-
* @returns {Promise<string | null>}
|
|
858
|
-
*/
|
|
859
|
-
async #firstCopyWithBytes(session, fileName) {
|
|
860
|
-
const held = this.#host.segmentStore.pathOfName(session.outputKey ?? "", fileName);
|
|
861
|
-
if (held === null) {
|
|
862
|
-
return null;
|
|
863
|
-
}
|
|
864
|
-
try {
|
|
865
|
-
const info = await stat(held);
|
|
866
|
-
return info.size > 0 ? held : null;
|
|
867
|
-
} catch {
|
|
868
|
-
return null;
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* Record where one viewer of this session is, and answer with the furthest
|
|
874
|
-
* any of them has reached.
|
|
875
|
-
*
|
|
876
|
-
* The furthest is what the single encoder is steered by: it has to serve
|
|
877
|
-
* everyone, and what lies behind the leader has already been produced and is
|
|
878
|
-
* served from disk without a wait. The individual positions exist for the
|
|
879
|
-
* opposite question — whether a particular held request is still wanted —
|
|
880
|
-
* which cannot be answered from a shared field.
|
|
881
|
-
*
|
|
882
|
-
* A REQUEST IS NOT A POSITION. It says the viewer is still here and nothing
|
|
883
|
-
* more: where they are is what they themselves state, on the viewer, and a
|
|
884
|
-
* request cannot reach it. Asking for a segment used to write the position,
|
|
885
|
-
* so two writers filled one field in turn and the priority map jumped
|
|
886
|
-
* backwards several times a second — measured 2026-09-13, 77 encoder starts
|
|
887
|
-
* and 141 stops in six minutes while both viewers sat frozen.
|
|
888
|
-
*
|
|
889
|
-
* A viewer is forgotten once nothing has been heard from them for longer than
|
|
890
|
-
* any silence a watching viewer can produce. The figure is the proxy's own
|
|
891
|
-
* look-ahead, not a chosen interval.
|
|
892
|
-
*
|
|
893
|
-
* @param {HlsSession} session
|
|
894
|
-
* @param {string} consumerId
|
|
895
|
-
* @returns {void}
|
|
896
|
-
*/
|
|
897
|
-
#noteViewerSeen(session, consumerId) {
|
|
898
|
-
// A request that names nobody is evidence about nobody.
|
|
899
|
-
if (!consumerId) {
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
this.#host.viewers.of(session, consumerId).seen();
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/**
|
|
906
|
-
* Whether a held request is for a segment the viewer STILL needs.
|
|
907
|
-
*
|
|
908
|
-
* The epoch alone says a seek happened; it cannot say whether this particular
|
|
909
|
-
* request was made for the position left behind or for the one just arrived
|
|
910
|
-
* at. That distinction is the whole of the failure measured 2026-08-18: the
|
|
911
|
-
* viewer seeked to 1061.0 s, the request for `segment-00101` — the segment AT
|
|
912
|
-
* that position — raced the seek notification, the epoch moved underneath it,
|
|
913
|
-
* and it was answered 503 twice within 80 ms. The player then hunted at
|
|
914
|
-
* sn=105-107, never came back to 101, and looped two audio segments 1473
|
|
915
|
-
* times over 149 s while the picture stood still.
|
|
916
|
-
*
|
|
917
|
-
* A request is stale when its segment lies behind where the viewer now is, or
|
|
918
|
-
* so far ahead that the running encode will not reach it. Anything between is
|
|
919
|
-
* exactly what the viewer is waiting for, and holding it is the point.
|
|
920
|
-
*
|
|
921
|
-
* "So far ahead" is the encoder's own look-ahead, measured on this session's
|
|
922
|
-
* own cut grid — the same figure the browser sizes its forward buffer from.
|
|
923
|
-
* It used to be `MAX_LOOKAHEAD_SEGMENTS`, which is eight segments ahead of
|
|
924
|
-
* the ENCODE HEAD
|
|
925
|
-
* and has nothing to do with how far ahead of the VIEWER a request may
|
|
926
|
-
* legitimately sit; it happened to match a browser holding 30 s, and would
|
|
927
|
-
* have refused three quarters of the requests of one holding the whole
|
|
928
|
-
* cushion (roadmap item 4).
|
|
929
|
-
*
|
|
930
|
-
* Judged against the position of the viewer who MADE the request, when the
|
|
931
|
-
* transport carries who that is. A session is shared by everyone watching a
|
|
932
|
-
* copied picture and the epoch is per session, so a seek by the viewer in
|
|
933
|
-
* front used to release every request being held for the viewer behind them.
|
|
934
|
-
*
|
|
935
|
-
* @param {string} sessionId
|
|
936
|
-
* @param {string} fileName
|
|
937
|
-
* @param {string} [consumerId] - Who is asking. Without it the one shared
|
|
938
|
-
* position decides, which is what a single viewer means anyway.
|
|
939
|
-
* @returns {boolean} True when the request should keep waiting.
|
|
940
|
-
*/
|
|
941
|
-
requestStillWanted(sessionId, fileName, consumerId = "") {
|
|
942
|
-
const session = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
943
|
-
if (!session) {
|
|
944
|
-
return false;
|
|
945
|
-
}
|
|
946
|
-
const index = session.segmentFormat?.segmentIndexFromName?.(fileName) ?? -1;
|
|
947
|
-
if (!(index >= 0)) {
|
|
948
|
-
return true; // a playlist or an init segment belongs to no position
|
|
949
|
-
}
|
|
950
|
-
const position = this.#host.viewerSecondsOn(session, consumerId);
|
|
951
|
-
const at = this.#host.outputTimes.segmentIndexForTime(session, position);
|
|
952
|
-
// The far edge on THIS session's own grid rather than a count of nominal
|
|
953
|
-
// segments: a copied picture is cut at the source's keyframes, so its
|
|
954
|
-
// segments are not four seconds long and dividing by that figure would put
|
|
955
|
-
// the edge somewhere else entirely. The segment CONTAINING the edge is
|
|
956
|
-
// wanted — it is the one the deepest allowed request lands in, and it
|
|
957
|
-
// already reaches past the cushion by whatever is left of its own duration.
|
|
958
|
-
const edge = this.#host.outputTimes.segmentIndexForTime(session, position + this.#host.lookaheadSeconds);
|
|
959
|
-
return index >= at && index <= edge;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/**
|
|
963
|
-
* How many times the viewer has moved since this session started.
|
|
964
|
-
*
|
|
965
|
-
* A request being held for a segment answers "retry" as soon as this changes,
|
|
966
|
-
* because it was made for a position the viewer has left — see `requestSeek`.
|
|
967
|
-
*
|
|
968
|
-
* @param {string} sessionId
|
|
969
|
-
* @returns {number}
|
|
970
|
-
*/
|
|
971
|
-
seekEpoch(sessionId) {
|
|
972
|
-
const session = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
973
|
-
return session ? this.#stateFor(session).waitEpoch : 0;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* Wait for the requested segment to be published, without polling the disk.
|
|
978
|
-
*
|
|
979
|
-
* Ends on the publication, on the waits of this output being invalidated, on
|
|
980
|
-
* the deadline, or when the requester goes — and whichever ends it, the
|
|
981
|
-
* waiter is taken out of the store, so a wait with no deadline cannot outlive
|
|
982
|
-
* the request it was for. Returns false at once for playlists and init files,
|
|
983
|
-
* whose readiness has a different owner.
|
|
984
|
-
*
|
|
985
|
-
* @param {string} sessionId
|
|
986
|
-
* @param {string} fileName
|
|
987
|
-
* @param {number} timeoutMs - May be infinite.
|
|
988
|
-
* @param {Promise<unknown> | null} [requesterGone]
|
|
989
|
-
* @returns {Promise<boolean>} Whether it is worth asking for the file again.
|
|
990
|
-
*/
|
|
991
|
-
async waitForSegment(sessionId, fileName, timeoutMs, requesterGone = null) {
|
|
992
|
-
const session = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
993
|
-
const index = session?.segmentFormat?.segmentIndexFromName?.(fileName) ?? -1;
|
|
994
|
-
if (!session || index < 0) return false;
|
|
995
|
-
const state = this.#stateFor(session);
|
|
996
|
-
let wake;
|
|
997
|
-
const invalidated = new Promise((resolve) => {
|
|
998
|
-
wake = resolve;
|
|
999
|
-
state.waitListeners.add(resolve);
|
|
1000
|
-
});
|
|
1001
|
-
let finished = () => {};
|
|
1002
|
-
const over = new Promise((resolve) => {
|
|
1003
|
-
finished = resolve;
|
|
1004
|
-
});
|
|
1005
|
-
try {
|
|
1006
|
-
return await Promise.race([
|
|
1007
|
-
this.#host.segmentStore.waitFor(session.outputKey ?? "", index, timeoutMs, over),
|
|
1008
|
-
invalidated,
|
|
1009
|
-
...(requesterGone ? [requesterGone.then(() => false)] : [])
|
|
1010
|
-
]);
|
|
1011
|
-
} finally {
|
|
1012
|
-
state.waitListeners.delete(wake);
|
|
1013
|
-
finished();
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
/**
|
|
1018
|
-
* Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
|
|
1019
|
-
* header, or until the session fails, or until the startup timeout elapses.
|
|
1020
|
-
* Throws with message `"HLS playlist is still warming up."` on timeout.
|
|
1021
|
-
*
|
|
1022
|
-
* @param {HlsSession} session
|
|
1023
|
-
* @returns {Promise<void>}
|
|
1024
|
-
*/
|
|
1025
|
-
async waitUntilReady(session) {
|
|
1026
|
-
// With a synthetic VOD playlist there is nothing to wait for: the playlist
|
|
1027
|
-
// is generated from the probed duration and is available immediately.
|
|
1028
|
-
// Individual segments are long-polled by the segment route as ffmpeg
|
|
1029
|
-
// produces them.
|
|
1030
|
-
if (session.useSyntheticPlaylist) {
|
|
1031
|
-
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
1032
|
-
throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
|
|
1033
|
-
}
|
|
1034
|
-
return;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
const playlistPath = path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), PLAYLIST_FILE_NAME);
|
|
1038
|
-
const deadline = Date.now() + this.#host.startupWaitMs;
|
|
1039
|
-
|
|
1040
|
-
while (Date.now() < deadline) {
|
|
1041
|
-
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
1042
|
-
throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
|
|
1043
|
-
}
|
|
1044
|
-
try {
|
|
1045
|
-
await access(playlistPath);
|
|
1046
|
-
const text = await readFile(playlistPath, "utf8");
|
|
1047
|
-
if (text.includes("#EXTM3U")) {
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
} catch (_error) {
|
|
1051
|
-
// Playlist is not ready yet.
|
|
1052
|
-
}
|
|
1053
|
-
await delay(250);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
throw new Error("HLS playlist is still warming up.");
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
/**
|
|
1060
|
-
* Answer the player's report that a delivered fragment sits far from the edge
|
|
1061
|
-
* of its buffer, with the one fact only this side holds: which boundary the
|
|
1062
|
-
* segment of that number really begins at.
|
|
1063
|
-
*
|
|
1064
|
-
* The player can say the gap; it cannot say whether the cause is its own
|
|
1065
|
-
* loading or a run whose output no longer matches its numbering. Here both
|
|
1066
|
-
* are in hand — the time the playlist gave that segment, and, when the
|
|
1067
|
-
* segment has been served, the time it truly began at — so the line either
|
|
1068
|
-
* names a shifted run or clears this side of it.
|
|
1069
|
-
*
|
|
1070
|
-
* Diagnostic only: nothing is repositioned on the strength of a browser's
|
|
1071
|
-
* reading, deliberately, because a wrong answer here would restart an encoder
|
|
1072
|
-
* the viewer is waiting on.
|
|
1073
|
-
*
|
|
1074
|
-
* @param {string} sessionId
|
|
1075
|
-
* @param {{ sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }} report
|
|
1076
|
-
* @returns {boolean} False when no such session exists.
|
|
1077
|
-
*/
|
|
1078
|
-
recordFragmentFar(sessionId, { sn, track, fragStartSec, bufferEndSec, currentTimeSec }) {
|
|
1079
|
-
const named = this.#host.outputs.get(sessionId);
|
|
1080
|
-
if (!named) {
|
|
1081
|
-
return false;
|
|
1082
|
-
}
|
|
1083
|
-
// Which of the two streams the report is about. The browser addresses
|
|
1084
|
-
// everything to the video session's id — the soundtrack is served under
|
|
1085
|
-
// `/a/<n>/` on that same id — but it is a session of its own, with its own
|
|
1086
|
-
// run and its own position, and that is exactly the pair this report exists
|
|
1087
|
-
// to tell apart. Answering an audio report from the picture's records would
|
|
1088
|
-
// state, confidently, something about the wrong stream.
|
|
1089
|
-
const onScreen = this.#host.activeOutputFor({ base: named, outputs: this.#host.outputs });
|
|
1090
|
-
const session = track === "audio"
|
|
1091
|
-
? ([...this.#host.outputs.familyOf(onScreen)].find((member) => member.spec.carries === "audio-only") ?? onScreen)
|
|
1092
|
-
: onScreen;
|
|
1093
|
-
const gap = fragStartSec - bufferEndSec;
|
|
1094
|
-
const declared = this.#host.outputTimes.publishedStartTime(session, sn);
|
|
1095
|
-
const trueStart = this.#host.outputTimes.trueStartAt(session, sn);
|
|
1096
|
-
const verdict = trueStart === undefined
|
|
1097
|
-
// Where a segment truly began is only ever read off one that was cut on
|
|
1098
|
-
// an explicit list — a uniform grid has nothing to read back — so this is
|
|
1099
|
-
// "not recorded", which is not the same as "not produced", and the line
|
|
1100
|
-
// must not claim the second.
|
|
1101
|
-
? "where that segment began is not recorded on this side, so the gap cannot be attributed here"
|
|
1102
|
-
: (() => {
|
|
1103
|
-
const at = this.#host.outputTimes.boundaryIndexAt(session, trueStart, this.#host.publishedGridFor(session));
|
|
1104
|
-
if (at === null) {
|
|
1105
|
-
return `it really began at ${trueStart.toFixed(3)}s, which is no boundary of this grid`;
|
|
1106
|
-
}
|
|
1107
|
-
if (at === sn) {
|
|
1108
|
-
return `it really began at boundary #${sn}, where it should — the gap is not this run's`;
|
|
1109
|
-
}
|
|
1110
|
-
return `it really began at boundary #${at}, ${sn - at} place(s) before its own number — ` +
|
|
1111
|
-
"this run's output does not match its numbering";
|
|
1112
|
-
})();
|
|
1113
|
-
logger.warn(
|
|
1114
|
-
`transcode ${session.id} the player is stuck: ${session.spec.carries === "audio-only" ? "sound" : "picture"} ` +
|
|
1115
|
-
`fragment #${sn} starts ${gap.toFixed(1)}s past ` +
|
|
1116
|
-
`the end of its buffer (${bufferEndSec.toFixed(1)}s, viewer at ${currentTimeSec.toFixed(1)}s, ` +
|
|
1117
|
-
`the playlist puts it at ${declared.toFixed(3)}s) — ${verdict}`
|
|
1118
|
-
);
|
|
1119
|
-
return true;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/**
|
|
1123
|
-
* Every segment number this session's OUTPUT holds, whoever produced it.
|
|
1124
|
-
*
|
|
1125
|
-
* Public because it is the one thing worth asserting about the address
|
|
1126
|
-
* change: two sessions of one output answer with the same list, including
|
|
1127
|
-
* segments the other one's encoder made.
|
|
1128
|
-
*
|
|
1129
|
-
* @param {HlsSession} session
|
|
1130
|
-
* @returns {number[]}
|
|
1131
|
-
*/
|
|
1132
|
-
producedSegmentNumbers(session) {
|
|
1133
|
-
return new Set(this.producedNumbers(session));
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
* What this session's OUTPUT holds, asked of the one thing that owns it.
|
|
1138
|
-
*
|
|
1139
|
-
* There were two owners of this fact over one directory: the store, addressed
|
|
1140
|
-
* by the output's own key, and a `ProducedIndex` built per SESSION over a
|
|
1141
|
-
* path the store had handed out. Two viewers of one output therefore built
|
|
1142
|
-
* two indexes over one directory, each with its own idea of what was in it.
|
|
1143
|
-
*
|
|
1144
|
-
* @param {HlsSession} session
|
|
1145
|
-
* @returns {number[]} Every segment number it holds, in order.
|
|
1146
|
-
*/
|
|
1147
|
-
producedNumbers(session) {
|
|
1148
|
-
return this.#host.segmentStore.provenNumbers(session.outputKey ?? "");
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Answering one request for a file of an output: a playlist, the init segment
|
|
3
|
+
* or a segment.
|
|
4
|
+
*
|
|
5
|
+
* A segment is served from the store when it is made and whole; otherwise the
|
|
6
|
+
* request is held until it is, or until nobody wants it any more, and a hold
|
|
7
|
+
* that lasts says why. A request is an operation on material that exists; where
|
|
8
|
+
* encoders work is the plan's, and a request does not move one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createReadStream } from "node:fs";
|
|
12
|
+
import { access, readFile, stat, unlink } from "node:fs/promises";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { logger } from "../../utils/logger.js";
|
|
16
|
+
import { isOutputName, PLAYLIST_FILE_NAME } from "../encode/output/index.js";
|
|
17
|
+
// The index of variants. Served from the same route as the media playlist, so
|
|
18
|
+
// it needs no path of its own.
|
|
19
|
+
export const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
|
|
20
|
+
// Read segment files in large blocks so the body is delivered to the data
|
|
21
|
+
// channel in few, big chunks. On a busy ARM host the in-process WebTorrent
|
|
22
|
+
// hashing starves the event loop in bursts, so fewer read iterations means
|
|
23
|
+
// far less time lost between chunks while serving the first segments.
|
|
24
|
+
const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Resolve after a given number of milliseconds.
|
|
27
|
+
*
|
|
28
|
+
* @param {number} ms
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
function delay(ms) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
setTimeout(resolve, ms);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Guard against path traversal by restricting file names to the known
|
|
38
|
+
* playlist and segment patterns produced by ffmpeg. Which segment names are
|
|
39
|
+
* legal depends on the active container, so the format decides.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} fileName
|
|
42
|
+
* @param {import("../encode/segment-formats/index.js").SegmentFormat} segmentFormat
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function isSafeFileName(fileName, segmentFormat) {
|
|
46
|
+
return (
|
|
47
|
+
fileName === PLAYLIST_FILE_NAME ||
|
|
48
|
+
fileName === MASTER_PLAYLIST_FILE_NAME ||
|
|
49
|
+
(segmentFormat.initFileName !== null && fileName === segmentFormat.initFileName) ||
|
|
50
|
+
segmentFormat.isSegmentFileName(fileName)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether this output is cut at times we hand the muxer, rather than at a
|
|
55
|
+
* duration it chooses for itself.
|
|
56
|
+
*
|
|
57
|
+
* A property of the output and not of a run: the cut grid and the branch decide
|
|
58
|
+
* it, so every run of one output answers alike. It decides how a segment is
|
|
59
|
+
* judged finished — see getFileStream.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} session
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
function cutsAtGivenTimes(session) {
|
|
65
|
+
const explicit = session?.segmentFormat?.explicitTimesMuxerArgs?.() ?? null;
|
|
66
|
+
if (!explicit) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return !session.spec.transcodesVideo || session.timeline?.cutGrid === "keyframe";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class SegmentServing {
|
|
73
|
+
/** Serving state keyed by the output object it belongs to. */
|
|
74
|
+
#states = new WeakMap();
|
|
75
|
+
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
76
|
+
#host;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} host - `buildMasterPlaylist`, `declaredTracks`, `publishedGridFor`, `runStartTimeFor`, `cushion`, `encodeOrchestrator`, `encodeRuns`, `hostTimings`, `lookaheadSeconds`, `outputTimes`, `outputs`, `segmentStore`, `startupWaitMs`, `viewers`
|
|
80
|
+
*/
|
|
81
|
+
constructor(host) {
|
|
82
|
+
this.#host = host;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#stateFor(output) {
|
|
86
|
+
let state = this.#states.get(output);
|
|
87
|
+
if (!state) {
|
|
88
|
+
state = {
|
|
89
|
+
holdExplainedAt: new Map(),
|
|
90
|
+
waitEpoch: 0,
|
|
91
|
+
waitListeners: new Set()
|
|
92
|
+
};
|
|
93
|
+
this.#states.set(output, state);
|
|
94
|
+
}
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
invalidateWaits(output) {
|
|
99
|
+
const state = this.#stateFor(output);
|
|
100
|
+
state.waitEpoch += 1;
|
|
101
|
+
for (const wake of state.waitListeners) wake(true);
|
|
102
|
+
state.waitListeners.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Open a read stream for an HLS segment or playlist file from a session.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} sessionId
|
|
109
|
+
* @param {string} fileName - Must match the playlist or segment name pattern.
|
|
110
|
+
* @param {{ consumerId?: string }} [options] -
|
|
111
|
+
* `consumerId` says WHICH viewer is asking, so a session
|
|
112
|
+
* shared by several of them can tell their positions apart; absent from a
|
|
113
|
+
* browser or a transport that does not carry it, and then everything falls
|
|
114
|
+
* back to the one shared position.
|
|
115
|
+
* @returns {Promise<
|
|
116
|
+
* | { kind: "not-found" }
|
|
117
|
+
* | { kind: "warming-up" }
|
|
118
|
+
* | { kind: "failed"; message: string }
|
|
119
|
+
* | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
|
|
120
|
+
* >}
|
|
121
|
+
*/
|
|
122
|
+
async getFileStream(sessionId, fileName, options = {}) {
|
|
123
|
+
const consumerId = typeof options.consumerId === "string" ? options.consumerId : "";
|
|
124
|
+
if (!isOutputName(sessionId)) {
|
|
125
|
+
return { kind: "not-found" };
|
|
126
|
+
}
|
|
127
|
+
const session = this.#host.outputs.get(sessionId);
|
|
128
|
+
// The session is looked up BEFORE the name is validated, because what
|
|
129
|
+
// counts as a valid segment name depends on the container this session
|
|
130
|
+
// chose — `.mp4` for fMP4, `.ts` for MPEG-TS.
|
|
131
|
+
if (!session || !isSafeFileName(fileName, session.segmentFormat)) {
|
|
132
|
+
return { kind: "not-found" };
|
|
133
|
+
}
|
|
134
|
+
// WHETHER PRODUCTION FAILED IS NOT ASKED HERE. It explains a piece that is
|
|
135
|
+
// ABSENT and says nothing about one that is on disk: a segment under its
|
|
136
|
+
// served name is whole by construction, whoever wrote it and whatever has
|
|
137
|
+
// become of the encoder since. Asked at the door, as it was, a failed run
|
|
138
|
+
// answered 500 to every request for material it had already finished — the
|
|
139
|
+
// viewer lost what was made as well as what was not, and a seek back into
|
|
140
|
+
// it could not be served either. It is asked where the answer is needed:
|
|
141
|
+
// where the file is not there (`#holdForProduction`, and the init's own
|
|
142
|
+
// absent branch).
|
|
143
|
+
this.#host.outputs.touch(session);
|
|
144
|
+
|
|
145
|
+
// The index of variants. Served from here rather than a route of its own,
|
|
146
|
+
// because to a player it is simply another playlist under the session.
|
|
147
|
+
if (fileName === MASTER_PLAYLIST_FILE_NAME) {
|
|
148
|
+
const masterText = this.#host.buildMasterPlaylist(sessionId, consumerId);
|
|
149
|
+
if (!masterText) {
|
|
150
|
+
return { kind: "not-found" };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
kind: "file",
|
|
154
|
+
stream: Readable.from([masterText]),
|
|
155
|
+
contentType: "application/vnd.apple.mpegurl",
|
|
156
|
+
isPlaylist: true
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Serve the synthetic VOD playlist (full duration, terminated with
|
|
161
|
+
// #EXT-X-ENDLIST) so the player gets the correct total length and a fully
|
|
162
|
+
// seekable timeline up-front, independent of how far ffmpeg has encoded.
|
|
163
|
+
if (fileName === PLAYLIST_FILE_NAME && session.useSyntheticPlaylist) {
|
|
164
|
+
return {
|
|
165
|
+
kind: "file",
|
|
166
|
+
stream: Readable.from([session.playlistText]),
|
|
167
|
+
contentType: "application/vnd.apple.mpegurl",
|
|
168
|
+
isPlaylist: true
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The init segment (fMP4 only; referenced by #EXT-X-MAP). Each seek-restart
|
|
173
|
+
// run REWRITES it, so cache the FIRST one and always serve that — the
|
|
174
|
+
// player fetches it once and never re-fetches, so it must stay stable for
|
|
175
|
+
// the session's lifetime. (What that costs, and why segments must therefore
|
|
176
|
+
// carry their own position, is documented in `encode/segment-formats/mp4-boxes.js`
|
|
177
|
+
// `stampSegmentStartTime`.)
|
|
178
|
+
//
|
|
179
|
+
// ffmpeg creates init.mp4 before it has finished writing the fMP4 header
|
|
180
|
+
// boxes into it (unlike segments, its write is not gated behind an atomic
|
|
181
|
+
// rename), so a read can race a moment where the file EXISTS but is still
|
|
182
|
+
// EMPTY. Root cause of a real incident: that empty read used to be cached
|
|
183
|
+
// as the output's init — a zero-length Buffer is still a truthy object,
|
|
184
|
+
// so a check on its presence treated it as "already resolved" and served
|
|
185
|
+
// the empty file for the rest of the session's life, permanently breaking
|
|
186
|
+
// playback (hls.js can never initialize its SourceBuffer from an empty
|
|
187
|
+
// init segment) while the transcode itself kept encoding normally. Guard
|
|
188
|
+
// on non-empty content on both the cache check and the fresh read, so an
|
|
189
|
+
// empty read is treated as not-yet-ready and the caller's long-poll keeps
|
|
190
|
+
// retrying until ffmpeg has actually written the header.
|
|
191
|
+
const { initFileName } = session.segmentFormat;
|
|
192
|
+
if (initFileName !== null && fileName === initFileName) {
|
|
193
|
+
const kept = this.#host.segmentStore.initOf(session.outputKey ?? "");
|
|
194
|
+
if (kept) {
|
|
195
|
+
return {
|
|
196
|
+
kind: "file",
|
|
197
|
+
stream: Readable.from([kept]),
|
|
198
|
+
contentType: session.segmentFormat.initContentType,
|
|
199
|
+
isPlaylist: false
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
// With explicit cut times there is no init file: that muxer writes each
|
|
204
|
+
// piece self-contained, header and all. The header is identical in every
|
|
205
|
+
// piece, so the first one to exist supplies it.
|
|
206
|
+
const bytes = cutsAtGivenTimes(session)
|
|
207
|
+
? await this.#initFromFirstSegment(session)
|
|
208
|
+
: await readFile(path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), initFileName));
|
|
209
|
+
const standing = this.#host.segmentStore.keepInit(session.outputKey ?? "", bytes);
|
|
210
|
+
if (!standing) {
|
|
211
|
+
// Nothing usable yet. On the branch that lifts the header out of the
|
|
212
|
+
// first piece there is no file to be missing, so "not produced yet"
|
|
213
|
+
// arrives HERE and not in the catch below: `#initFromFirstSegment`
|
|
214
|
+
// answers null rather than throwing `ENOENT`. A production that has
|
|
215
|
+
// ended in an error is not going to produce one either, and saying
|
|
216
|
+
// "still warming up" to that holds the request for its whole deadline.
|
|
217
|
+
return this.#failedOrWarming(session);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
kind: "file",
|
|
221
|
+
stream: Readable.from([standing]),
|
|
222
|
+
contentType: session.segmentFormat.initContentType,
|
|
223
|
+
isPlaylist: false
|
|
224
|
+
};
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error?.code === "ENOENT") {
|
|
227
|
+
// Not produced yet — the encode run started at session creation
|
|
228
|
+
// writes it early; the caller long-polls until it appears. Unless
|
|
229
|
+
// production has ended in an error, in which case nothing is going to
|
|
230
|
+
// write it and holding the request only spends the viewer's patience.
|
|
231
|
+
return this.#failedOrWarming(session);
|
|
232
|
+
}
|
|
233
|
+
logger.error(
|
|
234
|
+
`transcode ${session.id} could not serve ${initFileName}: ${error?.message ?? error}` +
|
|
235
|
+
(error?.stack ? `\n${error.stack}` : "")
|
|
236
|
+
);
|
|
237
|
+
return {
|
|
238
|
+
kind: "failed",
|
|
239
|
+
message: `Could not serve ${initFileName}: ${error?.message ?? String(error)}`
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Which run's copy answers, when several have written this name. Chosen by
|
|
245
|
+
// what the copies CARRY, not by which run is newest — see #chooseProducedCopy.
|
|
246
|
+
const filePath = this.#host.segmentStore.pathOfName(session.outputKey ?? "", fileName) ??
|
|
247
|
+
path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), fileName);
|
|
248
|
+
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
249
|
+
if (!isPlaylist) {
|
|
250
|
+
// A REQUEST SAYS THE VIEWER IS HERE, AND NOTHING ELSE. It does not say
|
|
251
|
+
// where they are — that is what they state themselves — and it steers no
|
|
252
|
+
// encoder: the segment either exists and is served, or does not and is
|
|
253
|
+
// waited for.
|
|
254
|
+
const requested = session.segmentFormat.segmentIndexFromName(fileName);
|
|
255
|
+
if (requested >= 0) {
|
|
256
|
+
this.#noteViewerSeen(session, consumerId);
|
|
257
|
+
// A viewer who has caught up must not wait out the monitor's interval —
|
|
258
|
+
// but only if they HAVE caught up, which is why this re-evaluates the
|
|
259
|
+
// same condition instead of resuming outright.
|
|
260
|
+
this.#host.cushion.reportCushionFor(session);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Whether the file is there is asked on its own, and nothing else shares
|
|
264
|
+
// this catch. Everything below is PREPARATION of a file that exists, and a
|
|
265
|
+
// failure there means something entirely different from "not produced yet"
|
|
266
|
+
// — but for one release the two were caught together, so an undeclared name
|
|
267
|
+
// in the fMP4 path read as "the segment is not ready". Every poll threw the
|
|
268
|
+
// same ReferenceError, every poll answered "wait", and playback never began
|
|
269
|
+
// on any file cut at keyframes (2.9.124; measured 2026-08-08: segment #0
|
|
270
|
+
// held for 45 281 ms with twelve finished segments on disk).
|
|
271
|
+
try {
|
|
272
|
+
await access(filePath);
|
|
273
|
+
} catch {
|
|
274
|
+
// Not produced yet.
|
|
275
|
+
return this.#holdForProduction(session, fileName, isPlaylist, options);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
// Existing is not the same as finished. The `hls` muxer wrote each
|
|
279
|
+
// segment to a temporary name and renamed it once complete, so a file
|
|
280
|
+
// appearing WAS a finished segment. The `segment` muxer has no such
|
|
281
|
+
// option: the file appears when writing begins. Serving it then hands the
|
|
282
|
+
// player a truncated segment, which it rejects and then simply stops —
|
|
283
|
+
// observed as playback dying a few seconds in with the encoder still
|
|
284
|
+
// running happily ahead. A segment is finished once the NEXT one has been
|
|
285
|
+
// started, or once the run producing it has ended.
|
|
286
|
+
if (!isPlaylist && cutsAtGivenTimes(session)) {
|
|
287
|
+
// WHAT PROVES A PIECE IS WHOLE is the encoder's own word for it: it
|
|
288
|
+
// names each file on a channel of its own the moment it closes it, and
|
|
289
|
+
// the store keeps those names.
|
|
290
|
+
//
|
|
291
|
+
// What stood here instead was the existence of the NEXT file, with two
|
|
292
|
+
// exceptions bolted on because it is not true. It is never true of the
|
|
293
|
+
// last piece of a run — nothing is producing a next one — so the first
|
|
294
|
+
// segment of every run was held: measured 2026-08-09, #807 held while
|
|
295
|
+
// it lay on disk, and in August the same shape held #317 for 46 seconds
|
|
296
|
+
// and then answered 404 to a browser that had given up.
|
|
297
|
+
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
298
|
+
if (!this.#host.segmentStore.isClosed(session.outputKey ?? "", index)) {
|
|
299
|
+
this.#explainHold(session, fileName, "the encoder has not closed it yet");
|
|
300
|
+
return { kind: "warming-up" };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Cold-start: the time from the create request to a playable first
|
|
305
|
+
// segment, measured once per output by the owner of host timings.
|
|
306
|
+
const coldStartMs = isPlaylist ? null : this.#host.hostTimings.noteSegmentServed(session);
|
|
307
|
+
if (coldStartMs !== null) {
|
|
308
|
+
// Data is flowing again, so the next loss starts its backoff afresh
|
|
309
|
+
// rather than inheriting the delay of the last one.
|
|
310
|
+
this.#host.encodeRuns.resetInputRetry(session);
|
|
311
|
+
logger.info(`cold-start ${sessionId.slice(0, 8)}: first-segment ready +${coldStartMs}ms`);
|
|
312
|
+
}
|
|
313
|
+
// Formats whose segments need correcting before they are valid against
|
|
314
|
+
// the session's cached init are read whole and passed through the format
|
|
315
|
+
// module; the rest stream straight off disk.
|
|
316
|
+
if (!isPlaylist && session.segmentFormat.needsSegmentRewrite) {
|
|
317
|
+
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
318
|
+
const raw = await readFile(filePath);
|
|
319
|
+
// Self-contained pieces carry the init header; a media segment must not.
|
|
320
|
+
const bytes = cutsAtGivenTimes(session) && session.segmentFormat.stripInit
|
|
321
|
+
? session.segmentFormat.stripInit(raw)
|
|
322
|
+
: raw;
|
|
323
|
+
// WHOLE OR SHORT IS A JUDGEMENT ABOUT BYTES, and the format that
|
|
324
|
+
// knows how to read them makes it (`judgeTracks`). What is done about
|
|
325
|
+
// the answer is this path's business and stays here: a whole piece is
|
|
326
|
+
// served, a short one is removed so it can be made again.
|
|
327
|
+
const verdict = session.segmentFormat.judgeTracks?.(raw, bytes, this.#host.segmentStore.initOf(session.outputKey ?? "")) ?? null;
|
|
328
|
+
if (verdict && !verdict.whole) {
|
|
329
|
+
logger.warn(
|
|
330
|
+
`transcode ${session.id} segment #${index} is short of a track — ` +
|
|
331
|
+
`${filePath}, ${raw.length} bytes on disk, ${bytes.length} of body, ` +
|
|
332
|
+
`${verdict.fragmentTracks} track(s) in its fragments against ` +
|
|
333
|
+
`${verdict.sessionTracks} the session's header declares and ` +
|
|
334
|
+
`${verdict.ownTracks} its own declares — ${verdict.because}`
|
|
335
|
+
);
|
|
336
|
+
if (!verdict.serve) {
|
|
337
|
+
try {
|
|
338
|
+
await unlink(filePath);
|
|
339
|
+
} catch {
|
|
340
|
+
// Already gone: either way nothing to do.
|
|
341
|
+
}
|
|
342
|
+
// What the store remembers of this directory is stale the moment a
|
|
343
|
+
// file is taken out of it.
|
|
344
|
+
this.#host.segmentStore.forget(session.outputKey ?? "");
|
|
345
|
+
return { kind: "warming-up" };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Where this segment REALLY begins, taken from the piece itself, and
|
|
349
|
+
// only from the playlist when the piece does not say.
|
|
350
|
+
//
|
|
351
|
+
// The playlist's own answer is built from the container's keyframe
|
|
352
|
+
// index, and an index can be wrong: measured 2026-08-06 on a Matroska
|
|
353
|
+
// file whose index claimed a keyframe at 157.99 s where the real ones
|
|
354
|
+
// were 153.82 and 164.247. ffmpeg cut at 153.82, and stamping that
|
|
355
|
+
// picture with 157.99 told the player it belonged four seconds later
|
|
356
|
+
// than it did — while subtitles, extracted straight from the source,
|
|
357
|
+
// kept the true times. Speech and text drifted apart by 4.17 s.
|
|
358
|
+
//
|
|
359
|
+
// Read from `raw`, before the header is stripped: the position lives
|
|
360
|
+
// in an empty edit in the piece's own `moov`, which `stripInit`
|
|
361
|
+
// removes. Identical to the playlist's figure whenever the index is
|
|
362
|
+
// honest, so nothing changes for a well-formed file.
|
|
363
|
+
const trueStart = cutsAtGivenTimes(session)
|
|
364
|
+
? session.segmentFormat.readSegmentStartSeconds?.(raw) ?? null
|
|
365
|
+
: null;
|
|
366
|
+
const declaredStart = this.#host.outputTimes.segmentStartTime(session, index);
|
|
367
|
+
if (trueStart !== null) {
|
|
368
|
+
this.#host.outputTimes.noteRunLanding(session, index, trueStart);
|
|
369
|
+
this.#host.outputTimes.noteIndexAccuracy(session, index, trueStart, declaredStart);
|
|
370
|
+
}
|
|
371
|
+
// WHERE THE PLAYER WAS TOLD THIS SEGMENT BEGINS, which is the playlist
|
|
372
|
+
// it holds and nothing else. The published text is fixed when the
|
|
373
|
+
// session is created; `#segmentStartTime` reads a table that a
|
|
374
|
+
// correction may since have moved, and a stamp taken from the moved
|
|
375
|
+
// table describes a timeline the player has never seen.
|
|
376
|
+
const publishedStart = this.#host.outputTimes.publishedStartTime(session, index);
|
|
377
|
+
// A player places a fragment by the playlist. If the bytes claim a
|
|
378
|
+
// different position, the fragment does not land where the fragment was
|
|
379
|
+
// expected, hls.js finds the range still unbuffered and asks for the
|
|
380
|
+
// same fragment again — for ever. Measured 2026-08-17: a seek to
|
|
381
|
+
// 1590.4 s produced audio segments #292/#293 whose own timeline said
|
|
382
|
+
// 1587.892 and 1592.692 against a playlist saying 1585.376 and
|
|
383
|
+
// 1590.585, and the browser fetched those two segments 1908 times each
|
|
384
|
+
// over ten minutes, every one of them served in 4 ms. The film was dead
|
|
385
|
+
// and no line said why.
|
|
386
|
+
//
|
|
387
|
+
// So the stamp follows the playlist whenever the two disagree by more
|
|
388
|
+
// than a player will bridge. hls.js bridges up to `maxBufferHole`,
|
|
389
|
+
// which it defaults to 0.5 s — that is the player's own published
|
|
390
|
+
// figure, not a number chosen here. Within it the file's own position
|
|
391
|
+
// is kept, because it is the honest one and it is what keeps speech and
|
|
392
|
+
// subtitles together on a file whose index is slightly out (2026-08-06,
|
|
393
|
+
// 4.17 s of drift on a Matroska index that lied).
|
|
394
|
+
// STAMPED WITH ITS OWN TRUE START, always. Two attempts at moving it
|
|
395
|
+
// toward the playlist both made things worse, and the reason is in what
|
|
396
|
+
// the first segment of a run is: it is not CUT at all — it begins where
|
|
397
|
+
// ffmpeg's seek landed. The picture must land on a keyframe; the sound
|
|
398
|
+
// needs none and starts at the instant asked for. So after every
|
|
399
|
+
// restart the two runs genuinely begin at different real times, and the
|
|
400
|
+
// whole run carries that difference (field 2026-08-17: the sound's
|
|
401
|
+
// #292 began at 1587.892 s and #293 at 1592.692 s — exactly one segment
|
|
402
|
+
// apart, the whole run shifted 2.5 s from the grid).
|
|
403
|
+
//
|
|
404
|
+
// Labelling each track with its own true time is therefore what keeps
|
|
405
|
+
// picture and sound together in real time. Moving them onto the
|
|
406
|
+
// published grid — separately (2.24.1) or by one family offset (2.25.0)
|
|
407
|
+
// — closes a gap that is real and opens one that is not: it desynced
|
|
408
|
+
// playback in the field within the hour, twice.
|
|
409
|
+
//
|
|
410
|
+
// What that leaves unsolved is the reason those attempts were made: a
|
|
411
|
+
// playlist that disagrees with the media by more than a player bridges
|
|
412
|
+
// makes hls.js refetch the same fragment for ever (1908 times each for
|
|
413
|
+
// two segments, measured). The answer to THAT is to make the published
|
|
414
|
+
// grid agree with where the runs really begin — not to relabel the
|
|
415
|
+
// media. Recorded as its own roadmap item rather than guessed at here.
|
|
416
|
+
const stampStart = trueStart ?? publishedStart;
|
|
417
|
+
if (trueStart !== null) {
|
|
418
|
+
this.#host.outputTimes.notePlaylistDisagreement(session, index, trueStart, publishedStart);
|
|
419
|
+
}
|
|
420
|
+
const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
|
|
421
|
+
startSeconds: stampStart,
|
|
422
|
+
rawBytes: raw,
|
|
423
|
+
initBytes: this.#host.segmentStore.initOf(session.outputKey ?? "")
|
|
424
|
+
});
|
|
425
|
+
this.#host.encodeRuns.noteRunProducedSegment(session, filePath);
|
|
426
|
+
return {
|
|
427
|
+
kind: "file",
|
|
428
|
+
stream: Readable.from([prepared]),
|
|
429
|
+
contentType: session.segmentFormat.segmentContentType,
|
|
430
|
+
isPlaylist: false
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (!isPlaylist) {
|
|
434
|
+
this.#host.encodeRuns.noteRunProducedSegment(session, filePath);
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
kind: "file",
|
|
438
|
+
stream: isPlaylist
|
|
439
|
+
? createReadStream(filePath)
|
|
440
|
+
: createReadStream(filePath, { highWaterMark: SEGMENT_READ_HIGH_WATER_MARK }),
|
|
441
|
+
contentType: isPlaylist
|
|
442
|
+
? "application/vnd.apple.mpegurl"
|
|
443
|
+
: session.segmentFormat.segmentContentType,
|
|
444
|
+
isPlaylist
|
|
445
|
+
};
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (error?.code === "ENOENT") {
|
|
448
|
+
// The file went away between the check and the read — a leftover being
|
|
449
|
+
// removed so it can be produced again. Means exactly what never having
|
|
450
|
+
// existed means.
|
|
451
|
+
return this.#holdForProduction(session, fileName, isPlaylist, options);
|
|
452
|
+
}
|
|
453
|
+
// Anything else is a fault in producing the answer. Name it: a request
|
|
454
|
+
// answered "wait" for ever tells the viewer nothing and leaves no trace
|
|
455
|
+
// of what actually happened.
|
|
456
|
+
logger.error(
|
|
457
|
+
`transcode ${session.id} could not serve ${fileName}: ${error?.message ?? error}` +
|
|
458
|
+
(error?.stack ? `\n${error.stack}` : "")
|
|
459
|
+
);
|
|
460
|
+
return {
|
|
461
|
+
kind: "failed",
|
|
462
|
+
message: `Could not serve ${fileName}: ${error?.message ?? String(error)}`
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Whether a piece already given to a viewer can still be served from what is
|
|
469
|
+
* stored under the key of the output that made it, after that output has
|
|
470
|
+
* gone.
|
|
471
|
+
*
|
|
472
|
+
* WHY THIS EXISTS (roadmap item 97, step 11). A repeat of an address must be
|
|
473
|
+
* answered by what answered it the first time: the player holds that
|
|
474
|
+
* output's header, and a piece of another output under the same address may
|
|
475
|
+
* not decode under it. When the output itself has been disposed, its pieces
|
|
476
|
+
* and its header stay in the store until the disk needs the room — so the
|
|
477
|
+
* very bytes that were given can be given again.
|
|
478
|
+
*
|
|
479
|
+
* @param {string} goneKey
|
|
480
|
+
* @param {string} fileName
|
|
481
|
+
* @returns {boolean}
|
|
482
|
+
*/
|
|
483
|
+
hasStoredPiece(goneKey, fileName) {
|
|
484
|
+
const format = this.#host.segmentFormatOfKey(goneKey);
|
|
485
|
+
if (!format) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
if (format.initFileName !== null && fileName === format.initFileName) {
|
|
489
|
+
return Boolean(this.#host.segmentStore.initOf(goneKey));
|
|
490
|
+
}
|
|
491
|
+
if (!format.isSegmentFileName(fileName)) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
const index = format.segmentIndexFromName(fileName);
|
|
495
|
+
return this.#host.segmentStore.isClosed(goneKey, index) &&
|
|
496
|
+
Boolean(this.#host.segmentStore.pathOfName(goneKey, fileName));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* That stored piece, prepared exactly as it would have been served while its
|
|
501
|
+
* output lived: its header stripped where the piece carries one, and stamped
|
|
502
|
+
* with where it truly begins or, where it does not say, where the playlist
|
|
503
|
+
* the player holds puts it.
|
|
504
|
+
*
|
|
505
|
+
* `like` is the picture the address belongs to. Every output interchangeable
|
|
506
|
+
* with it publishes one timeline, so its playlist is the gone output's too.
|
|
507
|
+
*
|
|
508
|
+
* @param {string} goneKey
|
|
509
|
+
* @param {string} likeId - The picture the address belongs to.
|
|
510
|
+
* @param {string} fileName
|
|
511
|
+
* @returns {Promise<{ kind: "file", stream: Readable, contentType: string, isPlaylist: false } | null>}
|
|
512
|
+
*/
|
|
513
|
+
async storedPieceOf(goneKey, likeId, fileName) {
|
|
514
|
+
const like = isOutputName(likeId) ? this.#host.outputs.get(likeId) : null;
|
|
515
|
+
const format = this.#host.segmentFormatOfKey(goneKey);
|
|
516
|
+
const spec = this.#host.specOfKey(goneKey);
|
|
517
|
+
if (!format || !spec || !like || !this.hasStoredPiece(goneKey, fileName)) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
const answer = (bytes, contentType) => ({
|
|
521
|
+
kind: "file",
|
|
522
|
+
stream: Readable.from([bytes]),
|
|
523
|
+
contentType,
|
|
524
|
+
isPlaylist: false
|
|
525
|
+
});
|
|
526
|
+
if (format.initFileName !== null && fileName === format.initFileName) {
|
|
527
|
+
return answer(this.#host.segmentStore.initOf(goneKey), format.initContentType);
|
|
528
|
+
}
|
|
529
|
+
const index = format.segmentIndexFromName(fileName);
|
|
530
|
+
let raw;
|
|
531
|
+
try {
|
|
532
|
+
raw = await readFile(this.#host.segmentStore.pathOfName(goneKey, fileName));
|
|
533
|
+
} catch {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
if (!format.needsSegmentRewrite) {
|
|
537
|
+
return answer(raw, format.segmentContentType);
|
|
538
|
+
}
|
|
539
|
+
const view = { segmentFormat: format, spec, timeline: like.timeline };
|
|
540
|
+
const selfContained = cutsAtGivenTimes(view);
|
|
541
|
+
const bytes = selfContained && format.stripInit ? format.stripInit(raw) : raw;
|
|
542
|
+
const trueStart = selfContained ? format.readSegmentStartSeconds?.(raw) ?? null : null;
|
|
543
|
+
const prepared = format.prepareSegmentBytes(bytes, {
|
|
544
|
+
startSeconds: trueStart ?? this.#host.outputTimes.publishedStartTime(like, index),
|
|
545
|
+
rawBytes: raw,
|
|
546
|
+
initBytes: this.#host.segmentStore.initOf(goneKey)
|
|
547
|
+
});
|
|
548
|
+
logger.info(`[hold] ${fileName} served from the stored pieces of ${goneKey}, the output that first answered it`);
|
|
549
|
+
return answer(prepared, format.segmentContentType);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Whether a piece of `liveKey` may stand where a piece of `goneKey` was
|
|
554
|
+
* given: the two headers compared by the product's own rule
|
|
555
|
+
* (`init-compat.js`), which refuses anything it cannot show a decoder
|
|
556
|
+
* ignores. A header not yet made is no proof, and so is a refusal.
|
|
557
|
+
*
|
|
558
|
+
* @param {string} goneKey
|
|
559
|
+
* @param {string} liveKey
|
|
560
|
+
* @returns {{ compatible: boolean, differences: string[] }}
|
|
561
|
+
*/
|
|
562
|
+
headersCompatible(goneKey, liveKey) {
|
|
563
|
+
const verdict = this.#host.compareInits(
|
|
564
|
+
this.#host.segmentStore.initOf(goneKey),
|
|
565
|
+
this.#host.segmentStore.initOf(liveKey)
|
|
566
|
+
);
|
|
567
|
+
return { compatible: verdict.compatible === true, differences: verdict.differences ?? [] };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Say WHY a segment is being held, at most once every few seconds per file.
|
|
572
|
+
*
|
|
573
|
+
* A hold is silent today, and that silence has now cost three releases: a
|
|
574
|
+
* file that exists, a route that answers "not yet", and nothing anywhere
|
|
575
|
+
* saying which of the several reasons applied. Measured 2026-08-09: a run
|
|
576
|
+
* begun mid-file at segment #317 produced two minutes of video from #317
|
|
577
|
+
* upwards at 10.5x, and #317 itself was held 46 s and then answered 404 once
|
|
578
|
+
* the browser had given up — with not one line about the cause.
|
|
579
|
+
*
|
|
580
|
+
* @param {HlsSession} session
|
|
581
|
+
* @param {string} fileName
|
|
582
|
+
* @param {string} reason
|
|
583
|
+
* @returns {void}
|
|
584
|
+
*/
|
|
585
|
+
#explainHold(session, fileName, reason) {
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
const state = this.#stateFor(session);
|
|
588
|
+
const last = state.holdExplainedAt.get(fileName) ?? 0;
|
|
589
|
+
if (now - last < 5_000) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
state.holdExplainedAt.set(fileName, now);
|
|
593
|
+
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
594
|
+
// What the encoder has actually DONE since it restarted. "Alive at the right
|
|
595
|
+
// index" was as far as the old line went, and it left the two possible
|
|
596
|
+
// causes indistinguishable: an encoder waiting for torrent pieces looks
|
|
597
|
+
// exactly like one that is encoding and simply has not finished. The
|
|
598
|
+
// difference is whether its position has moved at all.
|
|
599
|
+
// Where this run began, from the run — the same reckoning
|
|
600
|
+
// `processedSeconds` is counted in. A table lookup here can disagree with
|
|
601
|
+
// it by the distance between the two grids, which is enough to print a
|
|
602
|
+
// negative "produced" and send the reader after the torrent when the
|
|
603
|
+
// encoder is the subject.
|
|
604
|
+
const progress = this.#host.encodeRuns.progressOf(session, index);
|
|
605
|
+
const runStartSeconds = Number.isFinite(progress?.startPositionSeconds)
|
|
606
|
+
? progress.startPositionSeconds
|
|
607
|
+
: this.#host.runStartTimeFor(session, this.#host.encodeRuns.earliestStartOf(session) ?? 0);
|
|
608
|
+
const position = Number(progress?.processedSeconds);
|
|
609
|
+
const produced = Number.isFinite(position) ? position - runStartSeconds : null;
|
|
610
|
+
const speed = progress?.speed || "n/a";
|
|
611
|
+
logger.warn(
|
|
612
|
+
`transcode ${session.id} holding ${fileName}: ${reason} ` +
|
|
613
|
+
`(runs from #${this.#host.encodeRuns.earliestStartOf(session) ?? "?"}, viewer at #${this.#host.outputTimes.segmentIndexForTime(session, this.#host.viewerSecondsOn(session))}, ` +
|
|
614
|
+
`encoder ${this.#host.encodeRuns.liveRunsOf(session).length > 0 ? "alive" : "stopped"}, index #${index}, ` +
|
|
615
|
+
`produced ${produced === null ? "nothing yet — no position reported" : `${produced.toFixed(1)}s`} ` +
|
|
616
|
+
`at ${speed}${produced !== null && produced <= 0 ? " — the encoder has not moved, so it is waiting on its input" : ""})`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* What an absent file answers when production has failed.
|
|
622
|
+
*
|
|
623
|
+
* @param {object} session
|
|
624
|
+
* @returns {{ kind: "failed", message: string } | { kind: "warming-up" }}
|
|
625
|
+
*/
|
|
626
|
+
#failedOrWarming(session) {
|
|
627
|
+
if (!this.#host.encodeRuns.hasFailed(session)) {
|
|
628
|
+
return { kind: "warming-up" };
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
kind: "failed",
|
|
632
|
+
message: this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed for this transcode session."
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
#holdForProduction(session, fileName, isPlaylist, options) {
|
|
637
|
+
// The file is not there and production has ended in an error: nothing is
|
|
638
|
+
// going to write it, so the viewer is told instead of held.
|
|
639
|
+
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
640
|
+
return this.#failedOrWarming(session);
|
|
641
|
+
}
|
|
642
|
+
if (this.#host.encodeRuns.isWaitingForInput(session)) {
|
|
643
|
+
// The data went away and is being fetched again. Holding the request is
|
|
644
|
+
// the truthful answer: nothing is broken and there is nothing for the
|
|
645
|
+
// viewer to retry. Only what is NOT on disk is held for it — a piece that
|
|
646
|
+
// exists is whole, and it is served whatever the encoder is doing.
|
|
647
|
+
return { kind: "warming-up" };
|
|
648
|
+
}
|
|
649
|
+
/** @type {{ address: string, rank: number, topRank: number } | null} */
|
|
650
|
+
let ranked = null;
|
|
651
|
+
if (!isPlaylist) {
|
|
652
|
+
this.#explainHold(session, fileName, "the file is not on disk");
|
|
653
|
+
}
|
|
654
|
+
// A segment was requested that ffmpeg has not produced yet. Decide whether
|
|
655
|
+
// to wait for the current encode run to reach it or to restart the encoder
|
|
656
|
+
// at this position (server-side seeking). The caller long-polls.
|
|
657
|
+
if (!isPlaylist) {
|
|
658
|
+
const requestedIndex = session.segmentFormat.segmentIndexFromName(fileName);
|
|
659
|
+
// Unanswerable, and known to be: behind a run that only moves forward,
|
|
660
|
+
// too far behind for the repair to fetch it, and no seek on its way to
|
|
661
|
+
// move the encoder there. Holding it changes nothing about whether it can
|
|
662
|
+
// be produced — it only spends the player's patience.
|
|
663
|
+
//
|
|
664
|
+
// This is what a track change costs when it is held instead: measured
|
|
665
|
+
// 2026-08-15, hls.js asked the new track for segment #0 while the run was
|
|
666
|
+
// at #354, the request was held for the full minute, and only when it
|
|
667
|
+
// failed did the player move to the segment it actually needed — 63 s of
|
|
668
|
+
// spinner after a track that had been made ready in 7.
|
|
669
|
+
//
|
|
670
|
+
// WHETHER ANYBODY IS COMING FOR IT, asked of the encoding (`rankAt`,
|
|
671
|
+
// which also tells "in nobody's zone" from "no map yet"). The same walk
|
|
672
|
+
// was written out here and could answer only yes or no, so the RANK was
|
|
673
|
+
// discarded at the one point where a viewer measurably waits for a named
|
|
674
|
+
// segment; it goes back out with the answer, because the wait is measured
|
|
675
|
+
// by whoever holds the request.
|
|
676
|
+
const address = session.outputKey ?? "";
|
|
677
|
+
const { rank, topRank } = this.#host.encodeOrchestrator.rankAt(address, requestedIndex);
|
|
678
|
+
ranked = { address, rank, topRank };
|
|
679
|
+
const nobodyIsComing = topRank > 0 && rank === 0;
|
|
680
|
+
if (
|
|
681
|
+
Number.isFinite(requestedIndex) &&
|
|
682
|
+
requestedIndex < (this.#host.encodeRuns.earliestStartOf(session) ?? 0) &&
|
|
683
|
+
nobodyIsComing &&
|
|
684
|
+
this.#host.encodeRuns.liveRunsOf(session).length > 0
|
|
685
|
+
) {
|
|
686
|
+
logger.info(
|
|
687
|
+
`transcode ${session.id} segment #${requestedIndex} is ${(this.#host.encodeRuns.earliestStartOf(session) ?? 0) - requestedIndex} ` +
|
|
688
|
+
"segments behind the run and in nobody's zone; answered as absent rather than held"
|
|
689
|
+
);
|
|
690
|
+
return { kind: "not-found", ranked };
|
|
691
|
+
}
|
|
692
|
+
this.#noteWanted(session, requestedIndex);
|
|
693
|
+
}
|
|
694
|
+
return { kind: "warming-up", ranked };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Record that a segment nobody has made yet is wanted, and say so when it lies
|
|
699
|
+
* behind every encoder of this output.
|
|
700
|
+
*
|
|
701
|
+
* A REQUEST STEERS NO ENCODER. This used to be where one did: a request far
|
|
702
|
+
* from the encode head restarted ffmpeg there, and in the field one seek
|
|
703
|
+
* produced nine restarts in a minute, because a player holds a couple of
|
|
704
|
+
* dozen requests open at once and none of them is "the one the viewer ended
|
|
705
|
+
* on". What is missing in front of a viewer is stated by the priority map,
|
|
706
|
+
* and placing encoders on it is the plan's work. What is left here is a
|
|
707
|
+
* record for the restart accounting and a line for whoever reads the log.
|
|
708
|
+
*
|
|
709
|
+
* @param {HlsSession} session
|
|
710
|
+
* @param {number} index
|
|
711
|
+
* @returns {void}
|
|
712
|
+
*/
|
|
713
|
+
#noteWanted(session, index) {
|
|
714
|
+
// When this segment was FIRST asked for and nobody was producing it. The
|
|
715
|
+
// restart itself costs 0.7-1.3 s (measured 2.9.132), while a seek costs
|
|
716
|
+
// 5-8 s end to end — so most of the wait happens before a restart is even
|
|
717
|
+
// decided on, and that is what this records.
|
|
718
|
+
this.#host.encodeRuns.noteWanted(session, index);
|
|
719
|
+
if (!this.#host.encodeRuns.isLive(session) || index < 0) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const head = this.#host.encodeRuns.earliestStartOf(session) ?? 0;
|
|
723
|
+
if (index < head) {
|
|
724
|
+
// Nothing running goes backwards, so nothing running will make it.
|
|
725
|
+
this.#explainHold(
|
|
726
|
+
session,
|
|
727
|
+
session.segmentFormat.segmentFileName(index),
|
|
728
|
+
`it is behind the run (#${head}); where the viewers are is what places encoders`
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The init header, lifted out of the first segment that exists.
|
|
735
|
+
*
|
|
736
|
+
* Needed only on the explicit-cut path, where the muxer produces no init file
|
|
737
|
+
* of its own. Scans rather than assuming segment 0: a run started by a seek
|
|
738
|
+
* begins at whatever index the viewer asked for.
|
|
739
|
+
*
|
|
740
|
+
* @param {HlsSession} session
|
|
741
|
+
* @returns {Promise<Buffer | null>}
|
|
742
|
+
*/
|
|
743
|
+
|
|
744
|
+
async #initFromFirstSegment(session) {
|
|
745
|
+
if (typeof session.segmentFormat.extractInit !== "function") {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
// How many tracks a complete header must declare is ANSWERED, not assumed.
|
|
749
|
+
//
|
|
750
|
+
// The probe already knows the source's stream list, and the output maps at
|
|
751
|
+
// most one of each (`-map 0:v:0? -map 0:a:0?`), so the count follows from
|
|
752
|
+
// what the source actually has. A film with no soundtrack expects one; an
|
|
753
|
+
// ordinary file expects two; neither is a convention.
|
|
754
|
+
//
|
|
755
|
+
// Deriving it from the produced pieces instead — the first version of this
|
|
756
|
+
// — reads correctly only once a piece carrying every track exists, and the
|
|
757
|
+
// whole point is the moment BEFORE that: early pieces written before the
|
|
758
|
+
// video was muxed would set the requirement to one and wave through exactly
|
|
759
|
+
// the header this exists to reject. The pieces are still consulted, but
|
|
760
|
+
// only as a floor: a piece carrying more than the probe led us to expect is
|
|
761
|
+
// evidence, and evidence outranks the probe.
|
|
762
|
+
const declared = this.#host.declaredTracks(session);
|
|
763
|
+
let expectedTracks = (declared.video ? 1 : 0) + (declared.audio ? 1 : 0);
|
|
764
|
+
if (expectedTracks === 0) {
|
|
765
|
+
// Nothing to consult. Fall back to the evidence, with its known lag.
|
|
766
|
+
expectedTracks = 1;
|
|
767
|
+
}
|
|
768
|
+
let best = null;
|
|
769
|
+
let bestTracks = 0;
|
|
770
|
+
/** @type {Map<string, Buffer>} Pieces read once and used for both passes. */
|
|
771
|
+
const pieces = new Map();
|
|
772
|
+
let names;
|
|
773
|
+
try {
|
|
774
|
+
names = this.producedNumbers(session).map((index) => session.segmentFormat.segmentFileName(index));
|
|
775
|
+
} catch {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
// First pass: what do the produced pieces actually carry? The answer is the
|
|
779
|
+
// requirement — no assumption about the source is involved.
|
|
780
|
+
if (typeof session.segmentFormat.countSegmentTracks === "function") {
|
|
781
|
+
for (const name of names) {
|
|
782
|
+
try {
|
|
783
|
+
// The first copy WITH BYTES IN IT, not the first name: a run stopped
|
|
784
|
+
// with a piece open leaves an empty file under the same name, and
|
|
785
|
+
// taking that one skips a number whose header is sitting in the run
|
|
786
|
+
// before it.
|
|
787
|
+
const found = await this.#firstCopyWithBytes(session, name);
|
|
788
|
+
if (!found) {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const bytes = await readFile(found);
|
|
792
|
+
pieces.set(name, bytes);
|
|
793
|
+
expectedTracks = Math.max(expectedTracks, session.segmentFormat.countSegmentTracks(bytes));
|
|
794
|
+
} catch {
|
|
795
|
+
// Being written right now — it says nothing about the others.
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
for (const name of names) {
|
|
801
|
+
try {
|
|
802
|
+
const cached = pieces.get(name);
|
|
803
|
+
const found = cached ? name : await this.#firstCopyWithBytes(session, name);
|
|
804
|
+
if (!found) {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const ownInit = session.segmentFormat.extractInit(cached ?? await readFile(found));
|
|
808
|
+
const init = ownInit && session.segmentFormat.prepareSharedInit ?
|
|
809
|
+
session.segmentFormat.prepareSharedInit(ownInit) : ownInit;
|
|
810
|
+
if (!init || init.length === 0) {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
// The requirement computed above is APPLIED here. It was computed and
|
|
814
|
+
// then ignored: this loop returned the first header it found, so a
|
|
815
|
+
// piece written before the video was muxed supplied an audio-only
|
|
816
|
+
// header — and that header is cached for the session's whole life,
|
|
817
|
+
// because the player fetches `#EXT-X-MAP` once. Measured 2026-08-11:
|
|
818
|
+
// `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — sound playing
|
|
819
|
+
// and no picture, for as long as the session lasted.
|
|
820
|
+
const tracks = typeof session.segmentFormat.countInitTracks === "function"
|
|
821
|
+
? session.segmentFormat.countInitTracks(init)
|
|
822
|
+
: expectedTracks;
|
|
823
|
+
if (tracks >= expectedTracks) {
|
|
824
|
+
return init;
|
|
825
|
+
}
|
|
826
|
+
if (tracks > bestTracks) {
|
|
827
|
+
best = init;
|
|
828
|
+
bestTracks = tracks;
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
// Being written right now — try the next one.
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (best !== null) {
|
|
835
|
+
// Nothing carried the full set. The source is probably missing a stream;
|
|
836
|
+
// serving the richest header found is right, and saying so makes the
|
|
837
|
+
// other possibility — every piece so far written before the video was
|
|
838
|
+
// muxed — visible rather than silent.
|
|
839
|
+
logger.warn(
|
|
840
|
+
`transcode ${session.id} no piece declared ${expectedTracks} tracks; ` +
|
|
841
|
+
`serving an init with ${bestTracks}`
|
|
842
|
+
);
|
|
843
|
+
return best;
|
|
844
|
+
}
|
|
845
|
+
return best;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* A produced file that has anything in it, or null.
|
|
850
|
+
*
|
|
851
|
+
* For callers that need a file's CONTENTS and cannot judge them — deriving
|
|
852
|
+
* the session's header is the case, since the header is what judging would
|
|
853
|
+
* need. An empty file answers no question, so it is passed over.
|
|
854
|
+
*
|
|
855
|
+
* @param {HlsSession} session
|
|
856
|
+
* @param {string} fileName
|
|
857
|
+
* @returns {Promise<string | null>}
|
|
858
|
+
*/
|
|
859
|
+
async #firstCopyWithBytes(session, fileName) {
|
|
860
|
+
const held = this.#host.segmentStore.pathOfName(session.outputKey ?? "", fileName);
|
|
861
|
+
if (held === null) {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
const info = await stat(held);
|
|
866
|
+
return info.size > 0 ? held : null;
|
|
867
|
+
} catch {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Record where one viewer of this session is, and answer with the furthest
|
|
874
|
+
* any of them has reached.
|
|
875
|
+
*
|
|
876
|
+
* The furthest is what the single encoder is steered by: it has to serve
|
|
877
|
+
* everyone, and what lies behind the leader has already been produced and is
|
|
878
|
+
* served from disk without a wait. The individual positions exist for the
|
|
879
|
+
* opposite question — whether a particular held request is still wanted —
|
|
880
|
+
* which cannot be answered from a shared field.
|
|
881
|
+
*
|
|
882
|
+
* A REQUEST IS NOT A POSITION. It says the viewer is still here and nothing
|
|
883
|
+
* more: where they are is what they themselves state, on the viewer, and a
|
|
884
|
+
* request cannot reach it. Asking for a segment used to write the position,
|
|
885
|
+
* so two writers filled one field in turn and the priority map jumped
|
|
886
|
+
* backwards several times a second — measured 2026-09-13, 77 encoder starts
|
|
887
|
+
* and 141 stops in six minutes while both viewers sat frozen.
|
|
888
|
+
*
|
|
889
|
+
* A viewer is forgotten once nothing has been heard from them for longer than
|
|
890
|
+
* any silence a watching viewer can produce. The figure is the proxy's own
|
|
891
|
+
* look-ahead, not a chosen interval.
|
|
892
|
+
*
|
|
893
|
+
* @param {HlsSession} session
|
|
894
|
+
* @param {string} consumerId
|
|
895
|
+
* @returns {void}
|
|
896
|
+
*/
|
|
897
|
+
#noteViewerSeen(session, consumerId) {
|
|
898
|
+
// A request that names nobody is evidence about nobody.
|
|
899
|
+
if (!consumerId) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
this.#host.viewers.of(session, consumerId).seen();
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Whether a held request is for a segment the viewer STILL needs.
|
|
907
|
+
*
|
|
908
|
+
* The epoch alone says a seek happened; it cannot say whether this particular
|
|
909
|
+
* request was made for the position left behind or for the one just arrived
|
|
910
|
+
* at. That distinction is the whole of the failure measured 2026-08-18: the
|
|
911
|
+
* viewer seeked to 1061.0 s, the request for `segment-00101` — the segment AT
|
|
912
|
+
* that position — raced the seek notification, the epoch moved underneath it,
|
|
913
|
+
* and it was answered 503 twice within 80 ms. The player then hunted at
|
|
914
|
+
* sn=105-107, never came back to 101, and looped two audio segments 1473
|
|
915
|
+
* times over 149 s while the picture stood still.
|
|
916
|
+
*
|
|
917
|
+
* A request is stale when its segment lies behind where the viewer now is, or
|
|
918
|
+
* so far ahead that the running encode will not reach it. Anything between is
|
|
919
|
+
* exactly what the viewer is waiting for, and holding it is the point.
|
|
920
|
+
*
|
|
921
|
+
* "So far ahead" is the encoder's own look-ahead, measured on this session's
|
|
922
|
+
* own cut grid — the same figure the browser sizes its forward buffer from.
|
|
923
|
+
* It used to be `MAX_LOOKAHEAD_SEGMENTS`, which is eight segments ahead of
|
|
924
|
+
* the ENCODE HEAD
|
|
925
|
+
* and has nothing to do with how far ahead of the VIEWER a request may
|
|
926
|
+
* legitimately sit; it happened to match a browser holding 30 s, and would
|
|
927
|
+
* have refused three quarters of the requests of one holding the whole
|
|
928
|
+
* cushion (roadmap item 4).
|
|
929
|
+
*
|
|
930
|
+
* Judged against the position of the viewer who MADE the request, when the
|
|
931
|
+
* transport carries who that is. A session is shared by everyone watching a
|
|
932
|
+
* copied picture and the epoch is per session, so a seek by the viewer in
|
|
933
|
+
* front used to release every request being held for the viewer behind them.
|
|
934
|
+
*
|
|
935
|
+
* @param {string} sessionId
|
|
936
|
+
* @param {string} fileName
|
|
937
|
+
* @param {string} [consumerId] - Who is asking. Without it the one shared
|
|
938
|
+
* position decides, which is what a single viewer means anyway.
|
|
939
|
+
* @returns {boolean} True when the request should keep waiting.
|
|
940
|
+
*/
|
|
941
|
+
requestStillWanted(sessionId, fileName, consumerId = "") {
|
|
942
|
+
const session = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
943
|
+
if (!session) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
const index = session.segmentFormat?.segmentIndexFromName?.(fileName) ?? -1;
|
|
947
|
+
if (!(index >= 0)) {
|
|
948
|
+
return true; // a playlist or an init segment belongs to no position
|
|
949
|
+
}
|
|
950
|
+
const position = this.#host.viewerSecondsOn(session, consumerId);
|
|
951
|
+
const at = this.#host.outputTimes.segmentIndexForTime(session, position);
|
|
952
|
+
// The far edge on THIS session's own grid rather than a count of nominal
|
|
953
|
+
// segments: a copied picture is cut at the source's keyframes, so its
|
|
954
|
+
// segments are not four seconds long and dividing by that figure would put
|
|
955
|
+
// the edge somewhere else entirely. The segment CONTAINING the edge is
|
|
956
|
+
// wanted — it is the one the deepest allowed request lands in, and it
|
|
957
|
+
// already reaches past the cushion by whatever is left of its own duration.
|
|
958
|
+
const edge = this.#host.outputTimes.segmentIndexForTime(session, position + this.#host.lookaheadSeconds);
|
|
959
|
+
return index >= at && index <= edge;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* How many times the viewer has moved since this session started.
|
|
964
|
+
*
|
|
965
|
+
* A request being held for a segment answers "retry" as soon as this changes,
|
|
966
|
+
* because it was made for a position the viewer has left — see `requestSeek`.
|
|
967
|
+
*
|
|
968
|
+
* @param {string} sessionId
|
|
969
|
+
* @returns {number}
|
|
970
|
+
*/
|
|
971
|
+
seekEpoch(sessionId) {
|
|
972
|
+
const session = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
973
|
+
return session ? this.#stateFor(session).waitEpoch : 0;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Wait for the requested segment to be published, without polling the disk.
|
|
978
|
+
*
|
|
979
|
+
* Ends on the publication, on the waits of this output being invalidated, on
|
|
980
|
+
* the deadline, or when the requester goes — and whichever ends it, the
|
|
981
|
+
* waiter is taken out of the store, so a wait with no deadline cannot outlive
|
|
982
|
+
* the request it was for. Returns false at once for playlists and init files,
|
|
983
|
+
* whose readiness has a different owner.
|
|
984
|
+
*
|
|
985
|
+
* @param {string} sessionId
|
|
986
|
+
* @param {string} fileName
|
|
987
|
+
* @param {number} timeoutMs - May be infinite.
|
|
988
|
+
* @param {Promise<unknown> | null} [requesterGone]
|
|
989
|
+
* @returns {Promise<boolean>} Whether it is worth asking for the file again.
|
|
990
|
+
*/
|
|
991
|
+
async waitForSegment(sessionId, fileName, timeoutMs, requesterGone = null) {
|
|
992
|
+
const session = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
993
|
+
const index = session?.segmentFormat?.segmentIndexFromName?.(fileName) ?? -1;
|
|
994
|
+
if (!session || index < 0) return false;
|
|
995
|
+
const state = this.#stateFor(session);
|
|
996
|
+
let wake;
|
|
997
|
+
const invalidated = new Promise((resolve) => {
|
|
998
|
+
wake = resolve;
|
|
999
|
+
state.waitListeners.add(resolve);
|
|
1000
|
+
});
|
|
1001
|
+
let finished = () => {};
|
|
1002
|
+
const over = new Promise((resolve) => {
|
|
1003
|
+
finished = resolve;
|
|
1004
|
+
});
|
|
1005
|
+
try {
|
|
1006
|
+
return await Promise.race([
|
|
1007
|
+
this.#host.segmentStore.waitFor(session.outputKey ?? "", index, timeoutMs, over),
|
|
1008
|
+
invalidated,
|
|
1009
|
+
...(requesterGone ? [requesterGone.then(() => false)] : [])
|
|
1010
|
+
]);
|
|
1011
|
+
} finally {
|
|
1012
|
+
state.waitListeners.delete(wake);
|
|
1013
|
+
finished();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
|
|
1019
|
+
* header, or until the session fails, or until the startup timeout elapses.
|
|
1020
|
+
* Throws with message `"HLS playlist is still warming up."` on timeout.
|
|
1021
|
+
*
|
|
1022
|
+
* @param {HlsSession} session
|
|
1023
|
+
* @returns {Promise<void>}
|
|
1024
|
+
*/
|
|
1025
|
+
async waitUntilReady(session) {
|
|
1026
|
+
// With a synthetic VOD playlist there is nothing to wait for: the playlist
|
|
1027
|
+
// is generated from the probed duration and is available immediately.
|
|
1028
|
+
// Individual segments are long-polled by the segment route as ffmpeg
|
|
1029
|
+
// produces them.
|
|
1030
|
+
if (session.useSyntheticPlaylist) {
|
|
1031
|
+
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
1032
|
+
throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
|
|
1033
|
+
}
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const playlistPath = path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), PLAYLIST_FILE_NAME);
|
|
1038
|
+
const deadline = Date.now() + this.#host.startupWaitMs;
|
|
1039
|
+
|
|
1040
|
+
while (Date.now() < deadline) {
|
|
1041
|
+
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
1042
|
+
throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
await access(playlistPath);
|
|
1046
|
+
const text = await readFile(playlistPath, "utf8");
|
|
1047
|
+
if (text.includes("#EXTM3U")) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
} catch (_error) {
|
|
1051
|
+
// Playlist is not ready yet.
|
|
1052
|
+
}
|
|
1053
|
+
await delay(250);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
throw new Error("HLS playlist is still warming up.");
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Answer the player's report that a delivered fragment sits far from the edge
|
|
1061
|
+
* of its buffer, with the one fact only this side holds: which boundary the
|
|
1062
|
+
* segment of that number really begins at.
|
|
1063
|
+
*
|
|
1064
|
+
* The player can say the gap; it cannot say whether the cause is its own
|
|
1065
|
+
* loading or a run whose output no longer matches its numbering. Here both
|
|
1066
|
+
* are in hand — the time the playlist gave that segment, and, when the
|
|
1067
|
+
* segment has been served, the time it truly began at — so the line either
|
|
1068
|
+
* names a shifted run or clears this side of it.
|
|
1069
|
+
*
|
|
1070
|
+
* Diagnostic only: nothing is repositioned on the strength of a browser's
|
|
1071
|
+
* reading, deliberately, because a wrong answer here would restart an encoder
|
|
1072
|
+
* the viewer is waiting on.
|
|
1073
|
+
*
|
|
1074
|
+
* @param {string} sessionId
|
|
1075
|
+
* @param {{ sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }} report
|
|
1076
|
+
* @returns {boolean} False when no such session exists.
|
|
1077
|
+
*/
|
|
1078
|
+
recordFragmentFar(sessionId, { sn, track, fragStartSec, bufferEndSec, currentTimeSec }) {
|
|
1079
|
+
const named = this.#host.outputs.get(sessionId);
|
|
1080
|
+
if (!named) {
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
// Which of the two streams the report is about. The browser addresses
|
|
1084
|
+
// everything to the video session's id — the soundtrack is served under
|
|
1085
|
+
// `/a/<n>/` on that same id — but it is a session of its own, with its own
|
|
1086
|
+
// run and its own position, and that is exactly the pair this report exists
|
|
1087
|
+
// to tell apart. Answering an audio report from the picture's records would
|
|
1088
|
+
// state, confidently, something about the wrong stream.
|
|
1089
|
+
const onScreen = this.#host.activeOutputFor({ base: named, outputs: this.#host.outputs });
|
|
1090
|
+
const session = track === "audio"
|
|
1091
|
+
? ([...this.#host.outputs.familyOf(onScreen)].find((member) => member.spec.carries === "audio-only") ?? onScreen)
|
|
1092
|
+
: onScreen;
|
|
1093
|
+
const gap = fragStartSec - bufferEndSec;
|
|
1094
|
+
const declared = this.#host.outputTimes.publishedStartTime(session, sn);
|
|
1095
|
+
const trueStart = this.#host.outputTimes.trueStartAt(session, sn);
|
|
1096
|
+
const verdict = trueStart === undefined
|
|
1097
|
+
// Where a segment truly began is only ever read off one that was cut on
|
|
1098
|
+
// an explicit list — a uniform grid has nothing to read back — so this is
|
|
1099
|
+
// "not recorded", which is not the same as "not produced", and the line
|
|
1100
|
+
// must not claim the second.
|
|
1101
|
+
? "where that segment began is not recorded on this side, so the gap cannot be attributed here"
|
|
1102
|
+
: (() => {
|
|
1103
|
+
const at = this.#host.outputTimes.boundaryIndexAt(session, trueStart, this.#host.publishedGridFor(session));
|
|
1104
|
+
if (at === null) {
|
|
1105
|
+
return `it really began at ${trueStart.toFixed(3)}s, which is no boundary of this grid`;
|
|
1106
|
+
}
|
|
1107
|
+
if (at === sn) {
|
|
1108
|
+
return `it really began at boundary #${sn}, where it should — the gap is not this run's`;
|
|
1109
|
+
}
|
|
1110
|
+
return `it really began at boundary #${at}, ${sn - at} place(s) before its own number — ` +
|
|
1111
|
+
"this run's output does not match its numbering";
|
|
1112
|
+
})();
|
|
1113
|
+
logger.warn(
|
|
1114
|
+
`transcode ${session.id} the player is stuck: ${session.spec.carries === "audio-only" ? "sound" : "picture"} ` +
|
|
1115
|
+
`fragment #${sn} starts ${gap.toFixed(1)}s past ` +
|
|
1116
|
+
`the end of its buffer (${bufferEndSec.toFixed(1)}s, viewer at ${currentTimeSec.toFixed(1)}s, ` +
|
|
1117
|
+
`the playlist puts it at ${declared.toFixed(3)}s) — ${verdict}`
|
|
1118
|
+
);
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Every segment number this session's OUTPUT holds, whoever produced it.
|
|
1124
|
+
*
|
|
1125
|
+
* Public because it is the one thing worth asserting about the address
|
|
1126
|
+
* change: two sessions of one output answer with the same list, including
|
|
1127
|
+
* segments the other one's encoder made.
|
|
1128
|
+
*
|
|
1129
|
+
* @param {HlsSession} session
|
|
1130
|
+
* @returns {number[]}
|
|
1131
|
+
*/
|
|
1132
|
+
producedSegmentNumbers(session) {
|
|
1133
|
+
return new Set(this.producedNumbers(session));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* What this session's OUTPUT holds, asked of the one thing that owns it.
|
|
1138
|
+
*
|
|
1139
|
+
* There were two owners of this fact over one directory: the store, addressed
|
|
1140
|
+
* by the output's own key, and a `ProducedIndex` built per SESSION over a
|
|
1141
|
+
* path the store had handed out. Two viewers of one output therefore built
|
|
1142
|
+
* two indexes over one directory, each with its own idea of what was in it.
|
|
1143
|
+
*
|
|
1144
|
+
* @param {HlsSession} session
|
|
1145
|
+
* @returns {number[]} Every segment number it holds, in order.
|
|
1146
|
+
*/
|
|
1147
|
+
producedNumbers(session) {
|
|
1148
|
+
return this.#host.segmentStore.provenNumbers(session.outputKey ?? "");
|
|
1149
|
+
}
|
|
1150
|
+
}
|