@torrent-tv/proxy 2.85.1 → 2.87.0
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/CHANGELOG.md +2138 -1909
- package/CLAUDE.md +318 -197
- package/assets/calibration/NOTICE.md +2 -2
- package/bin/cli.js +684 -616
- package/biome.json +409 -233
- package/docs/container-architecture.md +218 -213
- package/docs/download-architecture.md +7 -7
- package/docs/encode-architecture.md +287 -11
- package/docs/encode-run-state.md +2 -2
- package/docs/logs.md +97 -97
- package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
- package/openspec/changes/adaptive-bitrate/design.md +2 -2
- package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
- package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
- package/openspec/changes/chunked-request-bodies/design.md +1 -1
- package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
- package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
- package/openspec/changes/cold-start/design.md +1 -1
- package/openspec/changes/disk-cap/proposal.md +1 -1
- package/openspec/changes/proxy-observability/proposal.md +2 -2
- package/openspec/changes/track-selection/proposal.md +1 -1
- package/openspec/changes/transcode-quality/proposal.md +1 -1
- package/package.json +2 -2
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/routes/api/playback-plan/post.js +2 -2
- package/routes/api/sources/files/get.js +1 -1
- package/routes/api/sources/stats/get.js +1 -1
- package/routes/api/sources/warm/post.js +10 -3
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
- package/routes/api/transcode-sessions/net-report/post.js +129 -94
- package/routes/api/transcode-sessions/post.js +160 -139
- package/routes/api/transcode-sessions/progress/get.js +3 -3
- package/routes/api/transcode-sessions/release/post.js +3 -3
- package/routes/api/transcode-sessions/seek/post.js +12 -3
- package/routes/stream/get.js +2 -2
- package/routes/transcode/audio-file/get.js +15 -6
- package/routes/transcode/audio-warm/get.js +8 -5
- package/routes/transcode/session-file/get.js +429 -268
- package/routes/transcode/variant-file/get.js +47 -7
- package/routes/transcode/variant-warm/get.js +18 -6
- package/scripts/render-run-graph.js +1 -1
- package/server.js +720 -465
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -0
- package/services/encode/EncodeAdmission.js +332 -0
- package/services/{orchestrators → encode}/EncodeOrchestrator.js +285 -17
- package/services/encode/EncodeRun.js +16 -9
- package/services/encode/EncodeRuns.js +1219 -0
- package/services/encode/Encoder.js +18 -2
- package/services/encode/EncoderSelection.js +38 -0
- package/services/encode/LocalObservations.js +359 -0
- package/services/encode/NvencEncoder.js +5 -0
- package/services/encode/OutputOpening.js +745 -0
- package/services/encode/OutputTimes.js +579 -0
- package/services/encode/Renditions.js +2162 -0
- package/services/encode/RunProgress.js +84 -0
- package/services/encode/SoftwareEncoder.js +12 -7
- package/services/encode/args.js +402 -186
- package/services/encode/calibration.js +361 -0
- package/services/encode/contention.js +128 -128
- package/services/encode/encode-run-state.js +16 -20
- package/services/encode/fingerprint.js +322 -0
- package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
- package/services/{learned-median.js → encode/learned-median.js} +11 -0
- package/services/encode/output/EncodedOutput.js +57 -0
- package/services/encode/output/Output.js +131 -0
- package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
- package/services/encode/output/OutputSpec.js +390 -0
- package/services/{output → encode/output}/Timeline.js +12 -37
- package/services/encode/output/index.js +20 -0
- package/services/encode/output/ladder.js +32 -0
- package/services/{output → encode/output}/playlists.js +3 -0
- package/services/encode/output-key-format.js +21 -0
- package/services/encode/quality/EncodeCost.js +1083 -0
- package/services/encode/quality/HostLoad.js +525 -0
- package/services/encode/quality/HostTimings.js +249 -0
- package/services/encode/quality/OutputQualityState.js +28 -0
- package/services/encode/quality/QualityController.js +931 -0
- package/services/encode/quality/QualityOffer.js +416 -0
- package/services/encode/quality/drain-threat.js +47 -0
- package/services/encode/quality/link-budget.js +254 -0
- package/services/encode/quality/output-format.js +266 -0
- package/services/encode/quality/serving-output.js +216 -0
- package/services/encode/quality/visible-rung.js +57 -0
- package/services/encode/run-command.js +23 -20
- package/services/encode/run-inputs.js +42 -0
- package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
- package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
- package/services/encode/segment-formats/init-compat.js +324 -0
- package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
- package/services/encode/throughput.js +97 -0
- package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
- package/services/media/KeyframeTables.js +316 -0
- package/services/{source → media}/SourceFile.js +7 -55
- package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
- package/services/media/SubtitleOrchestrator.js +167 -0
- package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
- package/services/{container → media/container}/AviContainer.js +266 -266
- package/services/{container → media/container}/Container.js +596 -601
- package/services/media/container/ContainerFactory.js +56 -0
- package/services/media/container/KeyframeTable.js +272 -0
- package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
- package/services/{container → media/container}/Mp4Container.js +1244 -1244
- package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
- package/services/media/keyframe-probe.js +157 -0
- package/services/media/media-info-probe.js +93 -0
- package/services/{playback-planner.js → media/playback-planner.js} +82 -39
- package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
- package/services/{tracks → media/tracks}/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -0
- package/services/server/SegmentServing.js +1146 -0
- package/services/server/ViewerRequests.js +522 -0
- package/services/server/controllers/PlaybackController.js +36 -0
- package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
- package/services/server/wire-outputs.js +958 -0
- package/services/storage/Diagnostics.js +202 -0
- package/services/storage/MachineBudget.js +235 -0
- package/services/{piece-store → storage}/allowance.js +107 -107
- package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
- package/services/{files → storage/files}/CompletedFiles.js +116 -1
- package/services/{disk → storage}/free.js +33 -0
- package/services/storage/machine-memory.js +56 -0
- package/services/{memory-report.js → storage/memory-report.js} +575 -596
- package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
- package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
- package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
- package/services/storage/wire.js +175 -0
- package/services/{download → torrent/download}/SwarmSelection.js +2 -2
- package/services/{download → torrent/download}/registry.js +2 -2
- package/services/torrent/files.js +1 -1
- package/services/torrent/piece-store-of.js +29 -0
- package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
- package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
- package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
- package/services/{torrent-worker → torrent/worker}/client.js +77 -58
- package/services/torrent/worker/held-bytes.js +155 -0
- package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
- package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
- package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
- package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
- package/services/torrent/worker/resume-warm.js +107 -0
- package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
- package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
- package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
- package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
- package/services/{health-collector.js → transport/health-collector.js} +6 -35
- package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
- package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
- package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
- package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
- package/services/viewer/Assignments.js +340 -0
- package/services/{priority → viewer}/PriorityMap.js +390 -390
- package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
- package/services/viewer/Viewer.js +657 -489
- package/services/viewer/Viewers.js +368 -257
- package/services/viewer/active-output.js +37 -54
- package/services/viewer/audio-start.js +46 -0
- package/services/viewer/buffer-trend.js +81 -0
- package/services/viewer/choices.js +647 -0
- package/services/viewer/positions.js +126 -120
- package/services/viewer/report-intake.js +46 -44
- package/test/asset-paths.test.js +28 -0
- package/test/audio-inventory.test.js +3 -3
- package/test/audio-track-bitrate.test.js +40 -0
- package/test/auto-quality-step.test.js +707 -518
- package/test/available-share.test.js +1 -1
- package/test/background-fill.test.js +1 -1
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +1 -1
- package/test/concurrent-cost.test.js +13 -3
- package/test/container-media-info.test.js +3 -3
- package/test/core-dumps.test.js +1 -1
- package/test/coverage-follows-the-disk.test.js +40 -8
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
- package/test/cut-times-timeline.test.js +2 -2
- package/test/cuts-follow-published-grid.test.js +9 -8
- package/test/data-channel-frame.test.js +1 -1
- package/test/dead-torrent-handle.test.js +1 -1
- package/test/declared-rates.test.js +2 -2
- package/test/declared-tracks.test.js +2 -2
- package/test/decode-cost-fit.test.js +1 -1
- package/test/decode-cost.test.js +34 -25
- package/test/delivery-probe.test.js +1 -1
- package/test/demand-register.test.js +4 -4
- package/test/dht-bootstrap.test.js +1 -1
- package/test/drain-threat.test.js +65 -0
- package/test/duplicate-add.test.js +1 -1
- package/test/encode-admission.test.js +146 -0
- package/test/encode-cost.test.js +199 -10
- package/test/encode-orchestrator.test.js +39 -1
- package/test/encode-plan-viewers.test.js +2 -2
- package/test/encode-run.test.js +2 -2
- package/test/encode-slope.test.js +1 -1
- package/test/encoded-output.test.js +39 -0
- package/test/encoder-readings.test.js +1 -1
- package/test/every-claimant-is-told-its-share.test.js +152 -0
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
- package/test/failed-start-breaker.test.js +81 -30
- package/test/fastest-wires.test.js +1 -1
- package/test/ffmpeg-stream-counts.test.js +1 -1
- package/test/file-edges.test.js +3 -3
- package/test/fmp4-self-contained.test.js +1 -1
- package/test/fmp4-timestamps.test.js +1 -1
- package/test/fmp4-track-completeness.test.js +1 -1
- package/test/format-keyed-outputs.test.js +78 -0
- package/test/health-metrics.test.js +3 -2
- package/test/held-request-width.test.js +22 -23
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -0
- package/test/helpers/manager.js +57 -0
- package/test/helpers/output-spec.js +63 -0
- package/test/host-load.test.js +1 -1
- package/test/index-accuracy-is-about-the-file.test.js +128 -0
- package/test/init-compat.test.js +219 -0
- package/test/input-lost-quiets-the-plan.test.js +1 -1
- package/test/input-unavailable.test.js +1 -1
- package/test/keeping-period.test.js +4 -4
- package/test/keyframe-index-accuracy.test.js +97 -45
- package/test/keyframe-table-budget.test.js +28 -33
- package/test/keyframes-belong-to-the-file.test.js +174 -57
- package/test/learned-median.test.js +1 -1
- package/test/letting-go-asks-who-is-watching.test.js +191 -0
- package/test/limit-row-by-area.test.js +60 -0
- package/test/link-budget.test.js +111 -0
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -0
- package/test/look-ahead-coverage.test.js +1 -1
- package/test/manager-surface.test.js +294 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +1 -1
- package/test/memory-budget.test.js +2 -2
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +1 -1
- package/test/one-authority.test.js +45 -41
- package/test/one-budget.test.js +149 -0
- package/test/one-output-one-session.test.js +204 -0
- package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
- package/test/one-serving-output-per-height.test.js +458 -0
- package/test/orchestrator-wired.test.js +46 -45
- package/test/output-catalog.test.js +39 -0
- package/test/output-shape.test.js +4 -51
- package/test/output-spec.test.js +251 -157
- package/test/output-speed.test.js +17 -15
- package/test/outputs-being-prepared.test.js +40 -0
- package/test/packet-witness-ring.test.js +2 -2
- package/test/packet-witness.test.js +1 -1
- package/test/piece-disk-store.test.js +2 -2
- package/test/piece-from-whole-file.test.js +1 -1
- package/test/piece-lru.test.js +1 -1
- package/test/piece-reader.test.js +2 -2
- package/test/piece-store-eviction.test.js +1 -1
- package/test/piece-store-never-refuses.test.js +1 -1
- package/test/piece-store-reservations.test.js +1 -1
- package/test/piece-store-slow-disk.test.js +1 -1
- package/test/piece-tail.test.js +1 -1
- package/test/plan-host-timings.test.js +1 -1
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +4 -4
- package/test/priority-map-emptied.test.js +204 -205
- package/test/priority-map-per-output.test.js +215 -209
- package/test/priority-map.test.js +1 -1
- package/test/probe-wedge-certainty.test.js +1 -1
- package/test/produced-copy-choice.test.js +15 -60
- package/test/quality-offer.test.js +162 -0
- package/test/quality-variants.test.js +1229 -1167
- package/test/rate-control-in-the-output.test.js +180 -0
- package/test/read-survives-withdrawal.test.js +2 -2
- package/test/read-window.test.js +10 -3
- package/test/resume-warm.test.js +1 -1
- package/test/run-inputs.test.js +80 -0
- package/test/run-position-follows-published-grid.test.js +13 -11
- package/test/run-progress.test.js +65 -0
- package/test/same-height-switch.test.js +664 -0
- package/test/seek-frees-the-viewers-requests.test.js +217 -0
- package/test/seek-landing.test.js +13 -13
- package/test/seek-target-not-superseded.test.js +28 -29
- package/test/segment-closed-on-both-branches.test.js +96 -0
- package/test/segment-cut-times.test.js +1 -1
- package/test/segment-hold-supersede.test.js +28 -35
- package/test/segment-judged-by-its-own-header.test.js +221 -0
- package/test/segment-serve-wiring.test.js +130 -39
- package/test/segment-store-eviction.test.js +22 -7
- package/test/segment-store.test.js +98 -14
- package/test/segment-true-start.test.js +1 -1
- package/test/segments-are-shared.test.js +17 -16
- package/test/send-chunk-bytes.test.js +1 -1
- package/test/serving-existing-output.test.js +102 -0
- package/test/serving-output.test.js +102 -0
- package/test/shared-piece-store.test.js +1 -1
- package/test/source-file.test.js +1 -1
- package/test/stale-request-after-seek.test.js +17 -21
- package/test/startup-readings.test.js +10 -5
- package/test/subtitle-cue-framing.test.js +5 -5
- package/test/subtitle-cue-source.test.js +36 -38
- package/test/subtitle-cue-walk.test.js +11 -10
- package/test/subtitle-defaults.test.js +1 -1
- package/test/subtitle-language.test.js +4 -4
- package/test/subtitle-track-numbering.test.js +5 -4
- package/test/supply-margin.test.js +1 -1
- package/test/swarm-follows-readers.test.js +3 -3
- package/test/swarm-reach.test.js +1 -1
- package/test/swarm-selection.test.js +3 -3
- package/test/tail-duplication.test.js +1 -1
- package/test/the-channel-has-both-ends.test.js +91 -0
- package/test/the-torrent-thread-serves-bytes.test.js +107 -0
- package/test/thread-pool.test.js +3 -3
- package/test/timeline.test.js +1 -12
- package/test/torrent-cost.test.js +1 -1
- package/test/tracks-begin-together.test.js +38 -24
- package/test/tunnel-renewal.test.js +1 -1
- package/test/two-viewers-one-picture.test.js +120 -87
- package/test/upload-hurry.test.js +3 -3
- package/test/usrsctp-state.test.js +1 -1
- package/test/video-facts.test.js +1 -1
- package/test/viewer-assignments.test.js +143 -0
- package/test/viewer-outputs.test.js +60 -52
- package/test/viewer-presence.test.js +22 -25
- package/test/viewer-requests-admission.test.js +89 -0
- package/test/viewer-states.test.js +189 -172
- package/test/viewer-subtitle-subscription.test.js +1 -1
- package/test/viewer.test.js +139 -136
- package/test/viewing-generations.test.js +390 -0
- package/test/visible-rung.test.js +37 -0
- package/test/wait-ledger.test.js +1 -1
- package/test/webrtc-shim.test.js +3 -3
- package/test/wedge-certainty.test.js +2 -2
- package/test/withdraw-piece-claim.test.js +2 -2
- package/test/worker-channel.test.js +1 -31
- package/utils/client-log-file.js +151 -151
- package/scripts/read-container-index.mjs +0 -108
- package/services/container/ContainerFactory.js +0 -122
- package/services/controllers/PlaybackController.js +0 -33
- package/services/disk/DiskSpace.js +0 -150
- package/services/disk/wire.js +0 -61
- package/services/hls-session-manager.js +0 -9689
- package/services/orchestrators/SubtitleOrchestrator.js +0 -160
- package/services/output/Output.js +0 -94
- package/services/output/OutputSpec.js +0 -202
- package/services/output/index.js +0 -1
- package/services/output/ladder.js +0 -26
- package/services/produced-index.js +0 -222
- package/services/quality/EncodeCost.js +0 -555
- package/services/torrent-worker/container-tracks.js +0 -414
- package/test/produced-index.test.js +0 -142
- /package/{services → bin}/thread-pool.js +0 -0
- /package/services/{available-share.js → encode/available-share.js} +0 -0
- /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
- /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
- /package/services/{host-load.js → encode/host-load.js} +0 -0
- /package/services/{output → encode/output}/cut-grid.js +0 -0
- /package/services/{output → encode/output}/rates.js +0 -0
- /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
- /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
- /package/services/{container → media/container}/ebml-reader.js +0 -0
- /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
- /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
- /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
- /package/services/{tracks → media/tracks}/index.js +0 -0
- /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
- /package/services/{disk → storage}/keep.js +0 -0
- /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
- /package/services/{disk → storage}/returns.js +0 -0
- /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
- /package/services/{demand → torrent/demand}/Urgency.js +0 -0
- /package/services/{demand → torrent/demand}/Window.js +0 -0
- /package/services/{demand → torrent/demand}/index.js +0 -0
- /package/services/{demand → torrent/demand}/pieces.js +0 -0
- /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
- /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
- /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
- /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
- /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
- /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
- /package/services/{registry-api.js → transport/registry-api.js} +0 -0
- /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
- /package/services/{priority → viewer}/WaitLedger.js +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where a file's keyframes are, found by DECODING it rather than by
|
|
3
|
+
* reading what its container states.
|
|
4
|
+
*
|
|
5
|
+
* The SECOND reader of one fact, and the weaker of the two: the container's own
|
|
6
|
+
* index is two point reads of 16 KB and names every keyframe, while this scans
|
|
7
|
+
* the media over a torrent-backed input and on a large file often does not
|
|
8
|
+
* finish — measured 2026-08-02, 77 keyframes in 45 s against all 570 in 0.8 s
|
|
9
|
+
* from the index. So it is never waited for and never displaces an index that
|
|
10
|
+
* answered; `KeyframeTable.learn` keeps the fuller answer whichever arrives
|
|
11
|
+
* second.
|
|
12
|
+
*
|
|
13
|
+
* It exists because some containers state no index at all, and a keyframe is
|
|
14
|
+
* still needed for something both encode branches need: choosing a SOURCE seek
|
|
15
|
+
* position ffmpeg can land on. `-ss` before `-i` trusts the container's
|
|
16
|
+
* on-the-fly seek, which for some containers (observed: AVI with VBR MP3 audio)
|
|
17
|
+
* can point at a position with no valid frame boundary at all — ffmpeg then
|
|
18
|
+
* fails outright ("Seek failed" / "Header missing"), not merely imprecisely.
|
|
19
|
+
*
|
|
20
|
+
* **Why it is here and not in `container/`.** It runs a process and reads the
|
|
21
|
+
* file over the address this proxy serves it at. A container is built from four
|
|
22
|
+
* functions and takes byte ranges; it spawns nothing and knows no URL. Both are
|
|
23
|
+
* readings of the same file, so both are this layer's — and they are separate
|
|
24
|
+
* files because one of them needs a machine and the other needs sixteen
|
|
25
|
+
* kilobytes.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The ffprobe that sits beside a given ffmpeg.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} ffmpegBin
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function ffprobeBinFor(ffmpegBin) {
|
|
37
|
+
if (typeof ffmpegBin !== "string" || ffmpegBin.length === 0) {
|
|
38
|
+
return "ffprobe";
|
|
39
|
+
}
|
|
40
|
+
if (/ffmpeg(\.exe)?$/i.test(ffmpegBin)) {
|
|
41
|
+
return ffmpegBin.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
|
|
42
|
+
}
|
|
43
|
+
return "ffprobe";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where this file's keyframes are, found by DECODING it rather than by reading
|
|
48
|
+
* what its container states.
|
|
49
|
+
*
|
|
50
|
+
* The second reader of one fact, and the weaker of the two: the container's own
|
|
51
|
+
* index is two point reads of 16 KB and names every keyframe, while this scans
|
|
52
|
+
* the media over a torrent-backed input and on a large file often does not
|
|
53
|
+
* finish — measured 2026-08-02, 77 keyframes in 45 s against all 570 in 0.8 s
|
|
54
|
+
* from the index. So it is never waited for and never displaces an index that
|
|
55
|
+
* answered; `KeyframeTable.learn` keeps the fuller answer whichever arrives
|
|
56
|
+
* second.
|
|
57
|
+
*
|
|
58
|
+
* It exists because some containers state no index at all, and a keyframe is
|
|
59
|
+
* still needed for something both branches need: choosing a SOURCE seek
|
|
60
|
+
* position ffmpeg can land on. `-ss` before `-i` trusts the container's
|
|
61
|
+
* on-the-fly seek, which for some containers (observed: AVI with VBR MP3 audio)
|
|
62
|
+
* can point at a position with no valid frame boundary at all — ffmpeg then
|
|
63
|
+
* fails outright ("Seek failed" / "Header missing"), not merely imprecisely.
|
|
64
|
+
*
|
|
65
|
+
* It lives here, beside the codec probe, because it is the same kind of thing:
|
|
66
|
+
* a reading of the file made by running ffmpeg's own tools over the address
|
|
67
|
+
* this proxy serves it at. The container layer takes byte ranges and spawns
|
|
68
|
+
* nothing.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} ffmpegBin
|
|
71
|
+
* @param {string | URL} inputUrl
|
|
72
|
+
* @param {number} [timeoutMs]
|
|
73
|
+
* @returns {Promise<number[] | null>} Sorted keyframe times, or null.
|
|
74
|
+
*/
|
|
75
|
+
export async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
let proc;
|
|
78
|
+
try {
|
|
79
|
+
proc = spawn(
|
|
80
|
+
ffprobeBinFor(ffmpegBin),
|
|
81
|
+
[
|
|
82
|
+
"-v", "error",
|
|
83
|
+
// `-skip_frame nokey` makes the decoder discard non-keyframes, so the
|
|
84
|
+
// probe reads only what it needs. Without it a full packet scan of a
|
|
85
|
+
// ~5 GB MKV cannot finish inside any sane budget over a torrent-backed
|
|
86
|
+
// input, the probe returns nothing, and the playlist falls back to a
|
|
87
|
+
// uniform grid — which on the COPY path is a lie: cuts land on the
|
|
88
|
+
// source's real keyframes, not on a 4 s ruler. The player then finds
|
|
89
|
+
// the declared times do not match the media, stops trusting the
|
|
90
|
+
// playlist and walks the file from segment #1 to locate the seek
|
|
91
|
+
// position by hand (field 2026-08-02: a seek to 1:30 produced requests
|
|
92
|
+
// #1, #2, #45, #86, #123 … #1187, taking minutes and never arriving).
|
|
93
|
+
"-skip_frame", "nokey",
|
|
94
|
+
"-select_streams", "v:0",
|
|
95
|
+
"-show_entries", "packet=pts_time,flags",
|
|
96
|
+
"-of", "csv=p=0",
|
|
97
|
+
String(inputUrl)
|
|
98
|
+
],
|
|
99
|
+
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
|
|
100
|
+
);
|
|
101
|
+
} catch {
|
|
102
|
+
resolve(null);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let stdout = "";
|
|
106
|
+
let settled = false;
|
|
107
|
+
const finish = (value) => {
|
|
108
|
+
if (settled) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
settled = true;
|
|
112
|
+
resolve(value);
|
|
113
|
+
};
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
try {
|
|
116
|
+
if (!proc.killed) {
|
|
117
|
+
proc.kill("SIGTERM");
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore
|
|
121
|
+
}
|
|
122
|
+
finish(null);
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
proc.stdout.on("data", (chunk) => {
|
|
125
|
+
stdout += String(chunk);
|
|
126
|
+
});
|
|
127
|
+
proc.on("error", () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
finish(null);
|
|
130
|
+
});
|
|
131
|
+
proc.on("exit", (code) => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
if (code !== 0) {
|
|
134
|
+
finish(null);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const times = [];
|
|
138
|
+
for (const line of stdout.split("\n")) {
|
|
139
|
+
// Each line: "<pts_time>,<flags>" e.g. "12.345000,K__"
|
|
140
|
+
const comma = line.indexOf(",");
|
|
141
|
+
if (comma < 0) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const flags = line.slice(comma + 1);
|
|
145
|
+
if (!flags.includes("K")) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const t = Number(line.slice(0, comma));
|
|
149
|
+
if (Number.isFinite(t)) {
|
|
150
|
+
times.push(t);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
times.sort((a, b) => a - b);
|
|
154
|
+
finish(times.length > 0 ? times : null);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a stream's own header says: duration, picture size, frame rate,
|
|
3
|
+
* start time, stream counts and HDR — from one short ffmpeg run that stops as
|
|
4
|
+
* soon as the header has been printed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import {
|
|
9
|
+
parseFfmpegBitrateKbps,
|
|
10
|
+
parseFfmpegDurationSeconds,
|
|
11
|
+
parseFfmpegHdr,
|
|
12
|
+
parseFfmpegStartTimeSeconds,
|
|
13
|
+
parseFfmpegStreamCounts,
|
|
14
|
+
parseFfmpegVideoDimensions,
|
|
15
|
+
parseFfmpegVideoFps
|
|
16
|
+
} from "./ffmpeg-banner.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run a short ffmpeg probe to extract the total duration AND video resolution
|
|
20
|
+
* of a stream from the container header. Both are printed almost immediately
|
|
21
|
+
* (before any decoding), so this returns as soon as they are seen; an 8 s
|
|
22
|
+
* timeout guards the rest.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} ffmpegBin - Path to the ffmpeg executable.
|
|
25
|
+
* @param {string | URL} inputUrl - URL of the stream to probe.
|
|
26
|
+
* @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
|
|
31
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
32
|
+
windowsHide: true
|
|
33
|
+
});
|
|
34
|
+
let stderr = "";
|
|
35
|
+
let settled = false;
|
|
36
|
+
const finish = () => {
|
|
37
|
+
if (settled) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
settled = true;
|
|
41
|
+
const dims = parseFfmpegVideoDimensions(stderr);
|
|
42
|
+
resolve({
|
|
43
|
+
durationSeconds: parseFfmpegDurationSeconds(stderr),
|
|
44
|
+
bitrateKbps: parseFfmpegBitrateKbps(stderr),
|
|
45
|
+
width: dims.width,
|
|
46
|
+
height: dims.height,
|
|
47
|
+
fps: parseFfmpegVideoFps(stderr),
|
|
48
|
+
startTime: parseFfmpegStartTimeSeconds(stderr),
|
|
49
|
+
// Only ever read when a run fails, and read HERE because by then the
|
|
50
|
+
// banner is long gone: this probe is the one place the source says what
|
|
51
|
+
// it holds.
|
|
52
|
+
streamCounts: parseFfmpegStreamCounts(stderr),
|
|
53
|
+
isHdr: parseFfmpegHdr(stderr)
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const timeoutId = setTimeout(() => {
|
|
57
|
+
if (!ffmpeg.killed) {
|
|
58
|
+
ffmpeg.kill("SIGTERM");
|
|
59
|
+
}
|
|
60
|
+
finish();
|
|
61
|
+
}, 8_000);
|
|
62
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
63
|
+
stderr += String(chunk);
|
|
64
|
+
// The header ("Duration:" then the "Video: … WxH" stream line) is printed
|
|
65
|
+
// before any decoding. Bail as soon as both are present instead of letting
|
|
66
|
+
// `-f null -` decode the whole stream until the 8 s timeout.
|
|
67
|
+
//
|
|
68
|
+
// This probe asks about the PICTURE and nothing else now: a file with no
|
|
69
|
+
// video track is read by the container layer, which answers from 64 KB of
|
|
70
|
+
// header. It used to take an `expectVideo: false` for exactly that case,
|
|
71
|
+
// and the branch cost 8121 ms of every cold start (2026-09-03) because
|
|
72
|
+
// the exit still waited for a parsed DURATION, which a partly downloaded
|
|
73
|
+
// file prints as `N/A`.
|
|
74
|
+
const duration = parseFfmpegDurationSeconds(stderr);
|
|
75
|
+
const dims = parseFfmpegVideoDimensions(stderr);
|
|
76
|
+
if (duration != null && dims.width != null) {
|
|
77
|
+
clearTimeout(timeoutId);
|
|
78
|
+
if (!ffmpeg.killed) {
|
|
79
|
+
ffmpeg.kill("SIGTERM");
|
|
80
|
+
}
|
|
81
|
+
finish();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
ffmpeg.on("error", () => {
|
|
85
|
+
clearTimeout(timeoutId);
|
|
86
|
+
finish();
|
|
87
|
+
});
|
|
88
|
+
ffmpeg.on("exit", () => {
|
|
89
|
+
clearTimeout(timeoutId);
|
|
90
|
+
finish();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { logger } from "
|
|
10
|
+
import { logger } from "../../utils/logger.js";
|
|
11
11
|
import { Container } from "./container/Container.js";
|
|
12
12
|
import { buildAudioInventory } from "./audio-inventory.js";
|
|
13
|
-
import { contentsOf } from "./torrent/Contents.js";
|
|
14
13
|
import {
|
|
15
14
|
parseFfmpegDurationSeconds,
|
|
16
15
|
parseFfmpegStartTimeSeconds,
|
|
@@ -50,6 +49,18 @@ const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "
|
|
|
50
49
|
* @param {string} ffmpegOutput
|
|
51
50
|
* @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
|
|
52
51
|
*/
|
|
52
|
+
/**
|
|
53
|
+
* The bitrate a stream line states for that stream, in kbit/s, or null.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} line
|
|
56
|
+
* @returns {number | null}
|
|
57
|
+
*/
|
|
58
|
+
function kbpsFromStreamLine(line) {
|
|
59
|
+
const match = line.match(/,\s*(\d+)\s*kb\/s/);
|
|
60
|
+
const kbps = match ? Number(match[1]) : Number.NaN;
|
|
61
|
+
return Number.isFinite(kbps) && kbps > 0 ? kbps : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
function parseStreams(ffmpegOutput) {
|
|
54
65
|
// Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
|
|
55
66
|
// too (wrapped_avframe / pcm_s16le), which would duplicate every track.
|
|
@@ -68,12 +79,21 @@ function parseStreams(ffmpegOutput) {
|
|
|
68
79
|
codec: String(streamMatch[4]).toLowerCase(),
|
|
69
80
|
language: (streamMatch[2] ?? "").toLowerCase(),
|
|
70
81
|
title: "",
|
|
71
|
-
isDefault: /\(default\)/.test(line)
|
|
82
|
+
isDefault: /\(default\)/.test(line),
|
|
83
|
+
// What the stream line states for THIS stream, e.g. "…, 128 kb/s". A
|
|
84
|
+
// Matroska stream usually states none here and carries its rate as a
|
|
85
|
+
// statistics tag instead (`BPS`, read below). Never the file's total.
|
|
86
|
+
bitrateKbps: kbpsFromStreamLine(line)
|
|
72
87
|
};
|
|
73
88
|
streams.push(current);
|
|
74
89
|
continue;
|
|
75
90
|
}
|
|
76
91
|
if (current) {
|
|
92
|
+
const bpsMatch = line.match(/^\s+BPS(?:-[A-Za-z]+)?\s*:\s*(\d+)\s*$/);
|
|
93
|
+
if (bpsMatch && current.bitrateKbps === null) {
|
|
94
|
+
current.bitrateKbps = Math.round(Number(bpsMatch[1]) / 1000);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
77
97
|
const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
|
|
78
98
|
if (titleMatch && current.title.length === 0) {
|
|
79
99
|
current.title = titleMatch[1].trim();
|
|
@@ -94,7 +114,7 @@ function parseStreams(ffmpegOutput) {
|
|
|
94
114
|
* @param {string} ffmpegOutput
|
|
95
115
|
* @returns {{ audioCodec: string, videoCodec: string }}
|
|
96
116
|
*/
|
|
97
|
-
function parseStreamCodecs(ffmpegOutput) {
|
|
117
|
+
export function parseStreamCodecs(ffmpegOutput) {
|
|
98
118
|
const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
|
|
99
119
|
const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
|
|
100
120
|
// Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
|
|
@@ -127,7 +147,8 @@ function parseStreamCodecs(ffmpegOutput) {
|
|
|
127
147
|
codec: s.codec,
|
|
128
148
|
language: s.language,
|
|
129
149
|
title: s.title,
|
|
130
|
-
isDefault: s.isDefault
|
|
150
|
+
isDefault: s.isDefault,
|
|
151
|
+
bitrateKbps: s.bitrateKbps
|
|
131
152
|
}));
|
|
132
153
|
const subtitleTracks = streams
|
|
133
154
|
.filter((s) => s.type === "subtitle")
|
|
@@ -262,8 +283,8 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
|
|
|
262
283
|
* @property {string} ffmpegBin
|
|
263
284
|
* @property {boolean} transcodeAudioEnabled
|
|
264
285
|
* @property {string} localBaseUrl
|
|
265
|
-
* @property {ReturnType<import("
|
|
266
|
-
* @property {import("
|
|
286
|
+
* @property {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
|
|
287
|
+
* @property {import("../torrent/torrent-pool.js").TorrentPool} torrentPool
|
|
267
288
|
*/
|
|
268
289
|
|
|
269
290
|
/**
|
|
@@ -285,10 +306,22 @@ export function createPlaybackPlanner({
|
|
|
285
306
|
// merely keeps up with realtime, and showed 15 s where 3.8 s were left.
|
|
286
307
|
expectedFirstSegmentMs,
|
|
287
308
|
expectedSessionCreateMs,
|
|
309
|
+
// What the torrent holds beside this picture — its dubs, its subtitle files,
|
|
310
|
+
// its contact sheets — grouped by whoever knows what a torrent contains. This
|
|
311
|
+
// layer answers about ONE file and must not read a torrent's file list.
|
|
312
|
+
sidecarsFromTorrent = null,
|
|
313
|
+
// What a file DECLARES about its own tracks, read from its header. Handed in
|
|
314
|
+
// rather than asked of the torrent pool: this layer used to call
|
|
315
|
+
// `torrentPool.getDeclaredAudioTracks` and friends, which is the media layer
|
|
316
|
+
// asking the torrent layer to parse a container on its behalf — the parse
|
|
317
|
+
// happened in the torrent thread and came back over the channel. It happens
|
|
318
|
+
// on this thread now, and what is passed here is the ordinary container read.
|
|
319
|
+
declaredTracksOf = null,
|
|
288
320
|
// Optional. Called once the file's edges are downloaded, so the keyframe
|
|
289
321
|
// index — which reads the same tail of the file — is fetched alongside the
|
|
290
|
-
// codec probe instead of after it.
|
|
291
|
-
// which owns
|
|
322
|
+
// codec probe instead of after it. It goes straight to `KeyframeTables`,
|
|
323
|
+
// which owns that table: it used to be routed through the session manager,
|
|
324
|
+
// which held a second copy of it and a second reader for it.
|
|
292
325
|
warmKeyframeIndex,
|
|
293
326
|
// Optional. The heights this host could actually serve this source at, for
|
|
294
327
|
// both playback branches, so the quality menu is right from the moment the
|
|
@@ -339,27 +372,30 @@ export function createPlaybackPlanner({
|
|
|
339
372
|
/**
|
|
340
373
|
* The files beside this picture that belong to it, in three groups.
|
|
341
374
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* answer.
|
|
375
|
+
* HANDED IN, because what a torrent contains is the torrent layer's to say
|
|
376
|
+
* and this is the media layer. It works the grouping out once and keeps it;
|
|
377
|
+
* it used to be worked out here on every call, and again on the warm-up's own
|
|
378
|
+
* path over the same list, so one opened film paired the same files several
|
|
379
|
+
* times and neither side could be sure of the other's answer.
|
|
380
|
+
*
|
|
381
|
+
* Absent, a file has no companions — which is what a proxy with no torrent
|
|
382
|
+
* behind it should answer, rather than failing.
|
|
347
383
|
*
|
|
348
384
|
* @param {object} torrent
|
|
349
385
|
* @param {number} fileIndex
|
|
350
386
|
* @returns {{ audio: object[], subtitles: object[], images: object[] }}
|
|
351
387
|
*/
|
|
352
388
|
function sidecarsOf(torrent, fileIndex) {
|
|
353
|
-
return
|
|
389
|
+
return sidecarsFromTorrent?.(torrent ?? {}, fileIndex) ?? { audio: [], subtitles: [], images: [] };
|
|
354
390
|
}
|
|
355
391
|
|
|
356
|
-
async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
|
|
357
|
-
if (subtitleTracks.length === 0 || typeof
|
|
392
|
+
async function withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks) {
|
|
393
|
+
if (subtitleTracks.length === 0 || typeof declaredTracksOf !== "function") {
|
|
358
394
|
return subtitleTracks.map((track) => ({ ...track, declaresDefault: false }));
|
|
359
395
|
}
|
|
360
396
|
let declared = [];
|
|
361
397
|
try {
|
|
362
|
-
declared = await
|
|
398
|
+
declared = (await declaredTracksOf({ sourceKey, fileIndex })).filter((track) => track?.type === "subtitle");
|
|
363
399
|
} catch (error) {
|
|
364
400
|
logger.info(`subtitle defaults: the container could not be read (${error?.message ?? error})`);
|
|
365
401
|
}
|
|
@@ -385,12 +421,13 @@ export function createPlaybackPlanner({
|
|
|
385
421
|
* menu and the number in the `a/<n>/` address are the same number by
|
|
386
422
|
* construction rather than by agreement.
|
|
387
423
|
*
|
|
424
|
+
* @param {string} sourceKey
|
|
388
425
|
* @param {object} torrent
|
|
389
426
|
* @param {number} fileIndex
|
|
390
427
|
* @param {object[]} bannerAudioTracks - The probe's own audio streams.
|
|
391
428
|
* @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
|
|
392
429
|
*/
|
|
393
|
-
async function buildInventory(torrent, fileIndex, bannerAudioTracks) {
|
|
430
|
+
async function buildInventory(sourceKey, torrent, fileIndex, bannerAudioTracks) {
|
|
394
431
|
const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
|
|
395
432
|
/**
|
|
396
433
|
* Read a file's declared audio tracks, or give up quickly.
|
|
@@ -410,13 +447,15 @@ export function createPlaybackPlanner({
|
|
|
410
447
|
* @returns {Promise<object[]>}
|
|
411
448
|
*/
|
|
412
449
|
const declaredAudioOf = async (wantedFileIndex, label) => {
|
|
413
|
-
if (typeof
|
|
450
|
+
if (typeof declaredTracksOf !== "function") {
|
|
414
451
|
return [];
|
|
415
452
|
}
|
|
416
453
|
let timer = null;
|
|
417
454
|
try {
|
|
418
455
|
return await Promise.race([
|
|
419
|
-
|
|
456
|
+
declaredTracksOf({ sourceKey, fileIndex: wantedFileIndex }).then((tracks) => tracks
|
|
457
|
+
.filter((track) => track?.type === "audio")
|
|
458
|
+
.sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))),
|
|
420
459
|
new Promise((resolve) => {
|
|
421
460
|
timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
|
|
422
461
|
timer.unref?.();
|
|
@@ -499,7 +538,12 @@ export function createPlaybackPlanner({
|
|
|
499
538
|
offeredHeights: plan.mediaInfoForOffer
|
|
500
539
|
? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
|
|
501
540
|
: null,
|
|
502
|
-
|
|
541
|
+
// The description the rest of the pool answers by arithmetic. Carried
|
|
542
|
+
// on every plan, not only on a refusal here: the output opened next can
|
|
543
|
+
// still be refused for want of a place on this machine (roadmap item 97,
|
|
544
|
+
// step 14), and the page then asks the pool the same question before
|
|
545
|
+
// anything plays — which it can only do with this in hand.
|
|
546
|
+
mediaInfoForOffer: plan.mediaInfoForOffer
|
|
503
547
|
};
|
|
504
548
|
// Refused rather than served badly. Both lists empty means this machine
|
|
505
549
|
// cannot sustain this file at ANY height — not even by copying the picture,
|
|
@@ -513,13 +557,12 @@ export function createPlaybackPlanner({
|
|
|
513
557
|
if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
|
|
514
558
|
withOffer.cannotServe =
|
|
515
559
|
"This proxy cannot keep up with this file at any quality right now.";
|
|
516
|
-
// The description travels with the
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
560
|
+
// The description travels with the plan (above). It is what lets the
|
|
561
|
+
// browser ask the rest of the pool the same question without anybody
|
|
562
|
+
// else adding the torrent, fetching a byte or running ffmpeg — the
|
|
563
|
+
// expensive half of finding out what this file IS has been paid here,
|
|
520
564
|
// once. Everyone else answers by arithmetic against their own startup
|
|
521
565
|
// benchmarks.
|
|
522
|
-
withOffer.mediaInfoForOffer = plan.mediaInfoForOffer;
|
|
523
566
|
}
|
|
524
567
|
return withOffer;
|
|
525
568
|
}
|
|
@@ -634,14 +677,9 @@ export function createPlaybackPlanner({
|
|
|
634
677
|
// The keyframe index reads the tail of the file, which the probe has just
|
|
635
678
|
// waited for as well. Started here it overlaps the probe instead of
|
|
636
679
|
// following the whole plan — worth 311-430 ms of the time before the
|
|
637
|
-
// first segment. Fire and forget:
|
|
638
|
-
//
|
|
639
|
-
warmKeyframeIndex?.({
|
|
640
|
-
sourceKey,
|
|
641
|
-
fileIndex,
|
|
642
|
-
inputUrl: new URL(directUrl),
|
|
643
|
-
logName: file.name
|
|
644
|
-
});
|
|
680
|
+
// first segment. Fire and forget: a session that finds the table
|
|
681
|
+
// unanswered joins this very read rather than starting a second one.
|
|
682
|
+
warmKeyframeIndex?.({ sourceKey, fileIndex, logName: file.name });
|
|
645
683
|
let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
646
684
|
const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
|
|
647
685
|
let attempt = 0;
|
|
@@ -669,9 +707,14 @@ export function createPlaybackPlanner({
|
|
|
669
707
|
// banner, while the `VideoTrack` the container declares was read and used
|
|
670
708
|
// for nothing but a line in the log.
|
|
671
709
|
let declaredVideo = null;
|
|
672
|
-
if (typeof
|
|
710
|
+
if (typeof declaredTracksOf === "function") {
|
|
673
711
|
try {
|
|
674
|
-
|
|
712
|
+
// One, because ffmpeg's `0:v:0` is what everything downstream is built
|
|
713
|
+
// on and a second video stream is a cover image far more often than a
|
|
714
|
+
// second film.
|
|
715
|
+
declaredVideo = (await declaredTracksOf({ sourceKey, fileIndex }))
|
|
716
|
+
.filter((track) => track?.type === "video")
|
|
717
|
+
.sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))[0] ?? null;
|
|
675
718
|
} catch (error) {
|
|
676
719
|
logger.info(`video track: could not be read (${error?.message ?? error})`);
|
|
677
720
|
}
|
|
@@ -714,8 +757,8 @@ export function createPlaybackPlanner({
|
|
|
714
757
|
// Full track inventory for the browser's audio/subtitle menus. The audio
|
|
715
758
|
// half spans the picture's own tracks AND the soundtracks shipped as
|
|
716
759
|
// files beside it, under one numbering — see `buildInventory`.
|
|
717
|
-
audioTracks: await buildInventory(torrent, fileIndex, audioTracks ?? []),
|
|
718
|
-
subtitleTracks: await withContainerDefaults(torrent, fileIndex, subtitleTracks ?? []),
|
|
760
|
+
audioTracks: await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []),
|
|
761
|
+
subtitleTracks: await withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks ?? []),
|
|
719
762
|
// The files BESIDE this picture that belong to it, and what each one's
|
|
720
763
|
// own path says about the track in it. Both answers are made here, by
|
|
721
764
|
// one grammar, because the browser used to make them again: it paired
|