@torrent-tv/proxy 2.89.7 → 2.89.9
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 +363 -338
- package/CHANGELOG.md +2345 -2335
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +412 -412
- package/docs/browser-proxy-contract.md +155 -155
- package/docs/container-architecture.md +273 -273
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +765 -765
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/playback-start-forecast.md +113 -113
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +273 -273
- 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 +51 -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 -106
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -217
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +138 -138
- package/routes/api/transcode-sessions/post.js +178 -178
- 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 +416 -416
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +95 -95
- 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/build-audio-samples.mjs +31 -31
- package/scripts/render-run-graph.js +173 -173
- package/server.js +748 -748
- 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 +1192 -1192
- package/services/encode/EncodePlan.js +1368 -1368
- package/services/encode/EncodeRun.js +36 -12
- 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 +784 -784
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2283 -2283
- package/services/encode/RunClock.js +87 -87
- package/services/encode/RunProgress.js +79 -79
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +418 -418
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- 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 +116 -116
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +43 -43
- 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 +24 -24
- 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 +1043 -1043
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +24 -24
- package/services/encode/quality/QualityController.js +758 -758
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +337 -337
- package/services/encode/quality/output-format.js +319 -319
- 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 +757 -757
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +53 -53
- package/services/encode/segment-formats/fmp4.js +476 -476
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +202 -202
- package/services/media/KeyframeTables.js +356 -356
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +592 -592
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +299 -299
- package/services/media/container/AviContainer.js +268 -268
- package/services/media/container/Container.js +632 -632
- package/services/media/container/ContainerFactory.js +38 -38
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1031 -1031
- package/services/media/container/Mp4Container.js +1354 -1354
- package/services/media/container/SubtitleFileContainer.js +339 -339
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/container/ebml-stream.js +174 -174
- package/services/media/container/matroska-clusters.js +1165 -1165
- package/services/media/container/unavailable.js +77 -77
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +931 -931
- package/services/media/tracks/AudioTrack.js +143 -143
- package/services/media/tracks/ContainerTrack.js +84 -84
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +295 -295
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/audio-bound.js +233 -233
- 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 +1152 -1152
- package/services/server/ViewerRequests.js +785 -785
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +1029 -1029
- 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 +1937 -1937
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1294 -1294
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +382 -382
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/episode-naming.js +182 -182
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +264 -264
- package/services/torrent/torrent-pool.js +2688 -2688
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +836 -836
- 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 +231 -231
- 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 +1137 -1137
- package/services/transport/data-channel-handler.js +1406 -1373
- package/services/transport/delivery-probe.js +798 -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 +421 -421
- 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/media-time.js +355 -355
- package/services/viewer/playback-readiness.js +635 -635
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-buffer-continuity.test.js +77 -77
- package/test/audio-calibration.test.js +23 -23
- package/test/audio-inventory.test.js +212 -212
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +762 -762
- 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/coalesce.test.js +39 -39
- 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 +233 -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 +162 -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 +475 -475
- package/test/delivery-probe-field.test.js +173 -0
- package/test/delivery-probe.test.js +382 -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 +275 -275
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +426 -426
- package/test/encode-plan-viewers.test.js +725 -725
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +20 -0
- 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 +68 -68
- package/test/episode-naming.test.js +131 -131
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fixtures/audio-frame-boundary.json +1 -1
- package/test/fmp4-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 +36 -38
- package/test/held-read-ownership.test.js +77 -77
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +248 -240
- package/test/helpers/held-file.js +77 -77
- package/test/helpers/manager.js +62 -62
- package/test/helpers/matroska-file.js +296 -296
- 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 +123 -123
- 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 +291 -291
- package/test/matroska-cluster-discovery.test.js +323 -323
- package/test/matroska-cues-track.test.js +192 -192
- package/test/media-time.test.js +62 -62
- 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 +273 -273
- 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 +207 -207
- 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 +92 -87
- 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 +518 -518
- package/test/playback-start-regressions.test.js +101 -101
- 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 +160 -160
- package/test/quality-variants.test.js +1274 -1270
- package/test/rate-control-in-the-output.test.js +181 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/reads-not-yet-arrived.test.js +218 -218
- 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 +64 -64
- package/test/same-height-switch.test.js +663 -663
- 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 +114 -114
- 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 +222 -222
- package/test/segment-serve-wiring.test.js +481 -481
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +352 -352
- 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 +111 -111
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/soundtrack-rate-bound.test.js +289 -289
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +113 -113
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +237 -237
- 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 +385 -385
- 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 -15
- package/test/subtitle-plan-waits.test.js +125 -125
- package/test/subtitle-track-numbering.test.js +391 -391
- package/test/supply-margin.test.js +190 -190
- 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 +508 -508
- 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 +92 -92
- package/test/viewer-starts-on-map.test.js +27 -27
- package/test/viewer-states.test.js +192 -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/whole-file-held-read.test.js +30 -30
- 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/coalesce.js +41 -41
- 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
package/server.js
CHANGED
|
@@ -1,748 +1,748 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Proxy HTTP server bootstrap.
|
|
3
|
-
*
|
|
4
|
-
* Creates and configures the Fastify application, registers all routes and
|
|
5
|
-
* plugins, then starts listening on the first available port at or above the
|
|
6
|
-
* requested one.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import Fastify from "fastify";
|
|
10
|
-
import fastifyCors from "@fastify/cors";
|
|
11
|
-
import fastifyHelmet from "@fastify/helmet";
|
|
12
|
-
import fastifyStatic from "@fastify/static";
|
|
13
|
-
import getPort from "get-port";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { createRequire } from "node:module";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { handleHealthGet } from "./routes/health/get.js";
|
|
18
|
-
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
19
|
-
import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
|
|
20
|
-
import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
|
|
21
|
-
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
22
|
-
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
23
|
-
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
24
|
-
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
25
|
-
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
26
|
-
import { handleApiPlaybackPlanAudioTracksPost } from "./routes/api/playback-plan/audio-tracks/post.js";
|
|
27
|
-
import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
|
|
28
|
-
import { createClientLogFiles } from "./utils/client-log-file.js";
|
|
29
|
-
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
30
|
-
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
31
|
-
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
32
|
-
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
33
|
-
import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
|
|
34
|
-
import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
|
|
35
|
-
import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
|
|
36
|
-
import { handleStreamGet } from "./routes/stream/get.js";
|
|
37
|
-
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
38
|
-
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
39
|
-
import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
|
|
40
|
-
import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
|
|
41
|
-
import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
|
|
42
|
-
import { createSourceRegistry } from "./store/source-registry.js";
|
|
43
|
-
import { WorkerTorrentPool } from "./services/torrent/worker/pool-adapter.js";
|
|
44
|
-
import { wireOutputs } from "./services/server/wire-outputs.js";
|
|
45
|
-
import { createPlaybackPlanner } from "./services/media/playback-planner.js";
|
|
46
|
-
import { KeyframeTables } from "./services/media/KeyframeTables.js";
|
|
47
|
-
import { contentsOf } from "./services/torrent/Contents.js";
|
|
48
|
-
import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
|
|
49
|
-
import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
|
|
50
|
-
import { viewerStartsOn } from "./services/viewer/PriorityMap.js";
|
|
51
|
-
import { coalescing } from "./utils/coalesce.js";
|
|
52
|
-
import {
|
|
53
|
-
warmSubtitleCues,
|
|
54
|
-
cuesHeldFor,
|
|
55
|
-
subtitleTracksOf,
|
|
56
|
-
declaredSubtitleTracksOf,
|
|
57
|
-
forgetSubtitles
|
|
58
|
-
} from "./services/media/SubtitleCues.js";
|
|
59
|
-
import { detectVideoEncoder, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport, softwareDescriptor } from "./services/encode/hwaccel.js";
|
|
60
|
-
import { calibrateEncoder, HostCalibration } from "./services/encode/calibration.js";
|
|
61
|
-
import { readHostFingerprint } from "./services/encode/fingerprint.js";
|
|
62
|
-
import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
|
|
63
|
-
import { benchmarkAudio } from "./services/encode/audio-calibration.js";
|
|
64
|
-
import { logger } from "./utils/logger.js";
|
|
65
|
-
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
66
|
-
|
|
67
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
68
|
-
const __dirname = path.dirname(__filename);
|
|
69
|
-
const require = createRequire(import.meta.url);
|
|
70
|
-
const { version } = require("./package.json");
|
|
71
|
-
const publicRoot = path.resolve(__dirname, "./public");
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Build a list of candidate port numbers starting at `startPort`.
|
|
75
|
-
*
|
|
76
|
-
* @param {number} startPort
|
|
77
|
-
* @param {number} [maxAttempts=51]
|
|
78
|
-
* @returns {number[]}
|
|
79
|
-
*/
|
|
80
|
-
function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
81
|
-
const ports = [];
|
|
82
|
-
for (let index = 0; index < maxAttempts; index += 1) {
|
|
83
|
-
ports.push(startPort + index);
|
|
84
|
-
}
|
|
85
|
-
return ports;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* @typedef {Object} ProxyServerOptions
|
|
90
|
-
* @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
|
|
91
|
-
* @property {number} port - Preferred listen port.
|
|
92
|
-
* @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
|
|
93
|
-
* @property {string} ffmpegBin - Path to the ffmpeg executable.
|
|
94
|
-
* @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
|
|
95
|
-
* @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
|
|
96
|
-
* @property {string} [stateDir] - Where to keep what this host has measured about itself.
|
|
97
|
-
*/
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Create, configure, and start the proxy HTTP server.
|
|
101
|
-
*
|
|
102
|
-
* @param {ProxyServerOptions} options
|
|
103
|
-
* @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
|
|
104
|
-
*/
|
|
105
|
-
export async function startProxyServer({
|
|
106
|
-
host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
|
|
107
|
-
deliverySink = false,
|
|
108
|
-
// Where the proxy writes its own log. Its DIRECTORY is what matters here:
|
|
109
|
-
// the browser's half of every session is written beside it, so the two are
|
|
110
|
-
// on one durable disk and join by name.
|
|
111
|
-
logFile = ""
|
|
112
|
-
}) {
|
|
113
|
-
const app = Fastify({
|
|
114
|
-
// No practical body-size limit — the proxy server is localhost-only and
|
|
115
|
-
// receives torrent source payloads that may be arbitrarily large.
|
|
116
|
-
bodyLimit: 256 * 1024 * 1024 // 256 MB
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
await app.register(fastifyHelmet, {
|
|
120
|
-
// Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
|
|
121
|
-
crossOriginResourcePolicy: {
|
|
122
|
-
policy: "cross-origin"
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
await app.register(fastifyCors, {
|
|
126
|
-
origin: true,
|
|
127
|
-
methods: ["GET", "POST", "OPTIONS"],
|
|
128
|
-
allowedHeaders: ["Content-Type", "Range"]
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
// Allow browser requests from an HTTPS page to this private-network proxy
|
|
132
|
-
// without triggering Chromium's Private Network Access permission prompt.
|
|
133
|
-
app.addHook("onRequest", async (_req, reply) => {
|
|
134
|
-
reply.header("Access-Control-Allow-Private-Network", "true");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
const sourceRegistry = createSourceRegistry(200);
|
|
138
|
-
// The torrent runs on its own thread. Profiling a live seek (2026-08-02)
|
|
139
|
-
// found the main thread ~85% occupied by WebTorrent — buffer concatenation
|
|
140
|
-
// ~15%, wire updates ~9%, garbage collection ~5% — while three of four cores
|
|
141
|
-
// idled. Serving a segment shared that thread, so reading an already-finished
|
|
142
|
-
// 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
|
|
143
|
-
// adapter keeps TorrentPool's interface, so nothing downstream changed.
|
|
144
|
-
const torrentPool = new WorkerTorrentPool({
|
|
145
|
-
memoryBytes,
|
|
146
|
-
stateDir,
|
|
147
|
-
// Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
|
|
148
|
-
// done about it — walking a file's new subtitle clusters and pushing what
|
|
149
|
-
// came out — happens here, because that is a reading of what the file says
|
|
150
|
-
// about itself. The walk used to run in that thread, which is what put a
|
|
151
|
-
// container parse there.
|
|
152
|
-
// The ONE forward reference here, and it is safe by construction: this fires
|
|
153
|
-
// only once the worker thread is up and a piece has verified, which is long
|
|
154
|
-
// after the declarations below have run.
|
|
155
|
-
onPiecesArrived: ({ sourceKey, fileIndexes }) => {
|
|
156
|
-
for (const fileIndex of fileIndexes) {
|
|
157
|
-
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
158
|
-
// A keyframe table somebody asked for while its bytes had not arrived
|
|
159
|
-
// is read again now; nothing was recorded for that wait.
|
|
160
|
-
keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
const selectedPort = await getPort({
|
|
165
|
-
port: buildPortCandidates(port)
|
|
166
|
-
});
|
|
167
|
-
// Auto-detect the best available H.264 encoder (hardware-accelerated or
|
|
168
|
-
// software) once at startup, with a real test-encode and graceful fallback.
|
|
169
|
-
// Only needed when transcoding can occur.
|
|
170
|
-
let videoEncoder = transcodeAudio
|
|
171
|
-
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
172
|
-
: null;
|
|
173
|
-
// WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
|
|
174
|
-
// was gated on the chosen encoder being SOFTWARE, and two of the three are
|
|
175
|
-
// not about the encoder at all: a host with a GPU decodes in software just
|
|
176
|
-
// the same — no hardware decoder is asked for anywhere — and what a second
|
|
177
|
-
// job costs is a property of the machine. So a GPU host had no decode cost,
|
|
178
|
-
// no contention penalty and no encoder throughput, and the quality offer,
|
|
179
|
-
// which is arithmetic over those three, had nothing to compute from.
|
|
180
|
-
//
|
|
181
|
-
// The decode model first and the throughput second — they are independent
|
|
182
|
-
// (the rungs are timed on raw frames), but the order keeps the two figures
|
|
183
|
-
// side by side in the log.
|
|
184
|
-
const decodeCostModel = transcodeAudio
|
|
185
|
-
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
186
|
-
: null;
|
|
187
|
-
// What a second job costs on this host. Measured because the budget adds
|
|
188
|
-
// independent prices and this host says two jobs that each fit alone do not
|
|
189
|
-
// fit together — 2.6× on the addon box (2026-08-18).
|
|
190
|
-
const contentionPenalties = transcodeAudio
|
|
191
|
-
? await benchmarkContention({ ffmpegBin, logger })
|
|
192
|
-
: null;
|
|
193
|
-
// WHICH MODES THIS HOST MAY ENCODE WITH, AND WHAT EACH COSTS AT EVERY SIZE
|
|
194
|
-
// (roadmap item 97, step 14). Every setting an output can actually be
|
|
195
|
-
// encoded at is put through the product's own arguments and its segments
|
|
196
|
-
// decoded, then timed at the sizes of the ladder; a mode that fails either is
|
|
197
|
-
// not used, and a size nothing was timed at is not offered. Software is
|
|
198
|
-
// calibrated too when detection chose a device: it is what a failing device
|
|
199
|
-
// falls back to for the rest of the process, and it must not be priced by
|
|
200
|
-
// the device's figures.
|
|
201
|
-
//
|
|
202
|
-
// And WHICH CONFIGURATION all of it describes — the ffmpeg and x264 builds,
|
|
203
|
-
// the processor and the threads, the device and its driver — which is what
|
|
204
|
-
// decides whether anything kept from an earlier run still applies.
|
|
205
|
-
const fingerprint = videoEncoder
|
|
206
|
-
? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder })
|
|
207
|
-
: null;
|
|
208
|
-
/** @type {Record<string, object[]>} */
|
|
209
|
-
const calibratedModes = {};
|
|
210
|
-
if (videoEncoder) {
|
|
211
|
-
calibratedModes[videoEncoder.kind] =
|
|
212
|
-
(await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger })).modes;
|
|
213
|
-
if (videoEncoder.kind !== "software") {
|
|
214
|
-
calibratedModes.software =
|
|
215
|
-
(await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger })).modes;
|
|
216
|
-
// A device that passed detection's own test and then none of whose modes
|
|
217
|
-
// passed the product's is not used: detection encodes a synthetic pattern
|
|
218
|
-
// with arguments of its own, and it is the product's arguments a viewer
|
|
219
|
-
// is served with.
|
|
220
|
-
if (calibratedModes[videoEncoder.kind].length === 0) {
|
|
221
|
-
logger.warn(
|
|
222
|
-
`calibration: ${videoEncoder.name} passed detection but none of its modes produced correct segments ` +
|
|
223
|
-
"through the product's own arguments; encoding in software instead"
|
|
224
|
-
);
|
|
225
|
-
videoEncoder = softwareDescriptor();
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
const calibration = videoEncoder ? new HostCalibration({ byKind: calibratedModes, fingerprint }) : null;
|
|
230
|
-
// What this host does with a picture it does NOT re-encode. Every other
|
|
231
|
-
// startup measurement prices encoding or decoding, and a copied picture does
|
|
232
|
-
// neither — it reads packets and writes them out again — so that whole branch
|
|
233
|
-
// had no speed until its own run had been running long enough to report one.
|
|
234
|
-
// The encoding layer decides where encoders go from arrivals, and an arrival
|
|
235
|
-
// cannot be computed without a speed, so the moment it mattered most was the
|
|
236
|
-
// moment nothing was known. Measured whatever the encoder is: copying does not
|
|
237
|
-
// touch it.
|
|
238
|
-
const copySpeedX = transcodeAudio
|
|
239
|
-
? await benchmarkCopySpeed({ ffmpegBin, logger })
|
|
240
|
-
: null;
|
|
241
|
-
const audioCalibration = transcodeAudio
|
|
242
|
-
? await benchmarkAudio({ ffmpegBin, logger })
|
|
243
|
-
: [];
|
|
244
|
-
// WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
|
|
245
|
-
// thing — leave an encoder where it stands, or kill it and start another —
|
|
246
|
-
// and both used to be learned only from runs that had ENDED, so at a cold
|
|
247
|
-
// open they were zero. Zero does not read as "not measured": it reads as
|
|
248
|
-
// "free", and a free move is always taken. Field 2026-09-08: an encoder moved
|
|
249
|
-
// between two adjacent numbers every half second and produced nothing.
|
|
250
|
-
const startStopCost = transcodeAudio
|
|
251
|
-
? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
|
|
252
|
-
: null;
|
|
253
|
-
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
254
|
-
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
255
|
-
// sources on the software path when available.
|
|
256
|
-
const tonemapSupported = transcodeAudio
|
|
257
|
-
? await detectTonemapSupport({ ffmpegBin, logger })
|
|
258
|
-
: false;
|
|
259
|
-
// Where every file's keyframe table lives, and the only thing that reads one.
|
|
260
|
-
// Built here rather than inside the session manager because it is not a fact
|
|
261
|
-
// about a session: the playback planner warms it while a file is being opened
|
|
262
|
-
// and no session exists yet, and a session created later is handed the very
|
|
263
|
-
// object that warm read filled in.
|
|
264
|
-
// Subtitles are two layers put together, and this is the one place that knows
|
|
265
|
-
// about both: what a file STATES about its subtitle tracks and how a cue is
|
|
266
|
-
// written out is the media layer's, while which clusters may be read, where
|
|
267
|
-
// the cursor stands and what one walk at a time means are the TORRENT's own
|
|
268
|
-
// rules. The media layer used to import the second directly.
|
|
269
|
-
/**
|
|
270
|
-
* One file of one torrent, reduced to what the subtitle walk may know: a
|
|
271
|
-
* name, a length, its one container, which ranges are downloaded whole, how
|
|
272
|
-
* to read one of those without asking the swarm, the portion one read may
|
|
273
|
-
* span, and where the viewers stand. File offsets and the bitfield stay in
|
|
274
|
-
* the torrent's thread; the piece length crosses as a plain number.
|
|
275
|
-
*
|
|
276
|
-
* @param {string} sourceKey
|
|
277
|
-
* @param {number} fileIndex
|
|
278
|
-
* @param {object} [known] - The torrent handle when the caller already has it.
|
|
279
|
-
* @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
|
|
280
|
-
*/
|
|
281
|
-
const heldFileFor = async (sourceKey, fileIndex, known = null) => {
|
|
282
|
-
let torrent = known;
|
|
283
|
-
if (!torrent) {
|
|
284
|
-
const record = sourceRegistry.get(sourceKey);
|
|
285
|
-
if (!record) {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
289
|
-
}
|
|
290
|
-
const file = torrent?.files?.[fileIndex];
|
|
291
|
-
if (!file || !(file.length > 0)) {
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
return {
|
|
295
|
-
sourceKey,
|
|
296
|
-
fileIndex,
|
|
297
|
-
name: String(file.name ?? ""),
|
|
298
|
-
length: file.length,
|
|
299
|
-
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
|
|
300
|
-
// The file's ONE container — the one the track table, the media info and
|
|
301
|
-
// the keyframe table are read from. Built here only when nobody has built
|
|
302
|
-
// it yet, so a pass of the walk does not fetch the file's edges again.
|
|
303
|
-
container: async () => {
|
|
304
|
-
const known = containerOrchestrator.known(sourceKey, fileIndex);
|
|
305
|
-
if (known !== undefined) {
|
|
306
|
-
return known;
|
|
307
|
-
}
|
|
308
|
-
const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
|
|
309
|
-
return params ? containerOrchestrator.containerFor(params) : null;
|
|
310
|
-
},
|
|
311
|
-
heldRanges: () => torrentPool.heldRangesOf(torrent, fileIndex),
|
|
312
|
-
readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end),
|
|
313
|
-
// Where the viewers of this file stand: the start of every urgent stretch
|
|
314
|
-
// of its priority map, which is where each viewer's own need begins.
|
|
315
|
-
wantedSeconds: () => viewerStartsOn(outputParts.priority?.mapFor(sourceKey, fileIndex)),
|
|
316
|
-
// A pass left readable clusters for the next one: run it.
|
|
317
|
-
askAgain: () => {
|
|
318
|
-
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
};
|
|
322
|
-
/**
|
|
323
|
-
* Walk whatever new cues a file now holds and push them to the viewers.
|
|
324
|
-
*
|
|
325
|
-
* One walk of a file at a time, and one more after it whenever pieces
|
|
326
|
-
* arrived meanwhile (`coalescing`): `verified` fires per piece, so a queue of
|
|
327
|
-
* identical passes would only postpone the one with something new to find,
|
|
328
|
-
* and dropping them — as this used to — lost the pass that would have found
|
|
329
|
-
* the last pieces of a file.
|
|
330
|
-
*/
|
|
331
|
-
const pushFreshCues = coalescing(async (sourceKey, fileIndex) => {
|
|
332
|
-
try {
|
|
333
|
-
const file = await heldFileFor(sourceKey, fileIndex);
|
|
334
|
-
if (!file) {
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
for (const entry of await warmSubtitleCues(file)) {
|
|
338
|
-
const span = entry.spanStartSeconds === null
|
|
339
|
-
? "empty"
|
|
340
|
-
: `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
|
|
341
|
-
logger.info(
|
|
342
|
-
`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
|
|
343
|
-
`${entry.cues.length} new cue(s) covering ${span}` +
|
|
344
|
-
(entry.withdrawn.length > 0 ? `, ${entry.withdrawn.length} taken back` : "") +
|
|
345
|
-
`, clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
|
|
346
|
-
);
|
|
347
|
-
onSubtitleCues?.({ sourceKey, fileIndex, ...entry });
|
|
348
|
-
}
|
|
349
|
-
} catch (error) {
|
|
350
|
-
logger.warn(`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} failed: ${error?.message ?? error}`);
|
|
351
|
-
}
|
|
352
|
-
});
|
|
353
|
-
const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
|
|
354
|
-
warm: async (torrent, fileIndex, sourceKey) => {
|
|
355
|
-
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
356
|
-
return file ? warmSubtitleCues(file) : [];
|
|
357
|
-
},
|
|
358
|
-
held: async (torrent, fileIndex, sourceKey, trackNumber) => {
|
|
359
|
-
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
360
|
-
return file ? cuesHeldFor(file, trackNumber) : null;
|
|
361
|
-
},
|
|
362
|
-
tracksOf: async (torrent, fileIndex, sourceKey) => {
|
|
363
|
-
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
364
|
-
return file ? subtitleTracksOf(file) : [];
|
|
365
|
-
},
|
|
366
|
-
declaredTracksOf: async (torrent, fileIndex, sourceKey) => {
|
|
367
|
-
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
368
|
-
return file ? declaredSubtitleTracksOf(file) : [];
|
|
369
|
-
},
|
|
370
|
-
forget: forgetSubtitles
|
|
371
|
-
});
|
|
372
|
-
// WHAT A FILE STATES ABOUT ITSELF IS READ HERE, ON THIS THREAD.
|
|
373
|
-
//
|
|
374
|
-
// The container layer is built from one function — `readRange(start, end)` —
|
|
375
|
-
// and the bytes behind it live in shared memory, so the parse has no reason
|
|
376
|
-
// to happen anywhere else. It used to happen in the torrent thread for one
|
|
377
|
-
// stated reason, that "the main thread cannot open a read stream on one of
|
|
378
|
-
// its files": true of WebTorrent's own API, and not of the bytes. The price
|
|
379
|
-
// was three commands, a second `ContainerOrchestrator` in that thread, and
|
|
380
|
-
// every answer carried back across the channel — which is what made "where is
|
|
381
|
-
// this fact kept" a question at all.
|
|
382
|
-
//
|
|
383
|
-
// The edges are fetched first because the file this is asked about is usually
|
|
384
|
-
// one nobody has played: a sidecar soundtrack is asked about before anyone
|
|
385
|
-
// has chosen it, and a Matroska file's Cues sit at the END behind a SeekHead
|
|
386
|
-
// in the head, so a read that has neither waits for the swarm twice over.
|
|
387
|
-
const containerOver = async ({ sourceKey, fileIndex, tailBytes = 0 }) => {
|
|
388
|
-
const record = sourceRegistry.get(sourceKey);
|
|
389
|
-
if (!record) {
|
|
390
|
-
return null;
|
|
391
|
-
}
|
|
392
|
-
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
393
|
-
const file = torrent?.files?.[fileIndex];
|
|
394
|
-
if (!file || !(file.length > 0)) {
|
|
395
|
-
return null;
|
|
396
|
-
}
|
|
397
|
-
try {
|
|
398
|
-
await torrentPool.prefetchFileEdges(torrent, fileIndex, {
|
|
399
|
-
headBytes: CONTAINER_HEAD_BYTES,
|
|
400
|
-
tailBytes,
|
|
401
|
-
timeoutMs: 60_000
|
|
402
|
-
});
|
|
403
|
-
} catch {
|
|
404
|
-
// A prefetch that failed is not a reason to skip the read: the read
|
|
405
|
-
// fetches what it needs itself, only more slowly.
|
|
406
|
-
}
|
|
407
|
-
return {
|
|
408
|
-
sourceKey,
|
|
409
|
-
fileIndex,
|
|
410
|
-
readRange: (start, end) =>
|
|
411
|
-
torrentPool.readRangeOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
|
|
412
|
-
fileSize: file.length,
|
|
413
|
-
label: String(file.name ?? ""),
|
|
414
|
-
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
|
|
415
|
-
};
|
|
416
|
-
};
|
|
417
|
-
const keyframeTables = new KeyframeTables({
|
|
418
|
-
// Read by the same container that answers the track table and the media
|
|
419
|
-
// info, from the same header, and now in the same thread as the session
|
|
420
|
-
// that is waiting for it.
|
|
421
|
-
readTable: async ({ sourceKey, fileIndex }) => {
|
|
422
|
-
// Both edges: the Cues of a Matroska file are at the end.
|
|
423
|
-
const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
|
|
424
|
-
if (!params) {
|
|
425
|
-
return null;
|
|
426
|
-
}
|
|
427
|
-
const index = await containerOrchestrator.getKeyframeIndex(params);
|
|
428
|
-
return {
|
|
429
|
-
times: index?.times ?? null,
|
|
430
|
-
tolerance: index?.tolerance ?? 0,
|
|
431
|
-
// Which container answered, whether or not it produced a table: the
|
|
432
|
-
// refusal that follows names it, and a measurement of how often an
|
|
433
|
-
// index disagrees with its own file cannot be read without it.
|
|
434
|
-
format: (await containerOrchestrator.getContainer(params))?.formatName ?? "unrecognised"
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
});
|
|
438
|
-
const outputParts = wireOutputs({
|
|
439
|
-
enabled: transcodeAudio,
|
|
440
|
-
keyframeTables,
|
|
441
|
-
ffmpegBin,
|
|
442
|
-
localBindHost: host,
|
|
443
|
-
localPort: selectedPort,
|
|
444
|
-
videoEncoder,
|
|
445
|
-
calibration,
|
|
446
|
-
audioCalibration,
|
|
447
|
-
decodeCostModel,
|
|
448
|
-
contentionPenalties,
|
|
449
|
-
copySpeedX,
|
|
450
|
-
startStopCost,
|
|
451
|
-
tonemapSupported,
|
|
452
|
-
segmentFormatId: segmentFormat,
|
|
453
|
-
stateDir,
|
|
454
|
-
diagnostics,
|
|
455
|
-
// Live download stats accessor for the realtime budget: lets it tell a
|
|
456
|
-
// CPU-bound transcode from a download-starved input before downscaling.
|
|
457
|
-
// What every torrent here has moved, so the proxy can price its own
|
|
458
|
-
// downloading, hashing and delivery against the machine (roadmap item 7).
|
|
459
|
-
// What the spilled pieces weigh on the torrent thread, and how to tell them
|
|
460
|
-
// their share of the disk. The owner of the disk is on this side, where the
|
|
461
|
-
// segments are; the pieces are on the other.
|
|
462
|
-
spillDisk: typeof torrentPool.allowSpillBytes === "function"
|
|
463
|
-
? {
|
|
464
|
-
held: () => torrentPool.spilledBytes ?? 0,
|
|
465
|
-
allow: (bytes) => torrentPool.allowSpillBytes(bytes)
|
|
466
|
-
}
|
|
467
|
-
: null,
|
|
468
|
-
// Files downloaded whole, kept as files. They live on the torrent thread
|
|
469
|
-
// too, and until 2026-09-14 they had no bound of any kind — a 2.8 GB film
|
|
470
|
-
// on a host whose disk is often a 32 GB card.
|
|
471
|
-
wholeFiles: typeof torrentPool.allowWholeFileBytes === "function"
|
|
472
|
-
? {
|
|
473
|
-
held: () => torrentPool.wholeFileBytes ?? 0,
|
|
474
|
-
allow: (bytes) => torrentPool.allowWholeFileBytes(bytes),
|
|
475
|
-
root: completedFilesRoot()
|
|
476
|
-
}
|
|
477
|
-
: null,
|
|
478
|
-
// The pieces held IN memory, on the torrent thread. Told their share by the
|
|
479
|
-
// same owner that divides the disk: what does not fit here is spilled
|
|
480
|
-
// there, so the two cannot be divided apart.
|
|
481
|
-
memoryClaimant: typeof torrentPool.allowMemoryBytes === "function"
|
|
482
|
-
? {
|
|
483
|
-
held: () => torrentPool.memoryClaim?.held ?? 0,
|
|
484
|
-
wanted: () => torrentPool.memoryClaim?.wanted ?? 0,
|
|
485
|
-
allow: (bytes) => torrentPool.allowMemoryBytes(bytes)
|
|
486
|
-
}
|
|
487
|
-
: null,
|
|
488
|
-
diagnosticsRoot: stateDir || "",
|
|
489
|
-
budgetPolicy,
|
|
490
|
-
getTorrentTotals: async () => {
|
|
491
|
-
if (typeof torrentPool.getTorrentTotals !== "function") {
|
|
492
|
-
return null;
|
|
493
|
-
}
|
|
494
|
-
return torrentPool.getTorrentTotals();
|
|
495
|
-
},
|
|
496
|
-
// The priority map, on its way to the downloading. It lives in another
|
|
497
|
-
// thread, so the map crosses the worker channel; what it does with it —
|
|
498
|
-
// seconds into bytes, what to ask the swarm for, what to keep in memory —
|
|
499
|
-
// is its own business.
|
|
500
|
-
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
501
|
-
const record = sourceRegistry.get(sourceKey);
|
|
502
|
-
if (!record) {
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
try {
|
|
506
|
-
await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
|
|
507
|
-
} catch {
|
|
508
|
-
// Best effort: the map is republished on the next change, and the
|
|
509
|
-
// downloading goes on serving reads meanwhile.
|
|
510
|
-
}
|
|
511
|
-
},
|
|
512
|
-
getSourceStats: async (sourceKey, fileIndex) => {
|
|
513
|
-
const record = sourceRegistry.get(sourceKey);
|
|
514
|
-
if (!record) {
|
|
515
|
-
return null;
|
|
516
|
-
}
|
|
517
|
-
try {
|
|
518
|
-
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
519
|
-
// Awaited for the same reason as the stats route: this now crosses a
|
|
520
|
-
// thread boundary and returns a promise.
|
|
521
|
-
return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
|
|
522
|
-
} catch {
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
},
|
|
526
|
-
// Reuse the media info the planner already probed for this file (same
|
|
527
|
-
// ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
|
|
528
|
-
// only at session-create time, after playbackPlanner is initialised.
|
|
529
|
-
getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
|
|
530
|
-
// The file's audio tracks, for the master playlist's rendition group. Already
|
|
531
|
-
// probed for the browser's audio menu; read from there rather than probed again.
|
|
532
|
-
getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
|
|
533
|
-
// What a file declares about itself, read by the container layer from the
|
|
534
|
-
// same header its track table comes from. This is how the session learns
|
|
535
|
-
// where a soundtrack shipped as its own file begins — it used to spawn an
|
|
536
|
-
// ffmpeg over this proxy's own HTTP to ask the same question of the same
|
|
537
|
-
// bytes, and that read cost 8.1 s of every cold start (field 2026-09-03).
|
|
538
|
-
getContainerMediaInfo: async ({ sourceKey, fileIndex }) => {
|
|
539
|
-
try {
|
|
540
|
-
const params = await containerOver({ sourceKey, fileIndex });
|
|
541
|
-
return params ? await containerOrchestrator.getMediaInfo(params) : null;
|
|
542
|
-
} catch {
|
|
543
|
-
return null;
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
547
|
-
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
548
|
-
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
549
|
-
// spare. A bounded read of the file's whole length, NOT `file.select()`:
|
|
550
|
-
// selecting a file alongside the readers' own windows is what made a seek
|
|
551
|
-
// wait 93 s while the swarm fetched 2.47 GB in file order (see
|
|
552
|
-
// `#syncSelections` in `torrent/torrent-pool.js`).
|
|
553
|
-
fetchWholeFile: async ({ sourceKey, fileIndex }) => {
|
|
554
|
-
const record = sourceRegistry.get(sourceKey);
|
|
555
|
-
if (!record) {
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
559
|
-
// The same background fill the warm-up starts when a file is chosen, not a
|
|
560
|
-
// second way of doing it. It is guarded against running twice on one file,
|
|
561
|
-
// so the two triggers converge instead of putting two readers on the same
|
|
562
|
-
// soundtrack — and only one of them would have stood aside for the
|
|
563
|
-
// picture. This trigger remains for the session that never had a warm-up
|
|
564
|
-
// before it.
|
|
565
|
-
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
566
|
-
}
|
|
567
|
-
});
|
|
568
|
-
// What the last life of this process left on the disk. The kernel kills this
|
|
569
|
-
// one often enough for that to be an ordinary state rather than an odd one —
|
|
570
|
-
// twice in a single viewing on 2026-09-02 — and when it does, no exit handler
|
|
571
|
-
// runs and nothing is cleared up. So this is both the cleanup and the only
|
|
572
|
-
// record that those encoders ended at all: it says what it found before it
|
|
573
|
-
// decides anything, keeps the segments whose closure is proven, and removes
|
|
574
|
-
// the one piece per output that was being written when the process died.
|
|
575
|
-
outputParts.lifecycle.adoptSegmentsLeftBehind();
|
|
576
|
-
const playbackPlanner = createPlaybackPlanner({
|
|
577
|
-
ffmpegBin,
|
|
578
|
-
transcodeAudioEnabled: transcodeAudio,
|
|
579
|
-
localBaseUrl: outputParts.localBaseUrl,
|
|
580
|
-
sourceRegistry,
|
|
581
|
-
torrentPool,
|
|
582
|
-
sidecarsFromTorrent: (torrent, fileIndex) => contentsOf(torrent).sidecarsOf(fileIndex),
|
|
583
|
-
// What a file declares about its own tracks, parsed on this thread from the
|
|
584
|
-
// header the swarm delivered. The planner used to ask the torrent pool for
|
|
585
|
-
// this, which meant the media layer asking the torrent layer to parse a
|
|
586
|
-
// container on its behalf, in the other thread, with the answer carried
|
|
587
|
-
// back over the channel.
|
|
588
|
-
declaredTracksOf: async ({ sourceKey, fileIndex }) => {
|
|
589
|
-
const params = await containerOver({ sourceKey, fileIndex });
|
|
590
|
-
return params ? await containerOrchestrator.getTracks(params) : [];
|
|
591
|
-
},
|
|
592
|
-
warmKeyframeIndex: (params) => keyframeTables.warm(params),
|
|
593
|
-
expectedFirstSegmentMs: () => outputParts.hostTimings.expectedFirstSegmentMs(),
|
|
594
|
-
expectedSessionCreateMs: () => outputParts.hostTimings.expectedSessionCreateMs(),
|
|
595
|
-
// The quality menu is on screen from the moment a file is opened, so the
|
|
596
|
-
// heights this host can actually serve have to be answerable before any
|
|
597
|
-
// encoder exists — from the probe and the startup benchmarks alone.
|
|
598
|
-
predictOfferedHeights: (mediaInfo) => outputParts.quality.predictOfferedHeights(mediaInfo)
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
602
|
-
app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
|
|
603
|
-
// Off unless --delivery-sink was given; see the route for why it exists.
|
|
604
|
-
app.get("/api/delivery-sink", async (req, reply) =>
|
|
605
|
-
handleApiDeliverySinkGet(req, reply, { enabled: deliverySink === true })
|
|
606
|
-
);
|
|
607
|
-
// Bytes a browser can time its link with before any film has been chosen.
|
|
608
|
-
app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
|
|
609
|
-
app.post("/api/sources", async (req, reply) =>
|
|
610
|
-
handleApiSourcesPost(req, reply, { sourceRegistry })
|
|
611
|
-
);
|
|
612
|
-
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
613
|
-
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
|
|
614
|
-
);
|
|
615
|
-
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
616
|
-
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
|
|
617
|
-
);
|
|
618
|
-
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
619
|
-
handleApiSourceWarmPost(req, reply, {
|
|
620
|
-
sourceRegistry,
|
|
621
|
-
torrentPool,
|
|
622
|
-
playbackPlanner,
|
|
623
|
-
// How long the file runs, read on this thread from the header. The warm
|
|
624
|
-
// turns a position in seconds into a byte offset and needs it; it used to
|
|
625
|
-
// read the container itself, in the torrent thread, to find out.
|
|
626
|
-
durationOf: async (params) => {
|
|
627
|
-
const over = await containerOver(params);
|
|
628
|
-
return over ? (await containerOrchestrator.getMediaInfo(over))?.durationSeconds ?? null : null;
|
|
629
|
-
}
|
|
630
|
-
})
|
|
631
|
-
);
|
|
632
|
-
// The browser's own log, kept beside the proxy's. See the route's own file
|
|
633
|
-
// for why it is here and not only on the registry server.
|
|
634
|
-
const clientLogs = logFile ? createClientLogFiles(logFile) : null;
|
|
635
|
-
app.post("/api/client-logs", async (req, reply) =>
|
|
636
|
-
handleApiClientLogsPost(req, reply, { clientLogs })
|
|
637
|
-
);
|
|
638
|
-
app.post("/api/playback-plan", async (req, reply) =>
|
|
639
|
-
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
640
|
-
);
|
|
641
|
-
app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
|
|
642
|
-
handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
|
|
643
|
-
);
|
|
644
|
-
app.get("/api/subtitles", async (req, reply) =>
|
|
645
|
-
handleApiSubtitlesGet(req, reply, {
|
|
646
|
-
sourceRegistry,
|
|
647
|
-
torrentPool,
|
|
648
|
-
ffmpegBin,
|
|
649
|
-
localBaseUrl: outputParts.localBaseUrl,
|
|
650
|
-
viewers: outputParts.viewers,
|
|
651
|
-
subtitles
|
|
652
|
-
})
|
|
653
|
-
);
|
|
654
|
-
app.get("/stream", async (req, reply) =>
|
|
655
|
-
handleStreamGet(req, reply, {
|
|
656
|
-
sourceRegistry,
|
|
657
|
-
torrentPool,
|
|
658
|
-
// So a session that has produced nothing yet can still show it is being
|
|
659
|
-
// fed. The route knows only files; the session id rides on the URL the
|
|
660
|
-
// session itself built.
|
|
661
|
-
noteInputBytes: (sessionId, bytes) => outputParts.encodeRuns.noteInputBytes(sessionId, bytes),
|
|
662
|
-
// When the encoder reading this waits for its input, so its speed is
|
|
663
|
-
// measured over its own work and not over the swarm's.
|
|
664
|
-
noteInputWaiting: (runToken, waiting) => outputParts.encodeRuns.noteInputWaiting(runToken, waiting)
|
|
665
|
-
})
|
|
666
|
-
);
|
|
667
|
-
app.post("/api/transcode-sessions", async (req, reply) =>
|
|
668
|
-
handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool })
|
|
669
|
-
);
|
|
670
|
-
app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
|
|
671
|
-
handleApiTranscodeSessionReleasePost(req, reply, { lifecycle: outputParts.lifecycle })
|
|
672
|
-
);
|
|
673
|
-
app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
|
|
674
|
-
handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests: outputParts.viewerRequests })
|
|
675
|
-
);
|
|
676
|
-
app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
|
|
677
|
-
// A viewer's statement about itself goes to the VIEWER layer, not through
|
|
678
|
-
// the session manager: what it needs is the live sessions and the registry
|
|
679
|
-
// of viewers, and nothing about encoding.
|
|
680
|
-
handleApiTranscodeSessionNetReportPost(req, reply, {
|
|
681
|
-
outputs: outputParts.outputs,
|
|
682
|
-
viewers: outputParts.viewers,
|
|
683
|
-
renditions: outputParts.renditions,
|
|
684
|
-
quality: outputParts.quality
|
|
685
|
-
})
|
|
686
|
-
);
|
|
687
|
-
app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
|
|
688
|
-
handleApiTranscodeSessionFragmentFarPost(req, reply, { serving: outputParts.serving })
|
|
689
|
-
);
|
|
690
|
-
app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
|
|
691
|
-
handleApiTranscodeSessionSeekPost(req, reply, { viewerRequests: outputParts.viewerRequests })
|
|
692
|
-
);
|
|
693
|
-
app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
|
|
694
|
-
handleTranscodeSessionFileGet(req, reply, { serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
695
|
-
);
|
|
696
|
-
// A quality variant's files. Registered before the static handler for the
|
|
697
|
-
// same reason as the line above, and kept a separate route rather than a
|
|
698
|
-
// wildcard so the height stays a parsed parameter.
|
|
699
|
-
// Registered BEFORE the variant file route: `warm` is not a file name, and
|
|
700
|
-
// Fastify matches a static segment ahead of a parameter either way — stated
|
|
701
|
-
// here so the order is not "tidied" into a bug.
|
|
702
|
-
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
703
|
-
handleTranscodeVariantWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
704
|
-
);
|
|
705
|
-
app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
|
|
706
|
-
handleTranscodeAudioWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
707
|
-
);
|
|
708
|
-
app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
|
|
709
|
-
handleTranscodeAudioFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
710
|
-
);
|
|
711
|
-
app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
|
|
712
|
-
handleTranscodeVariantFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
713
|
-
);
|
|
714
|
-
await app.register(fastifyStatic, {
|
|
715
|
-
root: publicRoot,
|
|
716
|
-
prefix: "/",
|
|
717
|
-
serveDotFiles: true
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
app.addHook("onClose", async () => {
|
|
721
|
-
// Order matters: stop the ffmpeg readers (HLS sessions) before destroying
|
|
722
|
-
// the torrents whose files they read from, then remove the torrent data.
|
|
723
|
-
await outputParts.lifecycle.disposeAll();
|
|
724
|
-
await torrentPool.destroyAll();
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
await app.listen({ host, port: selectedPort });
|
|
728
|
-
return {
|
|
729
|
-
app,
|
|
730
|
-
port: selectedPort,
|
|
731
|
-
// Asked over the tunnel when the proxy a viewer landed on has refused their
|
|
732
|
-
// file: could THIS host sustain it? Answered from the startup benchmarks
|
|
733
|
-
// and a description, so it needs no torrent and costs milliseconds.
|
|
734
|
-
outputParts,
|
|
735
|
-
// The browser only ever knows a source by its REGISTRY key (a hash of the
|
|
736
|
-
// raw request bytes, scoped to one API session) — never the torrent
|
|
737
|
-
// pool's own key (the content's infohash, shared across a magnet and a
|
|
738
|
-
// `.torrent` naming the same film). The subtitle push subscription is
|
|
739
|
-
// recorded from a browser request and published from the pool's side, so
|
|
740
|
-
// resolving one into the other is what lets the two ends agree on what
|
|
741
|
-
// they are both calling "sourceKey".
|
|
742
|
-
sourceRegistry,
|
|
743
|
-
// Which films this host holds, for the health poll the browser makes before
|
|
744
|
-
// it picks a proxy. The pool is on the worker thread and this is the way to
|
|
745
|
-
// it from the process that answers that poll.
|
|
746
|
-
torrentPool
|
|
747
|
-
};
|
|
748
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Proxy HTTP server bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Creates and configures the Fastify application, registers all routes and
|
|
5
|
+
* plugins, then starts listening on the first available port at or above the
|
|
6
|
+
* requested one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Fastify from "fastify";
|
|
10
|
+
import fastifyCors from "@fastify/cors";
|
|
11
|
+
import fastifyHelmet from "@fastify/helmet";
|
|
12
|
+
import fastifyStatic from "@fastify/static";
|
|
13
|
+
import getPort from "get-port";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { handleHealthGet } from "./routes/health/get.js";
|
|
18
|
+
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
19
|
+
import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
|
|
20
|
+
import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
|
|
21
|
+
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
22
|
+
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
23
|
+
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
24
|
+
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
25
|
+
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
26
|
+
import { handleApiPlaybackPlanAudioTracksPost } from "./routes/api/playback-plan/audio-tracks/post.js";
|
|
27
|
+
import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
|
|
28
|
+
import { createClientLogFiles } from "./utils/client-log-file.js";
|
|
29
|
+
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
30
|
+
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
31
|
+
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
32
|
+
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
33
|
+
import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
|
|
34
|
+
import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
|
|
35
|
+
import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
|
|
36
|
+
import { handleStreamGet } from "./routes/stream/get.js";
|
|
37
|
+
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
38
|
+
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
39
|
+
import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
|
|
40
|
+
import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
|
|
41
|
+
import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
|
|
42
|
+
import { createSourceRegistry } from "./store/source-registry.js";
|
|
43
|
+
import { WorkerTorrentPool } from "./services/torrent/worker/pool-adapter.js";
|
|
44
|
+
import { wireOutputs } from "./services/server/wire-outputs.js";
|
|
45
|
+
import { createPlaybackPlanner } from "./services/media/playback-planner.js";
|
|
46
|
+
import { KeyframeTables } from "./services/media/KeyframeTables.js";
|
|
47
|
+
import { contentsOf } from "./services/torrent/Contents.js";
|
|
48
|
+
import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
|
|
49
|
+
import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
|
|
50
|
+
import { viewerStartsOn } from "./services/viewer/PriorityMap.js";
|
|
51
|
+
import { coalescing } from "./utils/coalesce.js";
|
|
52
|
+
import {
|
|
53
|
+
warmSubtitleCues,
|
|
54
|
+
cuesHeldFor,
|
|
55
|
+
subtitleTracksOf,
|
|
56
|
+
declaredSubtitleTracksOf,
|
|
57
|
+
forgetSubtitles
|
|
58
|
+
} from "./services/media/SubtitleCues.js";
|
|
59
|
+
import { detectVideoEncoder, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport, softwareDescriptor } from "./services/encode/hwaccel.js";
|
|
60
|
+
import { calibrateEncoder, HostCalibration } from "./services/encode/calibration.js";
|
|
61
|
+
import { readHostFingerprint } from "./services/encode/fingerprint.js";
|
|
62
|
+
import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
|
|
63
|
+
import { benchmarkAudio } from "./services/encode/audio-calibration.js";
|
|
64
|
+
import { logger } from "./utils/logger.js";
|
|
65
|
+
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
66
|
+
|
|
67
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
68
|
+
const __dirname = path.dirname(__filename);
|
|
69
|
+
const require = createRequire(import.meta.url);
|
|
70
|
+
const { version } = require("./package.json");
|
|
71
|
+
const publicRoot = path.resolve(__dirname, "./public");
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a list of candidate port numbers starting at `startPort`.
|
|
75
|
+
*
|
|
76
|
+
* @param {number} startPort
|
|
77
|
+
* @param {number} [maxAttempts=51]
|
|
78
|
+
* @returns {number[]}
|
|
79
|
+
*/
|
|
80
|
+
function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
81
|
+
const ports = [];
|
|
82
|
+
for (let index = 0; index < maxAttempts; index += 1) {
|
|
83
|
+
ports.push(startPort + index);
|
|
84
|
+
}
|
|
85
|
+
return ports;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {Object} ProxyServerOptions
|
|
90
|
+
* @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
|
|
91
|
+
* @property {number} port - Preferred listen port.
|
|
92
|
+
* @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
|
|
93
|
+
* @property {string} ffmpegBin - Path to the ffmpeg executable.
|
|
94
|
+
* @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
|
|
95
|
+
* @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
|
|
96
|
+
* @property {string} [stateDir] - Where to keep what this host has measured about itself.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create, configure, and start the proxy HTTP server.
|
|
101
|
+
*
|
|
102
|
+
* @param {ProxyServerOptions} options
|
|
103
|
+
* @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
|
|
104
|
+
*/
|
|
105
|
+
export async function startProxyServer({
|
|
106
|
+
host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
|
|
107
|
+
deliverySink = false,
|
|
108
|
+
// Where the proxy writes its own log. Its DIRECTORY is what matters here:
|
|
109
|
+
// the browser's half of every session is written beside it, so the two are
|
|
110
|
+
// on one durable disk and join by name.
|
|
111
|
+
logFile = ""
|
|
112
|
+
}) {
|
|
113
|
+
const app = Fastify({
|
|
114
|
+
// No practical body-size limit — the proxy server is localhost-only and
|
|
115
|
+
// receives torrent source payloads that may be arbitrarily large.
|
|
116
|
+
bodyLimit: 256 * 1024 * 1024 // 256 MB
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await app.register(fastifyHelmet, {
|
|
120
|
+
// Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
|
|
121
|
+
crossOriginResourcePolicy: {
|
|
122
|
+
policy: "cross-origin"
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
await app.register(fastifyCors, {
|
|
126
|
+
origin: true,
|
|
127
|
+
methods: ["GET", "POST", "OPTIONS"],
|
|
128
|
+
allowedHeaders: ["Content-Type", "Range"]
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Allow browser requests from an HTTPS page to this private-network proxy
|
|
132
|
+
// without triggering Chromium's Private Network Access permission prompt.
|
|
133
|
+
app.addHook("onRequest", async (_req, reply) => {
|
|
134
|
+
reply.header("Access-Control-Allow-Private-Network", "true");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const sourceRegistry = createSourceRegistry(200);
|
|
138
|
+
// The torrent runs on its own thread. Profiling a live seek (2026-08-02)
|
|
139
|
+
// found the main thread ~85% occupied by WebTorrent — buffer concatenation
|
|
140
|
+
// ~15%, wire updates ~9%, garbage collection ~5% — while three of four cores
|
|
141
|
+
// idled. Serving a segment shared that thread, so reading an already-finished
|
|
142
|
+
// 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
|
|
143
|
+
// adapter keeps TorrentPool's interface, so nothing downstream changed.
|
|
144
|
+
const torrentPool = new WorkerTorrentPool({
|
|
145
|
+
memoryBytes,
|
|
146
|
+
stateDir,
|
|
147
|
+
// Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
|
|
148
|
+
// done about it — walking a file's new subtitle clusters and pushing what
|
|
149
|
+
// came out — happens here, because that is a reading of what the file says
|
|
150
|
+
// about itself. The walk used to run in that thread, which is what put a
|
|
151
|
+
// container parse there.
|
|
152
|
+
// The ONE forward reference here, and it is safe by construction: this fires
|
|
153
|
+
// only once the worker thread is up and a piece has verified, which is long
|
|
154
|
+
// after the declarations below have run.
|
|
155
|
+
onPiecesArrived: ({ sourceKey, fileIndexes }) => {
|
|
156
|
+
for (const fileIndex of fileIndexes) {
|
|
157
|
+
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
158
|
+
// A keyframe table somebody asked for while its bytes had not arrived
|
|
159
|
+
// is read again now; nothing was recorded for that wait.
|
|
160
|
+
keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
const selectedPort = await getPort({
|
|
165
|
+
port: buildPortCandidates(port)
|
|
166
|
+
});
|
|
167
|
+
// Auto-detect the best available H.264 encoder (hardware-accelerated or
|
|
168
|
+
// software) once at startup, with a real test-encode and graceful fallback.
|
|
169
|
+
// Only needed when transcoding can occur.
|
|
170
|
+
let videoEncoder = transcodeAudio
|
|
171
|
+
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
172
|
+
: null;
|
|
173
|
+
// WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
|
|
174
|
+
// was gated on the chosen encoder being SOFTWARE, and two of the three are
|
|
175
|
+
// not about the encoder at all: a host with a GPU decodes in software just
|
|
176
|
+
// the same — no hardware decoder is asked for anywhere — and what a second
|
|
177
|
+
// job costs is a property of the machine. So a GPU host had no decode cost,
|
|
178
|
+
// no contention penalty and no encoder throughput, and the quality offer,
|
|
179
|
+
// which is arithmetic over those three, had nothing to compute from.
|
|
180
|
+
//
|
|
181
|
+
// The decode model first and the throughput second — they are independent
|
|
182
|
+
// (the rungs are timed on raw frames), but the order keeps the two figures
|
|
183
|
+
// side by side in the log.
|
|
184
|
+
const decodeCostModel = transcodeAudio
|
|
185
|
+
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
186
|
+
: null;
|
|
187
|
+
// What a second job costs on this host. Measured because the budget adds
|
|
188
|
+
// independent prices and this host says two jobs that each fit alone do not
|
|
189
|
+
// fit together — 2.6× on the addon box (2026-08-18).
|
|
190
|
+
const contentionPenalties = transcodeAudio
|
|
191
|
+
? await benchmarkContention({ ffmpegBin, logger })
|
|
192
|
+
: null;
|
|
193
|
+
// WHICH MODES THIS HOST MAY ENCODE WITH, AND WHAT EACH COSTS AT EVERY SIZE
|
|
194
|
+
// (roadmap item 97, step 14). Every setting an output can actually be
|
|
195
|
+
// encoded at is put through the product's own arguments and its segments
|
|
196
|
+
// decoded, then timed at the sizes of the ladder; a mode that fails either is
|
|
197
|
+
// not used, and a size nothing was timed at is not offered. Software is
|
|
198
|
+
// calibrated too when detection chose a device: it is what a failing device
|
|
199
|
+
// falls back to for the rest of the process, and it must not be priced by
|
|
200
|
+
// the device's figures.
|
|
201
|
+
//
|
|
202
|
+
// And WHICH CONFIGURATION all of it describes — the ffmpeg and x264 builds,
|
|
203
|
+
// the processor and the threads, the device and its driver — which is what
|
|
204
|
+
// decides whether anything kept from an earlier run still applies.
|
|
205
|
+
const fingerprint = videoEncoder
|
|
206
|
+
? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder })
|
|
207
|
+
: null;
|
|
208
|
+
/** @type {Record<string, object[]>} */
|
|
209
|
+
const calibratedModes = {};
|
|
210
|
+
if (videoEncoder) {
|
|
211
|
+
calibratedModes[videoEncoder.kind] =
|
|
212
|
+
(await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger })).modes;
|
|
213
|
+
if (videoEncoder.kind !== "software") {
|
|
214
|
+
calibratedModes.software =
|
|
215
|
+
(await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger })).modes;
|
|
216
|
+
// A device that passed detection's own test and then none of whose modes
|
|
217
|
+
// passed the product's is not used: detection encodes a synthetic pattern
|
|
218
|
+
// with arguments of its own, and it is the product's arguments a viewer
|
|
219
|
+
// is served with.
|
|
220
|
+
if (calibratedModes[videoEncoder.kind].length === 0) {
|
|
221
|
+
logger.warn(
|
|
222
|
+
`calibration: ${videoEncoder.name} passed detection but none of its modes produced correct segments ` +
|
|
223
|
+
"through the product's own arguments; encoding in software instead"
|
|
224
|
+
);
|
|
225
|
+
videoEncoder = softwareDescriptor();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const calibration = videoEncoder ? new HostCalibration({ byKind: calibratedModes, fingerprint }) : null;
|
|
230
|
+
// What this host does with a picture it does NOT re-encode. Every other
|
|
231
|
+
// startup measurement prices encoding or decoding, and a copied picture does
|
|
232
|
+
// neither — it reads packets and writes them out again — so that whole branch
|
|
233
|
+
// had no speed until its own run had been running long enough to report one.
|
|
234
|
+
// The encoding layer decides where encoders go from arrivals, and an arrival
|
|
235
|
+
// cannot be computed without a speed, so the moment it mattered most was the
|
|
236
|
+
// moment nothing was known. Measured whatever the encoder is: copying does not
|
|
237
|
+
// touch it.
|
|
238
|
+
const copySpeedX = transcodeAudio
|
|
239
|
+
? await benchmarkCopySpeed({ ffmpegBin, logger })
|
|
240
|
+
: null;
|
|
241
|
+
const audioCalibration = transcodeAudio
|
|
242
|
+
? await benchmarkAudio({ ffmpegBin, logger })
|
|
243
|
+
: [];
|
|
244
|
+
// WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
|
|
245
|
+
// thing — leave an encoder where it stands, or kill it and start another —
|
|
246
|
+
// and both used to be learned only from runs that had ENDED, so at a cold
|
|
247
|
+
// open they were zero. Zero does not read as "not measured": it reads as
|
|
248
|
+
// "free", and a free move is always taken. Field 2026-09-08: an encoder moved
|
|
249
|
+
// between two adjacent numbers every half second and produced nothing.
|
|
250
|
+
const startStopCost = transcodeAudio
|
|
251
|
+
? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
|
|
252
|
+
: null;
|
|
253
|
+
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
254
|
+
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
255
|
+
// sources on the software path when available.
|
|
256
|
+
const tonemapSupported = transcodeAudio
|
|
257
|
+
? await detectTonemapSupport({ ffmpegBin, logger })
|
|
258
|
+
: false;
|
|
259
|
+
// Where every file's keyframe table lives, and the only thing that reads one.
|
|
260
|
+
// Built here rather than inside the session manager because it is not a fact
|
|
261
|
+
// about a session: the playback planner warms it while a file is being opened
|
|
262
|
+
// and no session exists yet, and a session created later is handed the very
|
|
263
|
+
// object that warm read filled in.
|
|
264
|
+
// Subtitles are two layers put together, and this is the one place that knows
|
|
265
|
+
// about both: what a file STATES about its subtitle tracks and how a cue is
|
|
266
|
+
// written out is the media layer's, while which clusters may be read, where
|
|
267
|
+
// the cursor stands and what one walk at a time means are the TORRENT's own
|
|
268
|
+
// rules. The media layer used to import the second directly.
|
|
269
|
+
/**
|
|
270
|
+
* One file of one torrent, reduced to what the subtitle walk may know: a
|
|
271
|
+
* name, a length, its one container, which ranges are downloaded whole, how
|
|
272
|
+
* to read one of those without asking the swarm, the portion one read may
|
|
273
|
+
* span, and where the viewers stand. File offsets and the bitfield stay in
|
|
274
|
+
* the torrent's thread; the piece length crosses as a plain number.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} sourceKey
|
|
277
|
+
* @param {number} fileIndex
|
|
278
|
+
* @param {object} [known] - The torrent handle when the caller already has it.
|
|
279
|
+
* @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
|
|
280
|
+
*/
|
|
281
|
+
const heldFileFor = async (sourceKey, fileIndex, known = null) => {
|
|
282
|
+
let torrent = known;
|
|
283
|
+
if (!torrent) {
|
|
284
|
+
const record = sourceRegistry.get(sourceKey);
|
|
285
|
+
if (!record) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
289
|
+
}
|
|
290
|
+
const file = torrent?.files?.[fileIndex];
|
|
291
|
+
if (!file || !(file.length > 0)) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
sourceKey,
|
|
296
|
+
fileIndex,
|
|
297
|
+
name: String(file.name ?? ""),
|
|
298
|
+
length: file.length,
|
|
299
|
+
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
|
|
300
|
+
// The file's ONE container — the one the track table, the media info and
|
|
301
|
+
// the keyframe table are read from. Built here only when nobody has built
|
|
302
|
+
// it yet, so a pass of the walk does not fetch the file's edges again.
|
|
303
|
+
container: async () => {
|
|
304
|
+
const known = containerOrchestrator.known(sourceKey, fileIndex);
|
|
305
|
+
if (known !== undefined) {
|
|
306
|
+
return known;
|
|
307
|
+
}
|
|
308
|
+
const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
|
|
309
|
+
return params ? containerOrchestrator.containerFor(params) : null;
|
|
310
|
+
},
|
|
311
|
+
heldRanges: () => torrentPool.heldRangesOf(torrent, fileIndex),
|
|
312
|
+
readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end),
|
|
313
|
+
// Where the viewers of this file stand: the start of every urgent stretch
|
|
314
|
+
// of its priority map, which is where each viewer's own need begins.
|
|
315
|
+
wantedSeconds: () => viewerStartsOn(outputParts.priority?.mapFor(sourceKey, fileIndex)),
|
|
316
|
+
// A pass left readable clusters for the next one: run it.
|
|
317
|
+
askAgain: () => {
|
|
318
|
+
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* Walk whatever new cues a file now holds and push them to the viewers.
|
|
324
|
+
*
|
|
325
|
+
* One walk of a file at a time, and one more after it whenever pieces
|
|
326
|
+
* arrived meanwhile (`coalescing`): `verified` fires per piece, so a queue of
|
|
327
|
+
* identical passes would only postpone the one with something new to find,
|
|
328
|
+
* and dropping them — as this used to — lost the pass that would have found
|
|
329
|
+
* the last pieces of a file.
|
|
330
|
+
*/
|
|
331
|
+
const pushFreshCues = coalescing(async (sourceKey, fileIndex) => {
|
|
332
|
+
try {
|
|
333
|
+
const file = await heldFileFor(sourceKey, fileIndex);
|
|
334
|
+
if (!file) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
for (const entry of await warmSubtitleCues(file)) {
|
|
338
|
+
const span = entry.spanStartSeconds === null
|
|
339
|
+
? "empty"
|
|
340
|
+
: `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
|
|
341
|
+
logger.info(
|
|
342
|
+
`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
|
|
343
|
+
`${entry.cues.length} new cue(s) covering ${span}` +
|
|
344
|
+
(entry.withdrawn.length > 0 ? `, ${entry.withdrawn.length} taken back` : "") +
|
|
345
|
+
`, clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
|
|
346
|
+
);
|
|
347
|
+
onSubtitleCues?.({ sourceKey, fileIndex, ...entry });
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
logger.warn(`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} failed: ${error?.message ?? error}`);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
|
|
354
|
+
warm: async (torrent, fileIndex, sourceKey) => {
|
|
355
|
+
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
356
|
+
return file ? warmSubtitleCues(file) : [];
|
|
357
|
+
},
|
|
358
|
+
held: async (torrent, fileIndex, sourceKey, trackNumber) => {
|
|
359
|
+
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
360
|
+
return file ? cuesHeldFor(file, trackNumber) : null;
|
|
361
|
+
},
|
|
362
|
+
tracksOf: async (torrent, fileIndex, sourceKey) => {
|
|
363
|
+
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
364
|
+
return file ? subtitleTracksOf(file) : [];
|
|
365
|
+
},
|
|
366
|
+
declaredTracksOf: async (torrent, fileIndex, sourceKey) => {
|
|
367
|
+
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
368
|
+
return file ? declaredSubtitleTracksOf(file) : [];
|
|
369
|
+
},
|
|
370
|
+
forget: forgetSubtitles
|
|
371
|
+
});
|
|
372
|
+
// WHAT A FILE STATES ABOUT ITSELF IS READ HERE, ON THIS THREAD.
|
|
373
|
+
//
|
|
374
|
+
// The container layer is built from one function — `readRange(start, end)` —
|
|
375
|
+
// and the bytes behind it live in shared memory, so the parse has no reason
|
|
376
|
+
// to happen anywhere else. It used to happen in the torrent thread for one
|
|
377
|
+
// stated reason, that "the main thread cannot open a read stream on one of
|
|
378
|
+
// its files": true of WebTorrent's own API, and not of the bytes. The price
|
|
379
|
+
// was three commands, a second `ContainerOrchestrator` in that thread, and
|
|
380
|
+
// every answer carried back across the channel — which is what made "where is
|
|
381
|
+
// this fact kept" a question at all.
|
|
382
|
+
//
|
|
383
|
+
// The edges are fetched first because the file this is asked about is usually
|
|
384
|
+
// one nobody has played: a sidecar soundtrack is asked about before anyone
|
|
385
|
+
// has chosen it, and a Matroska file's Cues sit at the END behind a SeekHead
|
|
386
|
+
// in the head, so a read that has neither waits for the swarm twice over.
|
|
387
|
+
const containerOver = async ({ sourceKey, fileIndex, tailBytes = 0 }) => {
|
|
388
|
+
const record = sourceRegistry.get(sourceKey);
|
|
389
|
+
if (!record) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
393
|
+
const file = torrent?.files?.[fileIndex];
|
|
394
|
+
if (!file || !(file.length > 0)) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex, {
|
|
399
|
+
headBytes: CONTAINER_HEAD_BYTES,
|
|
400
|
+
tailBytes,
|
|
401
|
+
timeoutMs: 60_000
|
|
402
|
+
});
|
|
403
|
+
} catch {
|
|
404
|
+
// A prefetch that failed is not a reason to skip the read: the read
|
|
405
|
+
// fetches what it needs itself, only more slowly.
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
sourceKey,
|
|
409
|
+
fileIndex,
|
|
410
|
+
readRange: (start, end) =>
|
|
411
|
+
torrentPool.readRangeOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
|
|
412
|
+
fileSize: file.length,
|
|
413
|
+
label: String(file.name ?? ""),
|
|
414
|
+
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
const keyframeTables = new KeyframeTables({
|
|
418
|
+
// Read by the same container that answers the track table and the media
|
|
419
|
+
// info, from the same header, and now in the same thread as the session
|
|
420
|
+
// that is waiting for it.
|
|
421
|
+
readTable: async ({ sourceKey, fileIndex }) => {
|
|
422
|
+
// Both edges: the Cues of a Matroska file are at the end.
|
|
423
|
+
const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
|
|
424
|
+
if (!params) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
const index = await containerOrchestrator.getKeyframeIndex(params);
|
|
428
|
+
return {
|
|
429
|
+
times: index?.times ?? null,
|
|
430
|
+
tolerance: index?.tolerance ?? 0,
|
|
431
|
+
// Which container answered, whether or not it produced a table: the
|
|
432
|
+
// refusal that follows names it, and a measurement of how often an
|
|
433
|
+
// index disagrees with its own file cannot be read without it.
|
|
434
|
+
format: (await containerOrchestrator.getContainer(params))?.formatName ?? "unrecognised"
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
const outputParts = wireOutputs({
|
|
439
|
+
enabled: transcodeAudio,
|
|
440
|
+
keyframeTables,
|
|
441
|
+
ffmpegBin,
|
|
442
|
+
localBindHost: host,
|
|
443
|
+
localPort: selectedPort,
|
|
444
|
+
videoEncoder,
|
|
445
|
+
calibration,
|
|
446
|
+
audioCalibration,
|
|
447
|
+
decodeCostModel,
|
|
448
|
+
contentionPenalties,
|
|
449
|
+
copySpeedX,
|
|
450
|
+
startStopCost,
|
|
451
|
+
tonemapSupported,
|
|
452
|
+
segmentFormatId: segmentFormat,
|
|
453
|
+
stateDir,
|
|
454
|
+
diagnostics,
|
|
455
|
+
// Live download stats accessor for the realtime budget: lets it tell a
|
|
456
|
+
// CPU-bound transcode from a download-starved input before downscaling.
|
|
457
|
+
// What every torrent here has moved, so the proxy can price its own
|
|
458
|
+
// downloading, hashing and delivery against the machine (roadmap item 7).
|
|
459
|
+
// What the spilled pieces weigh on the torrent thread, and how to tell them
|
|
460
|
+
// their share of the disk. The owner of the disk is on this side, where the
|
|
461
|
+
// segments are; the pieces are on the other.
|
|
462
|
+
spillDisk: typeof torrentPool.allowSpillBytes === "function"
|
|
463
|
+
? {
|
|
464
|
+
held: () => torrentPool.spilledBytes ?? 0,
|
|
465
|
+
allow: (bytes) => torrentPool.allowSpillBytes(bytes)
|
|
466
|
+
}
|
|
467
|
+
: null,
|
|
468
|
+
// Files downloaded whole, kept as files. They live on the torrent thread
|
|
469
|
+
// too, and until 2026-09-14 they had no bound of any kind — a 2.8 GB film
|
|
470
|
+
// on a host whose disk is often a 32 GB card.
|
|
471
|
+
wholeFiles: typeof torrentPool.allowWholeFileBytes === "function"
|
|
472
|
+
? {
|
|
473
|
+
held: () => torrentPool.wholeFileBytes ?? 0,
|
|
474
|
+
allow: (bytes) => torrentPool.allowWholeFileBytes(bytes),
|
|
475
|
+
root: completedFilesRoot()
|
|
476
|
+
}
|
|
477
|
+
: null,
|
|
478
|
+
// The pieces held IN memory, on the torrent thread. Told their share by the
|
|
479
|
+
// same owner that divides the disk: what does not fit here is spilled
|
|
480
|
+
// there, so the two cannot be divided apart.
|
|
481
|
+
memoryClaimant: typeof torrentPool.allowMemoryBytes === "function"
|
|
482
|
+
? {
|
|
483
|
+
held: () => torrentPool.memoryClaim?.held ?? 0,
|
|
484
|
+
wanted: () => torrentPool.memoryClaim?.wanted ?? 0,
|
|
485
|
+
allow: (bytes) => torrentPool.allowMemoryBytes(bytes)
|
|
486
|
+
}
|
|
487
|
+
: null,
|
|
488
|
+
diagnosticsRoot: stateDir || "",
|
|
489
|
+
budgetPolicy,
|
|
490
|
+
getTorrentTotals: async () => {
|
|
491
|
+
if (typeof torrentPool.getTorrentTotals !== "function") {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
return torrentPool.getTorrentTotals();
|
|
495
|
+
},
|
|
496
|
+
// The priority map, on its way to the downloading. It lives in another
|
|
497
|
+
// thread, so the map crosses the worker channel; what it does with it —
|
|
498
|
+
// seconds into bytes, what to ask the swarm for, what to keep in memory —
|
|
499
|
+
// is its own business.
|
|
500
|
+
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
501
|
+
const record = sourceRegistry.get(sourceKey);
|
|
502
|
+
if (!record) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
|
|
507
|
+
} catch {
|
|
508
|
+
// Best effort: the map is republished on the next change, and the
|
|
509
|
+
// downloading goes on serving reads meanwhile.
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
getSourceStats: async (sourceKey, fileIndex) => {
|
|
513
|
+
const record = sourceRegistry.get(sourceKey);
|
|
514
|
+
if (!record) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
519
|
+
// Awaited for the same reason as the stats route: this now crosses a
|
|
520
|
+
// thread boundary and returns a promise.
|
|
521
|
+
return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
// Reuse the media info the planner already probed for this file (same
|
|
527
|
+
// ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
|
|
528
|
+
// only at session-create time, after playbackPlanner is initialised.
|
|
529
|
+
getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
|
|
530
|
+
// The file's audio tracks, for the master playlist's rendition group. Already
|
|
531
|
+
// probed for the browser's audio menu; read from there rather than probed again.
|
|
532
|
+
getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
|
|
533
|
+
// What a file declares about itself, read by the container layer from the
|
|
534
|
+
// same header its track table comes from. This is how the session learns
|
|
535
|
+
// where a soundtrack shipped as its own file begins — it used to spawn an
|
|
536
|
+
// ffmpeg over this proxy's own HTTP to ask the same question of the same
|
|
537
|
+
// bytes, and that read cost 8.1 s of every cold start (field 2026-09-03).
|
|
538
|
+
getContainerMediaInfo: async ({ sourceKey, fileIndex }) => {
|
|
539
|
+
try {
|
|
540
|
+
const params = await containerOver({ sourceKey, fileIndex });
|
|
541
|
+
return params ? await containerOrchestrator.getMediaInfo(params) : null;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
547
|
+
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
548
|
+
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
549
|
+
// spare. A bounded read of the file's whole length, NOT `file.select()`:
|
|
550
|
+
// selecting a file alongside the readers' own windows is what made a seek
|
|
551
|
+
// wait 93 s while the swarm fetched 2.47 GB in file order (see
|
|
552
|
+
// `#syncSelections` in `torrent/torrent-pool.js`).
|
|
553
|
+
fetchWholeFile: async ({ sourceKey, fileIndex }) => {
|
|
554
|
+
const record = sourceRegistry.get(sourceKey);
|
|
555
|
+
if (!record) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
559
|
+
// The same background fill the warm-up starts when a file is chosen, not a
|
|
560
|
+
// second way of doing it. It is guarded against running twice on one file,
|
|
561
|
+
// so the two triggers converge instead of putting two readers on the same
|
|
562
|
+
// soundtrack — and only one of them would have stood aside for the
|
|
563
|
+
// picture. This trigger remains for the session that never had a warm-up
|
|
564
|
+
// before it.
|
|
565
|
+
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
// What the last life of this process left on the disk. The kernel kills this
|
|
569
|
+
// one often enough for that to be an ordinary state rather than an odd one —
|
|
570
|
+
// twice in a single viewing on 2026-09-02 — and when it does, no exit handler
|
|
571
|
+
// runs and nothing is cleared up. So this is both the cleanup and the only
|
|
572
|
+
// record that those encoders ended at all: it says what it found before it
|
|
573
|
+
// decides anything, keeps the segments whose closure is proven, and removes
|
|
574
|
+
// the one piece per output that was being written when the process died.
|
|
575
|
+
outputParts.lifecycle.adoptSegmentsLeftBehind();
|
|
576
|
+
const playbackPlanner = createPlaybackPlanner({
|
|
577
|
+
ffmpegBin,
|
|
578
|
+
transcodeAudioEnabled: transcodeAudio,
|
|
579
|
+
localBaseUrl: outputParts.localBaseUrl,
|
|
580
|
+
sourceRegistry,
|
|
581
|
+
torrentPool,
|
|
582
|
+
sidecarsFromTorrent: (torrent, fileIndex) => contentsOf(torrent).sidecarsOf(fileIndex),
|
|
583
|
+
// What a file declares about its own tracks, parsed on this thread from the
|
|
584
|
+
// header the swarm delivered. The planner used to ask the torrent pool for
|
|
585
|
+
// this, which meant the media layer asking the torrent layer to parse a
|
|
586
|
+
// container on its behalf, in the other thread, with the answer carried
|
|
587
|
+
// back over the channel.
|
|
588
|
+
declaredTracksOf: async ({ sourceKey, fileIndex }) => {
|
|
589
|
+
const params = await containerOver({ sourceKey, fileIndex });
|
|
590
|
+
return params ? await containerOrchestrator.getTracks(params) : [];
|
|
591
|
+
},
|
|
592
|
+
warmKeyframeIndex: (params) => keyframeTables.warm(params),
|
|
593
|
+
expectedFirstSegmentMs: () => outputParts.hostTimings.expectedFirstSegmentMs(),
|
|
594
|
+
expectedSessionCreateMs: () => outputParts.hostTimings.expectedSessionCreateMs(),
|
|
595
|
+
// The quality menu is on screen from the moment a file is opened, so the
|
|
596
|
+
// heights this host can actually serve have to be answerable before any
|
|
597
|
+
// encoder exists — from the probe and the startup benchmarks alone.
|
|
598
|
+
predictOfferedHeights: (mediaInfo) => outputParts.quality.predictOfferedHeights(mediaInfo)
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
602
|
+
app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
|
|
603
|
+
// Off unless --delivery-sink was given; see the route for why it exists.
|
|
604
|
+
app.get("/api/delivery-sink", async (req, reply) =>
|
|
605
|
+
handleApiDeliverySinkGet(req, reply, { enabled: deliverySink === true })
|
|
606
|
+
);
|
|
607
|
+
// Bytes a browser can time its link with before any film has been chosen.
|
|
608
|
+
app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
|
|
609
|
+
app.post("/api/sources", async (req, reply) =>
|
|
610
|
+
handleApiSourcesPost(req, reply, { sourceRegistry })
|
|
611
|
+
);
|
|
612
|
+
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
613
|
+
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
|
|
614
|
+
);
|
|
615
|
+
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
616
|
+
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
|
|
617
|
+
);
|
|
618
|
+
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
619
|
+
handleApiSourceWarmPost(req, reply, {
|
|
620
|
+
sourceRegistry,
|
|
621
|
+
torrentPool,
|
|
622
|
+
playbackPlanner,
|
|
623
|
+
// How long the file runs, read on this thread from the header. The warm
|
|
624
|
+
// turns a position in seconds into a byte offset and needs it; it used to
|
|
625
|
+
// read the container itself, in the torrent thread, to find out.
|
|
626
|
+
durationOf: async (params) => {
|
|
627
|
+
const over = await containerOver(params);
|
|
628
|
+
return over ? (await containerOrchestrator.getMediaInfo(over))?.durationSeconds ?? null : null;
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
);
|
|
632
|
+
// The browser's own log, kept beside the proxy's. See the route's own file
|
|
633
|
+
// for why it is here and not only on the registry server.
|
|
634
|
+
const clientLogs = logFile ? createClientLogFiles(logFile) : null;
|
|
635
|
+
app.post("/api/client-logs", async (req, reply) =>
|
|
636
|
+
handleApiClientLogsPost(req, reply, { clientLogs })
|
|
637
|
+
);
|
|
638
|
+
app.post("/api/playback-plan", async (req, reply) =>
|
|
639
|
+
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
640
|
+
);
|
|
641
|
+
app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
|
|
642
|
+
handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
|
|
643
|
+
);
|
|
644
|
+
app.get("/api/subtitles", async (req, reply) =>
|
|
645
|
+
handleApiSubtitlesGet(req, reply, {
|
|
646
|
+
sourceRegistry,
|
|
647
|
+
torrentPool,
|
|
648
|
+
ffmpegBin,
|
|
649
|
+
localBaseUrl: outputParts.localBaseUrl,
|
|
650
|
+
viewers: outputParts.viewers,
|
|
651
|
+
subtitles
|
|
652
|
+
})
|
|
653
|
+
);
|
|
654
|
+
app.get("/stream", async (req, reply) =>
|
|
655
|
+
handleStreamGet(req, reply, {
|
|
656
|
+
sourceRegistry,
|
|
657
|
+
torrentPool,
|
|
658
|
+
// So a session that has produced nothing yet can still show it is being
|
|
659
|
+
// fed. The route knows only files; the session id rides on the URL the
|
|
660
|
+
// session itself built.
|
|
661
|
+
noteInputBytes: (sessionId, bytes) => outputParts.encodeRuns.noteInputBytes(sessionId, bytes),
|
|
662
|
+
// When the encoder reading this waits for its input, so its speed is
|
|
663
|
+
// measured over its own work and not over the swarm's.
|
|
664
|
+
noteInputWaiting: (runToken, waiting) => outputParts.encodeRuns.noteInputWaiting(runToken, waiting)
|
|
665
|
+
})
|
|
666
|
+
);
|
|
667
|
+
app.post("/api/transcode-sessions", async (req, reply) =>
|
|
668
|
+
handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool })
|
|
669
|
+
);
|
|
670
|
+
app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
|
|
671
|
+
handleApiTranscodeSessionReleasePost(req, reply, { lifecycle: outputParts.lifecycle })
|
|
672
|
+
);
|
|
673
|
+
app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
|
|
674
|
+
handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests: outputParts.viewerRequests })
|
|
675
|
+
);
|
|
676
|
+
app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
|
|
677
|
+
// A viewer's statement about itself goes to the VIEWER layer, not through
|
|
678
|
+
// the session manager: what it needs is the live sessions and the registry
|
|
679
|
+
// of viewers, and nothing about encoding.
|
|
680
|
+
handleApiTranscodeSessionNetReportPost(req, reply, {
|
|
681
|
+
outputs: outputParts.outputs,
|
|
682
|
+
viewers: outputParts.viewers,
|
|
683
|
+
renditions: outputParts.renditions,
|
|
684
|
+
quality: outputParts.quality
|
|
685
|
+
})
|
|
686
|
+
);
|
|
687
|
+
app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
|
|
688
|
+
handleApiTranscodeSessionFragmentFarPost(req, reply, { serving: outputParts.serving })
|
|
689
|
+
);
|
|
690
|
+
app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
|
|
691
|
+
handleApiTranscodeSessionSeekPost(req, reply, { viewerRequests: outputParts.viewerRequests })
|
|
692
|
+
);
|
|
693
|
+
app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
|
|
694
|
+
handleTranscodeSessionFileGet(req, reply, { serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
695
|
+
);
|
|
696
|
+
// A quality variant's files. Registered before the static handler for the
|
|
697
|
+
// same reason as the line above, and kept a separate route rather than a
|
|
698
|
+
// wildcard so the height stays a parsed parameter.
|
|
699
|
+
// Registered BEFORE the variant file route: `warm` is not a file name, and
|
|
700
|
+
// Fastify matches a static segment ahead of a parameter either way — stated
|
|
701
|
+
// here so the order is not "tidied" into a bug.
|
|
702
|
+
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
703
|
+
handleTranscodeVariantWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
704
|
+
);
|
|
705
|
+
app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
|
|
706
|
+
handleTranscodeAudioWarmGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
707
|
+
);
|
|
708
|
+
app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
|
|
709
|
+
handleTranscodeAudioFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
710
|
+
);
|
|
711
|
+
app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
|
|
712
|
+
handleTranscodeVariantFileGet(req, reply, { renditions: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
|
|
713
|
+
);
|
|
714
|
+
await app.register(fastifyStatic, {
|
|
715
|
+
root: publicRoot,
|
|
716
|
+
prefix: "/",
|
|
717
|
+
serveDotFiles: true
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
app.addHook("onClose", async () => {
|
|
721
|
+
// Order matters: stop the ffmpeg readers (HLS sessions) before destroying
|
|
722
|
+
// the torrents whose files they read from, then remove the torrent data.
|
|
723
|
+
await outputParts.lifecycle.disposeAll();
|
|
724
|
+
await torrentPool.destroyAll();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
await app.listen({ host, port: selectedPort });
|
|
728
|
+
return {
|
|
729
|
+
app,
|
|
730
|
+
port: selectedPort,
|
|
731
|
+
// Asked over the tunnel when the proxy a viewer landed on has refused their
|
|
732
|
+
// file: could THIS host sustain it? Answered from the startup benchmarks
|
|
733
|
+
// and a description, so it needs no torrent and costs milliseconds.
|
|
734
|
+
outputParts,
|
|
735
|
+
// The browser only ever knows a source by its REGISTRY key (a hash of the
|
|
736
|
+
// raw request bytes, scoped to one API session) — never the torrent
|
|
737
|
+
// pool's own key (the content's infohash, shared across a magnet and a
|
|
738
|
+
// `.torrent` naming the same film). The subtitle push subscription is
|
|
739
|
+
// recorded from a browser request and published from the pool's side, so
|
|
740
|
+
// resolving one into the other is what lets the two ends agree on what
|
|
741
|
+
// they are both calling "sourceKey".
|
|
742
|
+
sourceRegistry,
|
|
743
|
+
// Which films this host holds, for the health poll the browser makes before
|
|
744
|
+
// it picks a proxy. The pool is on the worker thread and this is the way to
|
|
745
|
+
// it from the process that answers that poll.
|
|
746
|
+
torrentPool
|
|
747
|
+
};
|
|
748
|
+
}
|