@torrent-tv/proxy 2.87.2 → 2.87.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +2157 -2148
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/browser-proxy-contract.md +94 -94
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +50 -50
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -181
- 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 +129 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +361 -361
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/server.js +721 -720
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2162 -2162
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1083 -1083
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +266 -266
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +212 -212
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +856 -848
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1146 -1146
- package/services/server/ViewerRequests.js +522 -522
- package/services/{viewer → server}/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +36 -36
- 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 +983 -983
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2675 -2635
- package/services/torrent/torrent-source-key.js +38 -38
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +765 -760
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +365 -350
- package/services/torrent/worker/protocol.js +184 -182
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/torrent/worker/worker.js +1129 -1116
- package/services/transport/data-channel-handler.js +1356 -1356
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +657 -657
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +73 -73
- package/test/asset-paths.test.js +28 -28
- package/test/audio-inventory.test.js +176 -176
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +35 -35
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +161 -161
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -294
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
|
@@ -1,316 +1,316 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Reading the keyframe table of a file: once, whoever asks, with a bound
|
|
3
|
-
* on how long anybody waits for it.
|
|
4
|
-
*
|
|
5
|
-
* The table itself is `container/KeyframeTable.js`, one object per file. This is
|
|
6
|
-
* the policy around filling it in, and it is a policy because three things had
|
|
7
|
-
* to be true at once and each of them was learned from a field session:
|
|
8
|
-
*
|
|
9
|
-
* 1. **once per file, and one WAIT per file.** Two sessions created in the same
|
|
10
|
-
* moment used to miss the cache together and read the table twice — which is
|
|
11
|
-
* what two viewers opening one film do, measured 13 ms apart on 2026-09-03.
|
|
12
|
-
* Whoever asks second joins the read already running;
|
|
13
|
-
* 2. **the wait is bounded, the READ is not.** A picture cannot be copied
|
|
14
|
-
* without the table, so the answer to "not yet" is to re-encode — but the
|
|
15
|
-
* read goes on, lands in the file's own table, and is there for whoever holds
|
|
16
|
-
* it. Cancelling it would make every later viewer pay the whole wait again;
|
|
17
|
-
* 3. **a read that THREW is not an answer.** "The head is not downloaded" says
|
|
18
|
-
* nothing about the file; turning it into "this file has no keyframes" makes
|
|
19
|
-
* every picture of it a re-encode for as long as the process lives.
|
|
20
|
-
*
|
|
21
|
-
* **What it does NOT know.** Where the bytes come from. The reader is one
|
|
22
|
-
* function handed in at construction — on this proxy it crosses to the torrent
|
|
23
|
-
* thread, where one container per file answers the track table and the media
|
|
24
|
-
* info from the same header — so this can be exercised with plain values alone.
|
|
25
|
-
* It used to live in the session manager, which also carried a SECOND reader
|
|
26
|
-
* that fetched the file over the proxy's own HTTP and parsed it there: a
|
|
27
|
-
* transport inside a layer that must not have one, and a second answer to a
|
|
28
|
-
* question with one owner.
|
|
29
|
-
*
|
|
30
|
-
* **Why this is not `ContainerOrchestrator`, which is the same shape and sits
|
|
31
|
-
* in this directory: the THREAD, not the responsibility.** That one holds a
|
|
32
|
-
* container per file and needs byte ranges to build it, so it can only live
|
|
33
|
-
* where the torrent is — the worker. This lives on the main thread, where the
|
|
34
|
-
* sessions are, and reaches the other across the channel. The table is
|
|
35
|
-
* therefore held twice, once per thread, and the two are not equals: the
|
|
36
|
-
* worker's is the parse, memoized on the container it parsed, and the main
|
|
37
|
-
* thread's is the answer, which nothing in the worker ever reads back. Three
|
|
38
|
-
* directories of the storage layer are kept apart for the same reason, and it
|
|
39
|
-
* is stated here for the same reason too — an unexplained second home reads as
|
|
40
|
-
* a second owner.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import { KeyframeTable } from "./container/KeyframeTable.js";
|
|
44
|
-
import { logger as defaultLogger } from "../../utils/logger.js";
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* How long anybody waits for the table before giving up on copying the picture.
|
|
48
|
-
*
|
|
49
|
-
* Measured on the addon host, 2026-09-04, over seventeen files from
|
|
50
|
-
* `Dropbox/trn` — four containers, pieces from 0.25 to 16 MB, files from 0.36 to
|
|
51
|
-
* 20 GB, each torrent registered fresh so nothing of it was downloaded
|
|
52
|
-
* (`research/keyframe-table-read-2026-09-04.md`). Every table that arrived did
|
|
53
|
-
* so within 24.8 s, most within half a second; the two files that answered
|
|
54
|
-
* nothing took 120.9 s and 120.5 s.
|
|
55
|
-
*
|
|
56
|
-
* Those two figures are not a coincidence and they are what fixes this one:
|
|
57
|
-
* they are TWO of the bound the read already has — `READ_ABANDON_MS` in
|
|
58
|
-
* `torrent/worker/resume-warm.js`, one for the wait on the file's edges and
|
|
59
|
-
* one for the read itself, in series. A caller waiting for two of them is the
|
|
60
|
-
* defect; waiting for one is the bound, and it leaves 2.4x over the slowest
|
|
61
|
-
* table that did arrive. The line printed when it fires names which case
|
|
62
|
-
* happened, so the field can move it rather than an argument.
|
|
63
|
-
*/
|
|
64
|
-
export const KEYFRAME_TABLE_BUDGET_MS = 60_000;
|
|
65
|
-
|
|
66
|
-
export class KeyframeTables {
|
|
67
|
-
/** One per file, handed out rather than copied. @type {Map<string, KeyframeTable>} */
|
|
68
|
-
#byFile = new Map();
|
|
69
|
-
|
|
70
|
-
/** The read in flight, so the second asker joins it. @type {Map<string, Promise<KeyframeTable>>} */
|
|
71
|
-
#reading = new Map();
|
|
72
|
-
|
|
73
|
-
/** @type {((params: { sourceKey: string, fileIndex: number }) => Promise<object | null>) | null} */
|
|
74
|
-
#readTable;
|
|
75
|
-
|
|
76
|
-
/** @type {number} */
|
|
77
|
-
#budgetMs;
|
|
78
|
-
|
|
79
|
-
/** @type {{ info: Function, warn: Function }} */
|
|
80
|
-
#logger;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* @param {object} [params]
|
|
84
|
-
* @param {(params: { sourceKey: string, fileIndex: number }) => Promise<object | null>} [params.readTable] -
|
|
85
|
-
* Whatever can answer what this file's container states. The whole of this
|
|
86
|
-
* object's outside world.
|
|
87
|
-
* @param {number} [params.budgetMs] - How long `within` waits.
|
|
88
|
-
* @param {{ info: Function, warn: Function }} [params.logger]
|
|
89
|
-
*/
|
|
90
|
-
constructor({ readTable = null, budgetMs = KEYFRAME_TABLE_BUDGET_MS, logger = defaultLogger } = {}) {
|
|
91
|
-
this.#readTable = typeof readTable === "function" ? readTable : null;
|
|
92
|
-
const declared = Number(budgetMs);
|
|
93
|
-
this.#budgetMs = Number.isFinite(declared) && declared > 0 ? declared : KEYFRAME_TABLE_BUDGET_MS;
|
|
94
|
-
this.#logger = logger;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* @param {string} sourceKey
|
|
99
|
-
* @param {number} fileIndex
|
|
100
|
-
* @returns {string}
|
|
101
|
-
*/
|
|
102
|
-
static keyFor(sourceKey, fileIndex) {
|
|
103
|
-
return `${sourceKey}:${fileIndex}`;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** How long a caller of `within` waits. @returns {number} */
|
|
107
|
-
get budgetMs() {
|
|
108
|
-
return this.#budgetMs;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* This file's table — the one object everyone reading it holds.
|
|
113
|
-
*
|
|
114
|
-
* Made empty on first ask rather than on first answer, so a session created
|
|
115
|
-
* before the read finishes holds the very object the answer lands in. Handing
|
|
116
|
-
* out a value instead is what used to leave a late table unreachable to the
|
|
117
|
-
* sessions that needed it most.
|
|
118
|
-
*
|
|
119
|
-
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
120
|
-
* @returns {KeyframeTable}
|
|
121
|
-
*/
|
|
122
|
-
of({ sourceKey, fileIndex }) {
|
|
123
|
-
const key = KeyframeTables.keyFor(sourceKey, fileIndex);
|
|
124
|
-
let table = this.#byFile.get(key);
|
|
125
|
-
if (!table) {
|
|
126
|
-
table = new KeyframeTable();
|
|
127
|
-
this.#byFile.set(key, table);
|
|
128
|
-
}
|
|
129
|
-
return table;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Fill in the file's table, reading once however many ask.
|
|
134
|
-
*
|
|
135
|
-
* Rejects when the read rejects — see point 3 in the file comment.
|
|
136
|
-
*
|
|
137
|
-
* @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
|
|
138
|
-
* @returns {Promise<KeyframeTable>}
|
|
139
|
-
*/
|
|
140
|
-
async read({ sourceKey, fileIndex, logName = "" }) {
|
|
141
|
-
const key = KeyframeTables.keyFor(sourceKey, fileIndex);
|
|
142
|
-
const table = this.of({ sourceKey, fileIndex });
|
|
143
|
-
if (table.answered) {
|
|
144
|
-
return table;
|
|
145
|
-
}
|
|
146
|
-
const running = this.#reading.get(key);
|
|
147
|
-
if (running) {
|
|
148
|
-
return running;
|
|
149
|
-
}
|
|
150
|
-
if (!this.#readTable) {
|
|
151
|
-
// Nothing can answer. NOT recorded as an answer about the file: this is
|
|
152
|
-
// an object built without its one connection to the outside, and saying
|
|
153
|
-
// "this file has no keyframes" on that basis would be a statement about
|
|
154
|
-
// the wiring dressed up as a statement about the bytes.
|
|
155
|
-
return table;
|
|
156
|
-
}
|
|
157
|
-
// The WHOLE wait, as whoever asked for the table experiences it: the swarm
|
|
158
|
-
// delivering the head and tail of the file, the parse over those bytes, and
|
|
159
|
-
// the crossing to the torrent thread and back. The worker's own line
|
|
160
|
-
// (`container-keyframes:`) reports the last two apart from the first, and
|
|
161
|
-
// reading the two lines as one figure is what led to a wrong conclusion on
|
|
162
|
-
// 2026-09-04 — they differ by up to sixty seconds on a thin swarm.
|
|
163
|
-
const startedMs = Date.now();
|
|
164
|
-
// Called here and not inside a `.then`, so that "one read per file" is true
|
|
165
|
-
// at the instant the second asker arrives rather than one microtask later.
|
|
166
|
-
// A reader that throws synchronously is a failed read like any other.
|
|
167
|
-
let started;
|
|
168
|
-
try {
|
|
169
|
-
started = Promise.resolve(this.#readTable({ sourceKey, fileIndex }));
|
|
170
|
-
} catch (error) {
|
|
171
|
-
started = Promise.reject(error);
|
|
172
|
-
}
|
|
173
|
-
const work = started
|
|
174
|
-
.then((reading) => {
|
|
175
|
-
table.learn({
|
|
176
|
-
times: Array.isArray(reading?.times) ? reading.times : null,
|
|
177
|
-
tolerance: reading?.tolerance,
|
|
178
|
-
// Which container answered, whether or not it produced a table: the
|
|
179
|
-
// refusal that follows names it, and "unknown" would make that line
|
|
180
|
-
// say nothing about the file it is refusing.
|
|
181
|
-
format: reading?.format ?? "unrecognised"
|
|
182
|
-
});
|
|
183
|
-
this.#logger.info(
|
|
184
|
-
`keyframe index "${logName}": ${table.readable ? `${table.count} times` : "none"} from the ` +
|
|
185
|
-
`${table.format} container, waited ${Date.now() - startedMs}ms`
|
|
186
|
-
);
|
|
187
|
-
return table;
|
|
188
|
-
})
|
|
189
|
-
.catch((error) => {
|
|
190
|
-
this.#logger.warn(
|
|
191
|
-
`keyframe index "${logName}": the read failed after ${Date.now() - startedMs}ms — ` +
|
|
192
|
-
`${error?.message ?? error}`
|
|
193
|
-
);
|
|
194
|
-
throw error;
|
|
195
|
-
})
|
|
196
|
-
.finally(() => {
|
|
197
|
-
this.#reading.delete(key);
|
|
198
|
-
});
|
|
199
|
-
this.#reading.set(key, work);
|
|
200
|
-
return work;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Start the read without waiting for it and without ever rejecting.
|
|
205
|
-
*
|
|
206
|
-
* The index lives at the END of a Matroska file, which is also where the
|
|
207
|
-
* codec probe reads — both wait for the same piece to arrive, and they used
|
|
208
|
-
* to do it one after the other: measured 2026-08-04, a probe of 722-1206 ms
|
|
209
|
-
* followed by an index read of 311-430 ms, all of it before the first
|
|
210
|
-
* segment. Started together, the second costs nothing.
|
|
211
|
-
*
|
|
212
|
-
* @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
|
|
213
|
-
* @returns {Promise<void>}
|
|
214
|
-
*/
|
|
215
|
-
async warm(params) {
|
|
216
|
-
try {
|
|
217
|
-
await this.read(params);
|
|
218
|
-
} catch {
|
|
219
|
-
// Best effort by construction: nobody is waiting for this answer yet.
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* The same read, with a bound on how long THIS caller waits for it.
|
|
225
|
-
*
|
|
226
|
-
* The read is not cancelled when the bound is reached — it goes on, and it
|
|
227
|
-
* lands in the table this returns, which is the file's own. What the bound
|
|
228
|
-
* decides is only whether this caller waits: a copied picture cannot be cut
|
|
229
|
-
* without the table, so the answer to "not yet" is to re-encode, which needs
|
|
230
|
-
* no table because it places the keyframes itself.
|
|
231
|
-
*
|
|
232
|
-
* @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
|
|
233
|
-
* @returns {Promise<{ table: KeyframeTable, arrived: boolean }>} `arrived` is
|
|
234
|
-
* about THIS wait. False with an unanswered table means the read is still
|
|
235
|
-
* running, which is not the same as a file with no keyframes.
|
|
236
|
-
*/
|
|
237
|
-
async within(params) {
|
|
238
|
-
const read = this.read(params);
|
|
239
|
-
let timer = null;
|
|
240
|
-
const budget = new Promise((resolve) => {
|
|
241
|
-
timer = setTimeout(() => resolve(null), this.#budgetMs);
|
|
242
|
-
// A caller must not be held open by this timer alone.
|
|
243
|
-
timer?.unref?.();
|
|
244
|
-
});
|
|
245
|
-
const answer = await Promise.race([read.then((table) => ({ table, arrived: true })), budget]);
|
|
246
|
-
clearTimeout(timer);
|
|
247
|
-
// Nothing is added here to swallow a late rejection: the race is holding a
|
|
248
|
-
// handler on that promise already, and a second one would only look like it
|
|
249
|
-
// was doing something.
|
|
250
|
-
return answer ?? { table: this.of(params), arrived: false };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Take in a table found by something other than the reader — the packet
|
|
255
|
-
* probe, which finds keyframes by decoding, where no container index exists.
|
|
256
|
-
*
|
|
257
|
-
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
258
|
-
* @param {{ times?: number[] | null, tolerance?: number, format?: string }} reading
|
|
259
|
-
* @returns {KeyframeTable} The file's table, as it now stands.
|
|
260
|
-
*/
|
|
261
|
-
learn(params, reading) {
|
|
262
|
-
return this.of(params).learn(reading);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Drop every table nobody holds any more.
|
|
267
|
-
*
|
|
268
|
-
* Swept by the OBJECT, the way the timelines, the outputs and the source
|
|
269
|
-
* files beside it are: what must survive is what a live session points at,
|
|
270
|
-
* and that is a reference rather than a string that happens to match one. A
|
|
271
|
-
* map that only ever grows is the shape of half the memory faults recorded in
|
|
272
|
-
* this repository.
|
|
273
|
-
*
|
|
274
|
-
* **A file whose read is still running is kept whatever the set says.** Drop
|
|
275
|
-
* it and the read completes into an object nobody holds, the next asker makes
|
|
276
|
-
* a fresh empty one, and the whole wait — up to a minute off a thin swarm —
|
|
277
|
-
* is paid again for an answer this process already had. That is also the
|
|
278
|
-
* window in which a table warmed by the playback planner has no session yet.
|
|
279
|
-
*
|
|
280
|
-
* @param {Set<import("./container/KeyframeTable.js").KeyframeTable>} inUse
|
|
281
|
-
* @returns {number} How many were dropped.
|
|
282
|
-
*/
|
|
283
|
-
forgetUnused(inUse) {
|
|
284
|
-
let dropped = 0;
|
|
285
|
-
for (const [key, table] of [...this.#byFile.entries()]) {
|
|
286
|
-
if (inUse.has(table) || this.#reading.has(key)) {
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
this.#byFile.delete(key);
|
|
290
|
-
dropped += 1;
|
|
291
|
-
}
|
|
292
|
-
return dropped;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* @param {string} sourceKey
|
|
297
|
-
* @param {number} [fileIndex] - Absent, every file of this torrent.
|
|
298
|
-
* @returns {void}
|
|
299
|
-
*/
|
|
300
|
-
forget(sourceKey, fileIndex) {
|
|
301
|
-
if (fileIndex === undefined) {
|
|
302
|
-
for (const key of [...this.#byFile.keys()]) {
|
|
303
|
-
if (key.startsWith(`${sourceKey}:`)) {
|
|
304
|
-
this.#byFile.delete(key);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
this.#byFile.delete(KeyframeTables.keyFor(sourceKey, fileIndex));
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/** @returns {number} */
|
|
313
|
-
get size() {
|
|
314
|
-
return this.#byFile.size;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Reading the keyframe table of a file: once, whoever asks, with a bound
|
|
3
|
+
* on how long anybody waits for it.
|
|
4
|
+
*
|
|
5
|
+
* The table itself is `container/KeyframeTable.js`, one object per file. This is
|
|
6
|
+
* the policy around filling it in, and it is a policy because three things had
|
|
7
|
+
* to be true at once and each of them was learned from a field session:
|
|
8
|
+
*
|
|
9
|
+
* 1. **once per file, and one WAIT per file.** Two sessions created in the same
|
|
10
|
+
* moment used to miss the cache together and read the table twice — which is
|
|
11
|
+
* what two viewers opening one film do, measured 13 ms apart on 2026-09-03.
|
|
12
|
+
* Whoever asks second joins the read already running;
|
|
13
|
+
* 2. **the wait is bounded, the READ is not.** A picture cannot be copied
|
|
14
|
+
* without the table, so the answer to "not yet" is to re-encode — but the
|
|
15
|
+
* read goes on, lands in the file's own table, and is there for whoever holds
|
|
16
|
+
* it. Cancelling it would make every later viewer pay the whole wait again;
|
|
17
|
+
* 3. **a read that THREW is not an answer.** "The head is not downloaded" says
|
|
18
|
+
* nothing about the file; turning it into "this file has no keyframes" makes
|
|
19
|
+
* every picture of it a re-encode for as long as the process lives.
|
|
20
|
+
*
|
|
21
|
+
* **What it does NOT know.** Where the bytes come from. The reader is one
|
|
22
|
+
* function handed in at construction — on this proxy it crosses to the torrent
|
|
23
|
+
* thread, where one container per file answers the track table and the media
|
|
24
|
+
* info from the same header — so this can be exercised with plain values alone.
|
|
25
|
+
* It used to live in the session manager, which also carried a SECOND reader
|
|
26
|
+
* that fetched the file over the proxy's own HTTP and parsed it there: a
|
|
27
|
+
* transport inside a layer that must not have one, and a second answer to a
|
|
28
|
+
* question with one owner.
|
|
29
|
+
*
|
|
30
|
+
* **Why this is not `ContainerOrchestrator`, which is the same shape and sits
|
|
31
|
+
* in this directory: the THREAD, not the responsibility.** That one holds a
|
|
32
|
+
* container per file and needs byte ranges to build it, so it can only live
|
|
33
|
+
* where the torrent is — the worker. This lives on the main thread, where the
|
|
34
|
+
* sessions are, and reaches the other across the channel. The table is
|
|
35
|
+
* therefore held twice, once per thread, and the two are not equals: the
|
|
36
|
+
* worker's is the parse, memoized on the container it parsed, and the main
|
|
37
|
+
* thread's is the answer, which nothing in the worker ever reads back. Three
|
|
38
|
+
* directories of the storage layer are kept apart for the same reason, and it
|
|
39
|
+
* is stated here for the same reason too — an unexplained second home reads as
|
|
40
|
+
* a second owner.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { KeyframeTable } from "./container/KeyframeTable.js";
|
|
44
|
+
import { logger as defaultLogger } from "../../utils/logger.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How long anybody waits for the table before giving up on copying the picture.
|
|
48
|
+
*
|
|
49
|
+
* Measured on the addon host, 2026-09-04, over seventeen files from
|
|
50
|
+
* `Dropbox/trn` — four containers, pieces from 0.25 to 16 MB, files from 0.36 to
|
|
51
|
+
* 20 GB, each torrent registered fresh so nothing of it was downloaded
|
|
52
|
+
* (`research/keyframe-table-read-2026-09-04.md`). Every table that arrived did
|
|
53
|
+
* so within 24.8 s, most within half a second; the two files that answered
|
|
54
|
+
* nothing took 120.9 s and 120.5 s.
|
|
55
|
+
*
|
|
56
|
+
* Those two figures are not a coincidence and they are what fixes this one:
|
|
57
|
+
* they are TWO of the bound the read already has — `READ_ABANDON_MS` in
|
|
58
|
+
* `torrent/worker/resume-warm.js`, one for the wait on the file's edges and
|
|
59
|
+
* one for the read itself, in series. A caller waiting for two of them is the
|
|
60
|
+
* defect; waiting for one is the bound, and it leaves 2.4x over the slowest
|
|
61
|
+
* table that did arrive. The line printed when it fires names which case
|
|
62
|
+
* happened, so the field can move it rather than an argument.
|
|
63
|
+
*/
|
|
64
|
+
export const KEYFRAME_TABLE_BUDGET_MS = 60_000;
|
|
65
|
+
|
|
66
|
+
export class KeyframeTables {
|
|
67
|
+
/** One per file, handed out rather than copied. @type {Map<string, KeyframeTable>} */
|
|
68
|
+
#byFile = new Map();
|
|
69
|
+
|
|
70
|
+
/** The read in flight, so the second asker joins it. @type {Map<string, Promise<KeyframeTable>>} */
|
|
71
|
+
#reading = new Map();
|
|
72
|
+
|
|
73
|
+
/** @type {((params: { sourceKey: string, fileIndex: number }) => Promise<object | null>) | null} */
|
|
74
|
+
#readTable;
|
|
75
|
+
|
|
76
|
+
/** @type {number} */
|
|
77
|
+
#budgetMs;
|
|
78
|
+
|
|
79
|
+
/** @type {{ info: Function, warn: Function }} */
|
|
80
|
+
#logger;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {object} [params]
|
|
84
|
+
* @param {(params: { sourceKey: string, fileIndex: number }) => Promise<object | null>} [params.readTable] -
|
|
85
|
+
* Whatever can answer what this file's container states. The whole of this
|
|
86
|
+
* object's outside world.
|
|
87
|
+
* @param {number} [params.budgetMs] - How long `within` waits.
|
|
88
|
+
* @param {{ info: Function, warn: Function }} [params.logger]
|
|
89
|
+
*/
|
|
90
|
+
constructor({ readTable = null, budgetMs = KEYFRAME_TABLE_BUDGET_MS, logger = defaultLogger } = {}) {
|
|
91
|
+
this.#readTable = typeof readTable === "function" ? readTable : null;
|
|
92
|
+
const declared = Number(budgetMs);
|
|
93
|
+
this.#budgetMs = Number.isFinite(declared) && declared > 0 ? declared : KEYFRAME_TABLE_BUDGET_MS;
|
|
94
|
+
this.#logger = logger;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} sourceKey
|
|
99
|
+
* @param {number} fileIndex
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
static keyFor(sourceKey, fileIndex) {
|
|
103
|
+
return `${sourceKey}:${fileIndex}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How long a caller of `within` waits. @returns {number} */
|
|
107
|
+
get budgetMs() {
|
|
108
|
+
return this.#budgetMs;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* This file's table — the one object everyone reading it holds.
|
|
113
|
+
*
|
|
114
|
+
* Made empty on first ask rather than on first answer, so a session created
|
|
115
|
+
* before the read finishes holds the very object the answer lands in. Handing
|
|
116
|
+
* out a value instead is what used to leave a late table unreachable to the
|
|
117
|
+
* sessions that needed it most.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
120
|
+
* @returns {KeyframeTable}
|
|
121
|
+
*/
|
|
122
|
+
of({ sourceKey, fileIndex }) {
|
|
123
|
+
const key = KeyframeTables.keyFor(sourceKey, fileIndex);
|
|
124
|
+
let table = this.#byFile.get(key);
|
|
125
|
+
if (!table) {
|
|
126
|
+
table = new KeyframeTable();
|
|
127
|
+
this.#byFile.set(key, table);
|
|
128
|
+
}
|
|
129
|
+
return table;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Fill in the file's table, reading once however many ask.
|
|
134
|
+
*
|
|
135
|
+
* Rejects when the read rejects — see point 3 in the file comment.
|
|
136
|
+
*
|
|
137
|
+
* @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
|
|
138
|
+
* @returns {Promise<KeyframeTable>}
|
|
139
|
+
*/
|
|
140
|
+
async read({ sourceKey, fileIndex, logName = "" }) {
|
|
141
|
+
const key = KeyframeTables.keyFor(sourceKey, fileIndex);
|
|
142
|
+
const table = this.of({ sourceKey, fileIndex });
|
|
143
|
+
if (table.answered) {
|
|
144
|
+
return table;
|
|
145
|
+
}
|
|
146
|
+
const running = this.#reading.get(key);
|
|
147
|
+
if (running) {
|
|
148
|
+
return running;
|
|
149
|
+
}
|
|
150
|
+
if (!this.#readTable) {
|
|
151
|
+
// Nothing can answer. NOT recorded as an answer about the file: this is
|
|
152
|
+
// an object built without its one connection to the outside, and saying
|
|
153
|
+
// "this file has no keyframes" on that basis would be a statement about
|
|
154
|
+
// the wiring dressed up as a statement about the bytes.
|
|
155
|
+
return table;
|
|
156
|
+
}
|
|
157
|
+
// The WHOLE wait, as whoever asked for the table experiences it: the swarm
|
|
158
|
+
// delivering the head and tail of the file, the parse over those bytes, and
|
|
159
|
+
// the crossing to the torrent thread and back. The worker's own line
|
|
160
|
+
// (`container-keyframes:`) reports the last two apart from the first, and
|
|
161
|
+
// reading the two lines as one figure is what led to a wrong conclusion on
|
|
162
|
+
// 2026-09-04 — they differ by up to sixty seconds on a thin swarm.
|
|
163
|
+
const startedMs = Date.now();
|
|
164
|
+
// Called here and not inside a `.then`, so that "one read per file" is true
|
|
165
|
+
// at the instant the second asker arrives rather than one microtask later.
|
|
166
|
+
// A reader that throws synchronously is a failed read like any other.
|
|
167
|
+
let started;
|
|
168
|
+
try {
|
|
169
|
+
started = Promise.resolve(this.#readTable({ sourceKey, fileIndex }));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
started = Promise.reject(error);
|
|
172
|
+
}
|
|
173
|
+
const work = started
|
|
174
|
+
.then((reading) => {
|
|
175
|
+
table.learn({
|
|
176
|
+
times: Array.isArray(reading?.times) ? reading.times : null,
|
|
177
|
+
tolerance: reading?.tolerance,
|
|
178
|
+
// Which container answered, whether or not it produced a table: the
|
|
179
|
+
// refusal that follows names it, and "unknown" would make that line
|
|
180
|
+
// say nothing about the file it is refusing.
|
|
181
|
+
format: reading?.format ?? "unrecognised"
|
|
182
|
+
});
|
|
183
|
+
this.#logger.info(
|
|
184
|
+
`keyframe index "${logName}": ${table.readable ? `${table.count} times` : "none"} from the ` +
|
|
185
|
+
`${table.format} container, waited ${Date.now() - startedMs}ms`
|
|
186
|
+
);
|
|
187
|
+
return table;
|
|
188
|
+
})
|
|
189
|
+
.catch((error) => {
|
|
190
|
+
this.#logger.warn(
|
|
191
|
+
`keyframe index "${logName}": the read failed after ${Date.now() - startedMs}ms — ` +
|
|
192
|
+
`${error?.message ?? error}`
|
|
193
|
+
);
|
|
194
|
+
throw error;
|
|
195
|
+
})
|
|
196
|
+
.finally(() => {
|
|
197
|
+
this.#reading.delete(key);
|
|
198
|
+
});
|
|
199
|
+
this.#reading.set(key, work);
|
|
200
|
+
return work;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Start the read without waiting for it and without ever rejecting.
|
|
205
|
+
*
|
|
206
|
+
* The index lives at the END of a Matroska file, which is also where the
|
|
207
|
+
* codec probe reads — both wait for the same piece to arrive, and they used
|
|
208
|
+
* to do it one after the other: measured 2026-08-04, a probe of 722-1206 ms
|
|
209
|
+
* followed by an index read of 311-430 ms, all of it before the first
|
|
210
|
+
* segment. Started together, the second costs nothing.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
|
|
213
|
+
* @returns {Promise<void>}
|
|
214
|
+
*/
|
|
215
|
+
async warm(params) {
|
|
216
|
+
try {
|
|
217
|
+
await this.read(params);
|
|
218
|
+
} catch {
|
|
219
|
+
// Best effort by construction: nobody is waiting for this answer yet.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The same read, with a bound on how long THIS caller waits for it.
|
|
225
|
+
*
|
|
226
|
+
* The read is not cancelled when the bound is reached — it goes on, and it
|
|
227
|
+
* lands in the table this returns, which is the file's own. What the bound
|
|
228
|
+
* decides is only whether this caller waits: a copied picture cannot be cut
|
|
229
|
+
* without the table, so the answer to "not yet" is to re-encode, which needs
|
|
230
|
+
* no table because it places the keyframes itself.
|
|
231
|
+
*
|
|
232
|
+
* @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
|
|
233
|
+
* @returns {Promise<{ table: KeyframeTable, arrived: boolean }>} `arrived` is
|
|
234
|
+
* about THIS wait. False with an unanswered table means the read is still
|
|
235
|
+
* running, which is not the same as a file with no keyframes.
|
|
236
|
+
*/
|
|
237
|
+
async within(params) {
|
|
238
|
+
const read = this.read(params);
|
|
239
|
+
let timer = null;
|
|
240
|
+
const budget = new Promise((resolve) => {
|
|
241
|
+
timer = setTimeout(() => resolve(null), this.#budgetMs);
|
|
242
|
+
// A caller must not be held open by this timer alone.
|
|
243
|
+
timer?.unref?.();
|
|
244
|
+
});
|
|
245
|
+
const answer = await Promise.race([read.then((table) => ({ table, arrived: true })), budget]);
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
// Nothing is added here to swallow a late rejection: the race is holding a
|
|
248
|
+
// handler on that promise already, and a second one would only look like it
|
|
249
|
+
// was doing something.
|
|
250
|
+
return answer ?? { table: this.of(params), arrived: false };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Take in a table found by something other than the reader — the packet
|
|
255
|
+
* probe, which finds keyframes by decoding, where no container index exists.
|
|
256
|
+
*
|
|
257
|
+
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
258
|
+
* @param {{ times?: number[] | null, tolerance?: number, format?: string }} reading
|
|
259
|
+
* @returns {KeyframeTable} The file's table, as it now stands.
|
|
260
|
+
*/
|
|
261
|
+
learn(params, reading) {
|
|
262
|
+
return this.of(params).learn(reading);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Drop every table nobody holds any more.
|
|
267
|
+
*
|
|
268
|
+
* Swept by the OBJECT, the way the timelines, the outputs and the source
|
|
269
|
+
* files beside it are: what must survive is what a live session points at,
|
|
270
|
+
* and that is a reference rather than a string that happens to match one. A
|
|
271
|
+
* map that only ever grows is the shape of half the memory faults recorded in
|
|
272
|
+
* this repository.
|
|
273
|
+
*
|
|
274
|
+
* **A file whose read is still running is kept whatever the set says.** Drop
|
|
275
|
+
* it and the read completes into an object nobody holds, the next asker makes
|
|
276
|
+
* a fresh empty one, and the whole wait — up to a minute off a thin swarm —
|
|
277
|
+
* is paid again for an answer this process already had. That is also the
|
|
278
|
+
* window in which a table warmed by the playback planner has no session yet.
|
|
279
|
+
*
|
|
280
|
+
* @param {Set<import("./container/KeyframeTable.js").KeyframeTable>} inUse
|
|
281
|
+
* @returns {number} How many were dropped.
|
|
282
|
+
*/
|
|
283
|
+
forgetUnused(inUse) {
|
|
284
|
+
let dropped = 0;
|
|
285
|
+
for (const [key, table] of [...this.#byFile.entries()]) {
|
|
286
|
+
if (inUse.has(table) || this.#reading.has(key)) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
this.#byFile.delete(key);
|
|
290
|
+
dropped += 1;
|
|
291
|
+
}
|
|
292
|
+
return dropped;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {string} sourceKey
|
|
297
|
+
* @param {number} [fileIndex] - Absent, every file of this torrent.
|
|
298
|
+
* @returns {void}
|
|
299
|
+
*/
|
|
300
|
+
forget(sourceKey, fileIndex) {
|
|
301
|
+
if (fileIndex === undefined) {
|
|
302
|
+
for (const key of [...this.#byFile.keys()]) {
|
|
303
|
+
if (key.startsWith(`${sourceKey}:`)) {
|
|
304
|
+
this.#byFile.delete(key);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.#byFile.delete(KeyframeTables.keyFor(sourceKey, fileIndex));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** @returns {number} */
|
|
313
|
+
get size() {
|
|
314
|
+
return this.#byFile.size;
|
|
315
|
+
}
|
|
316
|
+
}
|