@torrent-tv/proxy 2.87.8 → 2.87.10
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 +8 -0
- 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/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 +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +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/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/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 +3 -0
- 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/server/WaitLedger.js +142 -142
- 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/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +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 +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +43 -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 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/utils/torrent-source-key.js +38 -38
|
@@ -1,520 +1,520 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Reading the subtitle cues a file already holds, and the plan of its
|
|
3
|
-
* subtitle tracks.
|
|
4
|
-
*
|
|
5
|
-
* It walks ONLY what is downloaded: switching subtitles on must never pull
|
|
6
|
-
* bytes the viewer is not waiting for. The head and the Cues table are the one
|
|
7
|
-
* exception and are fetched — they are kilobytes, they are needed before
|
|
8
|
-
* anything can be offered, and the codec probe has already pulled the head for
|
|
9
|
-
* every file that plays.
|
|
10
|
-
*
|
|
11
|
-
* **The torrent is not here, and that is the point.** This layer is handed a
|
|
12
|
-
* `HeldFile`: a name, a length, which ranges are downloaded whole, and how to
|
|
13
|
-
* read one of them without fetching. Piece length, file offsets and the
|
|
14
|
-
* bitfield are the torrent's words and stay in the torrent's thread
|
|
15
|
-
* (`torrent/worker/held-bytes.js`). Until 2026-09-15 this whole file lived
|
|
16
|
-
* there, which put a container parse — the media layer's work — in the thread
|
|
17
|
-
* that owns the swarm, and made every answer something to carry back across a
|
|
18
|
-
* channel.
|
|
19
|
-
*
|
|
20
|
-
* What is genuinely this file's own, and travels with it: the found-order
|
|
21
|
-
* cursor a browser follows, the per-file state, and one walk of a file at a
|
|
22
|
-
* time.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
26
|
-
import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
|
|
27
|
-
import { detectLanguage } from "./tracks/language-detect.js";
|
|
28
|
-
import { logger } from "../../utils/logger.js";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* One file of one torrent, as much of it as this layer is allowed to know.
|
|
33
|
-
*
|
|
34
|
-
* Four plain values and two functions: what it is called, how long it is, which
|
|
35
|
-
* of it is downloaded whole, and how to read a range of that without asking the
|
|
36
|
-
* swarm for anything. No torrent, no bitfield, no piece length — those are the
|
|
37
|
-
* torrent's words, and turning them into these is its job.
|
|
38
|
-
*
|
|
39
|
-
* @typedef {object} HeldFile
|
|
40
|
-
* @property {string} sourceKey
|
|
41
|
-
* @property {number} fileIndex
|
|
42
|
-
* @property {string} name
|
|
43
|
-
* @property {number} length
|
|
44
|
-
* @property {() => Promise<Array<[number, number]>>} heldRanges - Ascending,
|
|
45
|
-
* non-overlapping, inclusive offsets within the file.
|
|
46
|
-
* @property {(start: number, end: number) => Promise<Buffer | null>} readHeld -
|
|
47
|
-
* Null where those bytes are not there, which is never fetched.
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Whether a range falls entirely inside one of the held runs.
|
|
52
|
-
*
|
|
53
|
-
* @param {Array<[number, number]> | null} ranges
|
|
54
|
-
* @param {number} start
|
|
55
|
-
* @param {number} end - Inclusive.
|
|
56
|
-
* @returns {boolean}
|
|
57
|
-
*/
|
|
58
|
-
function rangeHolds(ranges, start, end) {
|
|
59
|
-
if (!Array.isArray(ranges) || !(end >= start)) {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
for (const [from, to] of ranges) {
|
|
63
|
-
if (start >= from && end <= to) {
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
|
|
71
|
-
const byFile = new Map();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* A container over one file of a torrent, told how to read it.
|
|
77
|
-
*
|
|
78
|
-
* The container is given two readers and a predicate and never the torrent:
|
|
79
|
-
* whether a byte range is already downloaded, how to read one without asking
|
|
80
|
-
* the swarm, and how to read one that may need fetching. That is the whole of
|
|
81
|
-
* what this layer knows and the container does not.
|
|
82
|
-
*
|
|
83
|
-
* WHICH container is decided from the bytes, by the factory's sniff — the
|
|
84
|
-
* header is what the muxer wrote, and a file name is what somebody typed. Only
|
|
85
|
-
* where the head is not downloaded, and so cannot be sniffed without asking the
|
|
86
|
-
* swarm, does the name answer instead. One choice either way: the caller never
|
|
87
|
-
* decides a second time from a track's shape, which is two decisions from
|
|
88
|
-
* different evidence that have to agree.
|
|
89
|
-
*
|
|
90
|
-
* @param {object} state - This file's state; the container is kept on it.
|
|
91
|
-
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
92
|
-
* @returns {Promise<import("./container/Container.js").Container | null>}
|
|
93
|
-
*/
|
|
94
|
-
async function containerOver(state, source) {
|
|
95
|
-
if (state.container !== null) {
|
|
96
|
-
return state.container;
|
|
97
|
-
}
|
|
98
|
-
const last = Number(source.length) - 1;
|
|
99
|
-
const held = async (start, end) => source.readHeld(start, Math.min(end, last));
|
|
100
|
-
const params = {
|
|
101
|
-
readRange: held,
|
|
102
|
-
readHeld: held,
|
|
103
|
-
// Answered from the list of ranges this file has downloaded WHOLE, taken
|
|
104
|
-
// once per pass rather than asked per cluster: a pass asks about every
|
|
105
|
-
// cluster of the file, and the torrent that knows is on the other thread.
|
|
106
|
-
isHeld: (start, end) => rangeHolds(state.held, start, Math.min(end, last)),
|
|
107
|
-
fileSize: source.length,
|
|
108
|
-
label: String(source.name ?? "")
|
|
109
|
-
};
|
|
110
|
-
const sniffed = await ContainerFactory.create(params);
|
|
111
|
-
if (sniffed) {
|
|
112
|
-
state.container = sniffed;
|
|
113
|
-
return state.container;
|
|
114
|
-
}
|
|
115
|
-
const ByName = ContainerFactory.byName(source.name);
|
|
116
|
-
state.container = ByName ? new ByName(params) : null;
|
|
117
|
-
return state.container;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* The subtitle tracks of a file, read once and kept.
|
|
122
|
-
*
|
|
123
|
-
* The head and the Cues table are two short reads, and they ARE fetched if
|
|
124
|
-
* missing — they are kilobytes, they are needed before anything can be offered,
|
|
125
|
-
* and the codec probe has already pulled the head for every file that plays.
|
|
126
|
-
*
|
|
127
|
-
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
128
|
-
* @param {string} key - `sourceKey:fileIndex`.
|
|
129
|
-
* @returns {Promise<object | null>}
|
|
130
|
-
*/
|
|
131
|
-
async function planFor(source, key) {
|
|
132
|
-
const state = stateFor(key);
|
|
133
|
-
if (state.plan !== null) {
|
|
134
|
-
return state.plan;
|
|
135
|
-
}
|
|
136
|
-
// The head and the Cues table are two reads that DO wait on the swarm, so two
|
|
137
|
-
// callers arriving together would both make them. One promise, awaited by
|
|
138
|
-
// whoever asks while it is in flight.
|
|
139
|
-
if (!state.planPromise) {
|
|
140
|
-
state.planPromise = readPlan(source, state).finally(() => {
|
|
141
|
-
state.planPromise = null;
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
return state.planPromise;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* The state kept for one file, created on first use.
|
|
149
|
-
*
|
|
150
|
-
* @param {string} key - `sourceKey:fileIndex`.
|
|
151
|
-
* @returns {object}
|
|
152
|
-
*/
|
|
153
|
-
function stateFor(key) {
|
|
154
|
-
let state = byFile.get(key);
|
|
155
|
-
// A state that has been forgotten is not handed out again, even in the moment
|
|
156
|
-
// between the call and the walk that was still running finishing.
|
|
157
|
-
if (state?.forgotten === true) {
|
|
158
|
-
state = undefined;
|
|
159
|
-
}
|
|
160
|
-
if (!state) {
|
|
161
|
-
state = {
|
|
162
|
-
plan: null,
|
|
163
|
-
planPromise: null,
|
|
164
|
-
forgotten: false,
|
|
165
|
-
// One walk of a file at a time — see `serialize`.
|
|
166
|
-
chain: Promise.resolve(),
|
|
167
|
-
// The container over this file, built once. It caches what it has parsed
|
|
168
|
-
// — a `moov` box is tens of megabytes off a torrent — so building a fresh
|
|
169
|
-
// one per call would throw that away on every request.
|
|
170
|
-
container: null,
|
|
171
|
-
// Which byte ranges of this file are downloaded WHOLE, as of the start of
|
|
172
|
-
// the pass now running. Taken once and read many times: a pass asks about
|
|
173
|
-
// every cluster of the file, and what knows the answer is on the other
|
|
174
|
-
// thread.
|
|
175
|
-
held: null,
|
|
176
|
-
harvested: new Map(),
|
|
177
|
-
cues: new Map(),
|
|
178
|
-
seq: new Map(),
|
|
179
|
-
walked: new Set(),
|
|
180
|
-
// The found-order cursor of the last cue PUSHED for each track, so a
|
|
181
|
-
// second warmup pass sends only what a first one did not — the same
|
|
182
|
-
// found-order idea `?since=` uses for a browser's own pull.
|
|
183
|
-
pushed: new Map()
|
|
184
|
-
};
|
|
185
|
-
byFile.set(key, state);
|
|
186
|
-
}
|
|
187
|
-
return state;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Run `work` after every walk of this file already started, and before any
|
|
192
|
-
* started after it.
|
|
193
|
-
*
|
|
194
|
-
* Both entry points here — a browser's own pull and the warmup that runs ahead
|
|
195
|
-
* of it — mark a cluster as walked only AFTER reading and parsing it, which is
|
|
196
|
-
* two suspension points later. Until 2.56.0 nothing stopped a second call
|
|
197
|
-
* arriving in between: `warmActiveFiles` runs on every verified piece AND on a
|
|
198
|
-
* 3 s timer, so on a fast download the same cluster was read and parsed several
|
|
199
|
-
* times over and the same line could be pushed twice under different `seq`
|
|
200
|
-
* numbers. Each of those reads is a WebTorrent file stream, which selects and
|
|
201
|
-
* deselects its pieces, so the repetition reached the piece picker as well.
|
|
202
|
-
*
|
|
203
|
-
* @template T
|
|
204
|
-
* @param {object} state
|
|
205
|
-
* @param {() => Promise<T>} work
|
|
206
|
-
* @returns {Promise<T>}
|
|
207
|
-
*/
|
|
208
|
-
function serialize(state, work) {
|
|
209
|
-
const run = state.chain.then(work, work);
|
|
210
|
-
// The queue must survive a failed walk, so what is chained is the settled
|
|
211
|
-
// form; the caller still sees the rejection.
|
|
212
|
-
state.chain = run.then(() => undefined, () => undefined);
|
|
213
|
-
return run;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Read one file's subtitle plan — the tracks it declares and where the clusters
|
|
218
|
-
* holding them are. Called once per file; see `planFor`.
|
|
219
|
-
*
|
|
220
|
-
* @param {object} torrent
|
|
221
|
-
* @param {object} state
|
|
222
|
-
* @returns {Promise<object>}
|
|
223
|
-
*/
|
|
224
|
-
async function readPlan(source, state) {
|
|
225
|
-
// `declared` is what the container itself says about its subtitle tracks, in
|
|
226
|
-
// its own order. Empty means the container said nothing — which is a real
|
|
227
|
-
// answer and not a missing one: nothing is then shown unasked. An MP4 has no
|
|
228
|
-
// element that means "show this subtitle track by default", so it declares
|
|
229
|
-
// nothing however many tracks it carries.
|
|
230
|
-
const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
231
|
-
if (!source || !(Number(source.length) > 0)) {
|
|
232
|
-
state.plan = empty;
|
|
233
|
-
return state.plan;
|
|
234
|
-
}
|
|
235
|
-
state.held = await source.heldRanges();
|
|
236
|
-
const container = await containerOver(state, source);
|
|
237
|
-
if (!container) {
|
|
238
|
-
state.plan = empty;
|
|
239
|
-
return state.plan;
|
|
240
|
-
}
|
|
241
|
-
const plan = await container.readSubtitlePlan();
|
|
242
|
-
state.plan = plan ?? empty;
|
|
243
|
-
if (state.plan.tracks.length > 0) {
|
|
244
|
-
logger.info(
|
|
245
|
-
`subtitles: "${String(source.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
|
|
246
|
-
`of ${state.plan.declared.length} declared — ` +
|
|
247
|
-
state.plan.tracks
|
|
248
|
-
// `s:N` is the number the browser names (ffmpeg's own), and it differs
|
|
249
|
-
// from the file's track number whenever a picture track sits among them.
|
|
250
|
-
.map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
|
|
251
|
-
`${track.name ? `/${track.name}` : ""}(${track.clusterPositions.length} indexed)`)
|
|
252
|
-
.join(" ")
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
return state.plan;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* The order a cue was FOUND in, which is the only cursor a browser can follow.
|
|
260
|
-
*
|
|
261
|
-
* A cue's TIME cannot serve as one. Cues are harvested out of whichever
|
|
262
|
-
* clusters happen to be downloaded, and those are not contiguous, so the set
|
|
263
|
-
* grows in the middle as well as at the end. A browser that remembered "the
|
|
264
|
-
* latest time I hold" and asked for everything past it would never be sent the
|
|
265
|
-
* cues that turn up BEHIND that mark afterwards — which is exactly the stretch
|
|
266
|
-
* it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
|
|
267
|
-
* carried cues out to 1176 s, and from then on every cue between the two was
|
|
268
|
-
* filtered away for the rest of the session, with 59 of 276 clusters read.
|
|
269
|
-
*
|
|
270
|
-
* Found-order is monotonic by construction, so `?since=<n>` is exact however
|
|
271
|
-
* the file arrives.
|
|
272
|
-
*
|
|
273
|
-
* @param {{ seq: Map<number, number> }} state
|
|
274
|
-
* @param {number} trackNumber
|
|
275
|
-
* @returns {number}
|
|
276
|
-
*/
|
|
277
|
-
function nextSeq(state, trackNumber) {
|
|
278
|
-
const next = (state.seq.get(trackNumber) ?? 0) + 1;
|
|
279
|
-
state.seq.set(trackNumber, next);
|
|
280
|
-
return next;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Every cue of one track that can be read from what is already downloaded.
|
|
285
|
-
*
|
|
286
|
-
* @param {object} torrent
|
|
287
|
-
* @param {number} fileIndex
|
|
288
|
-
* @param {string} sourceKey
|
|
289
|
-
* @param {number} trackNumber
|
|
290
|
-
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
291
|
-
*/
|
|
292
|
-
export async function cuesHeldFor(source, trackNumber) {
|
|
293
|
-
const key = `${source.sourceKey}:${source.fileIndex}`;
|
|
294
|
-
// A source that cannot say which of itself is downloaded makes every range
|
|
295
|
-
// read as "not there", so the walk reads nothing and returns an empty list —
|
|
296
|
-
// which is also what a file with no cues yet returns, and that is how this
|
|
297
|
-
// went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
|
|
298
|
-
// the browser served `WEBVTT` and nothing else). It is not repairable here,
|
|
299
|
-
// so it is said rather than swallowed.
|
|
300
|
-
if (typeof source.heldRanges !== "function" || typeof source.readHeld !== "function") {
|
|
301
|
-
logger.warn(
|
|
302
|
-
`subtitles: asked for cues of "${String(source.name ?? key).slice(0, 40)}" ` +
|
|
303
|
-
"on a source that cannot say which of it is downloaded — no cluster can be read, " +
|
|
304
|
-
"and the answer would be an empty document indistinguishable from a file with no cues"
|
|
305
|
-
);
|
|
306
|
-
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
307
|
-
}
|
|
308
|
-
const plan = await planFor(source, key);
|
|
309
|
-
const state = stateFor(key);
|
|
310
|
-
const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
|
|
311
|
-
if (!track) {
|
|
312
|
-
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
313
|
-
}
|
|
314
|
-
return serialize(state, () => walkFor(source, state, plan, track, trackNumber));
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
|
|
319
|
-
* one file to one walk at a time.
|
|
320
|
-
*
|
|
321
|
-
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
322
|
-
* @param {object} state
|
|
323
|
-
* @param {object} plan
|
|
324
|
-
* @param {object} track
|
|
325
|
-
* @param {number} trackNumber
|
|
326
|
-
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
327
|
-
*/
|
|
328
|
-
async function walkFor(source, state, plan, track, trackNumber) {
|
|
329
|
-
// Taken once per pass: pieces keep arriving, and what may be read now is a
|
|
330
|
-
// different list from what could be read when the plan was built.
|
|
331
|
-
state.held = await source.heldRanges();
|
|
332
|
-
const container = await containerOver(state, source);
|
|
333
|
-
if (!container) {
|
|
334
|
-
return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// One question, whichever container this is. Matroska walks the clusters its
|
|
338
|
-
// Cues table names and fills every track from one walk; an MP4 reads the
|
|
339
|
-
// samples its own table states for this track. Nothing here chooses between
|
|
340
|
-
// them, which is the point: the container was chosen once, from the bytes.
|
|
341
|
-
const { found, covered, indexed } = await container.readHeldCues(plan, track, state);
|
|
342
|
-
|
|
343
|
-
for (const [number, cues] of found) {
|
|
344
|
-
let into = state.cues.get(number);
|
|
345
|
-
if (!into) {
|
|
346
|
-
into = [];
|
|
347
|
-
state.cues.set(number, into);
|
|
348
|
-
}
|
|
349
|
-
for (const cue of cues) {
|
|
350
|
-
into.push({ ...cue, seq: nextSeq(state, number) });
|
|
351
|
-
}
|
|
352
|
-
into.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return {
|
|
356
|
-
cues: state.cues.get(trackNumber) ?? [],
|
|
357
|
-
coveredClusters: covered,
|
|
358
|
-
indexedClusters: indexed,
|
|
359
|
-
track
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Walk whatever clusters have newly arrived, for every text track a file
|
|
365
|
-
* carries, and report what is new since the last call — so the cues can be
|
|
366
|
-
* PUSHED to a browser rather than left for it to come back and ask.
|
|
367
|
-
*
|
|
368
|
-
* `cuesHeldFor` already skips positions it has walked before (`state.walked`),
|
|
369
|
-
* so calling this on a timer or on every verified piece is cheap once a file
|
|
370
|
-
* is caught up: the only cost is deciding there is nothing new to read. It is
|
|
371
|
-
* `getSubtitleCues` run ahead of being asked, on the same state that call
|
|
372
|
-
* itself would build — nothing is duplicated, and a file nobody has opened
|
|
373
|
-
* costs nothing beyond this.
|
|
374
|
-
*
|
|
375
|
-
* @param {object} torrent
|
|
376
|
-
* @param {number} fileIndex
|
|
377
|
-
* @param {string} sourceKey
|
|
378
|
-
* @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
|
|
379
|
-
* One entry per track that gained at least one cue since the last call.
|
|
380
|
-
* `trackIndex` is `declaredIndex` — the track's position among ALL the file's
|
|
381
|
-
* subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
|
|
382
|
-
* knows. NOT the container's own track number, and not the position among the
|
|
383
|
-
* readable tracks either: counting those alone puts every text track after a
|
|
384
|
-
* picture-based one in the wrong place.
|
|
385
|
-
*/
|
|
386
|
-
export async function warmSubtitleCues(source) {
|
|
387
|
-
const key = `${source.sourceKey}:${source.fileIndex}`;
|
|
388
|
-
const plan = await planFor(source, key);
|
|
389
|
-
const state = stateFor(key);
|
|
390
|
-
const fresh = [];
|
|
391
|
-
const tracks = plan?.tracks ?? [];
|
|
392
|
-
for (let order = 0; order < tracks.length; order += 1) {
|
|
393
|
-
const track = tracks[order];
|
|
394
|
-
const held = await cuesHeldFor(source, track.trackNumber);
|
|
395
|
-
const since = state.pushed.get(track.trackNumber) ?? 0;
|
|
396
|
-
const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
|
|
397
|
-
if (newCues.length === 0) {
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
|
|
401
|
-
state.pushed.set(track.trackNumber, highest);
|
|
402
|
-
const codecId = held.track?.codecId ?? track.codecId;
|
|
403
|
-
const cues = TextSubtitleTrack.finalizeCues(newCues, codecId);
|
|
404
|
-
fresh.push({
|
|
405
|
-
// ffmpeg's own numbering, which is the only one the browser knows.
|
|
406
|
-
trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
407
|
-
cues,
|
|
408
|
-
language: held.track?.language ?? "",
|
|
409
|
-
// What the CUES say the language is, re-read on every push over every cue
|
|
410
|
-
// held so far rather than over this batch. A track whose container states
|
|
411
|
-
// no language is unreadable at the start of a session — a handful of cues
|
|
412
|
-
// is not a sample of a language, and the detector refuses to answer on one
|
|
413
|
-
// — so the answer has to be re-taken as the film downloads, and the label
|
|
414
|
-
// moved when it arrives. Costs about 6 ms per push, measured; pushes
|
|
415
|
-
// arrive about once a second per file being read.
|
|
416
|
-
detectedLanguage: detectLanguage(
|
|
417
|
-
TextSubtitleTrack.finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
|
|
418
|
-
),
|
|
419
|
-
// Where the browser should resume from if it has to ask again — after a
|
|
420
|
-
// reconnect, which loses the subscription these pushes ride on.
|
|
421
|
-
cursor: highest,
|
|
422
|
-
// What this batch is ABOUT, in film time, so a log can be read against
|
|
423
|
-
// the position being played.
|
|
424
|
-
spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
|
|
425
|
-
spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
|
|
426
|
-
walkedClusters: held.coveredClusters ?? 0,
|
|
427
|
-
indexedClusters: held.indexedClusters ?? 0
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
return fresh;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* The text subtitle tracks of a file, for the menu the viewer sees.
|
|
435
|
-
*
|
|
436
|
-
* @param {object} torrent
|
|
437
|
-
* @param {number} fileIndex
|
|
438
|
-
* @param {string} sourceKey
|
|
439
|
-
* @returns {Promise<object[]>}
|
|
440
|
-
*/
|
|
441
|
-
export async function subtitleTracksOf(source) {
|
|
442
|
-
const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
|
|
443
|
-
return (plan?.tracks ?? []).map((track, order) => ({
|
|
444
|
-
trackNumber: track.trackNumber,
|
|
445
|
-
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
446
|
-
codecId: track.codecId,
|
|
447
|
-
language: track.language,
|
|
448
|
-
name: track.name,
|
|
449
|
-
isDefault: track.isDefault,
|
|
450
|
-
indexedClusters: track.clusterPositions.length
|
|
451
|
-
}));
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* What the container itself says about its subtitle tracks, in its own order
|
|
456
|
-
* and including the picture-based ones.
|
|
457
|
-
*
|
|
458
|
-
* Separate from `subtitleTracksOf`, which lists only what can be turned into
|
|
459
|
-
* WebVTT and is indexed by position in the subtitle API. This one exists to be
|
|
460
|
-
* lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
|
|
461
|
-
* stream, so leaving the picture ones out would shift it.
|
|
462
|
-
*
|
|
463
|
-
* @param {object} torrent
|
|
464
|
-
* @param {number} fileIndex
|
|
465
|
-
* @param {string} sourceKey
|
|
466
|
-
* @returns {Promise<object[]>}
|
|
467
|
-
*/
|
|
468
|
-
export async function declaredSubtitleTracksOf(source) {
|
|
469
|
-
const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
|
|
470
|
-
return plan?.declared ?? [];
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Forget a file's cues — the torrent is gone, and holding them would keep the
|
|
475
|
-
* text of a film nobody is watching.
|
|
476
|
-
*
|
|
477
|
-
* @param {string} sourceKey
|
|
478
|
-
* @param {number} [fileIndex]
|
|
479
|
-
* @returns {void}
|
|
480
|
-
*/
|
|
481
|
-
export function forgetSubtitles(sourceKey, fileIndex) {
|
|
482
|
-
if (fileIndex === undefined) {
|
|
483
|
-
for (const key of [...byFile.keys()]) {
|
|
484
|
-
if (key.startsWith(`${sourceKey}:`)) {
|
|
485
|
-
forgetOne(key);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
forgetOne(`${sourceKey}:${fileIndex}`);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Drop one file's state, but not while a walk of it is still running: the
|
|
495
|
-
* record of which clusters have been read lives in that state, and a walk left
|
|
496
|
-
* writing into a discarded copy while a new one starts beside it is the one
|
|
497
|
-
* path that defeats the serialization above.
|
|
498
|
-
*
|
|
499
|
-
* @param {string} key
|
|
500
|
-
* @returns {void}
|
|
501
|
-
*/
|
|
502
|
-
function forgetOne(key) {
|
|
503
|
-
const state = byFile.get(key);
|
|
504
|
-
if (!state) {
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
// Held, so that a walk started before this call is not left orphaned; the
|
|
508
|
-
// entry is dropped the moment the queue empties, and nothing is handed this
|
|
509
|
-
// state in the meantime.
|
|
510
|
-
state.forgotten = true;
|
|
511
|
-
void state.chain.then(() => {
|
|
512
|
-
if (byFile.get(key) === state) {
|
|
513
|
-
byFile.delete(key);
|
|
514
|
-
}
|
|
515
|
-
}, () => {
|
|
516
|
-
if (byFile.get(key) === state) {
|
|
517
|
-
byFile.delete(key);
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Reading the subtitle cues a file already holds, and the plan of its
|
|
3
|
+
* subtitle tracks.
|
|
4
|
+
*
|
|
5
|
+
* It walks ONLY what is downloaded: switching subtitles on must never pull
|
|
6
|
+
* bytes the viewer is not waiting for. The head and the Cues table are the one
|
|
7
|
+
* exception and are fetched — they are kilobytes, they are needed before
|
|
8
|
+
* anything can be offered, and the codec probe has already pulled the head for
|
|
9
|
+
* every file that plays.
|
|
10
|
+
*
|
|
11
|
+
* **The torrent is not here, and that is the point.** This layer is handed a
|
|
12
|
+
* `HeldFile`: a name, a length, which ranges are downloaded whole, and how to
|
|
13
|
+
* read one of them without fetching. Piece length, file offsets and the
|
|
14
|
+
* bitfield are the torrent's words and stay in the torrent's thread
|
|
15
|
+
* (`torrent/worker/held-bytes.js`). Until 2026-09-15 this whole file lived
|
|
16
|
+
* there, which put a container parse — the media layer's work — in the thread
|
|
17
|
+
* that owns the swarm, and made every answer something to carry back across a
|
|
18
|
+
* channel.
|
|
19
|
+
*
|
|
20
|
+
* What is genuinely this file's own, and travels with it: the found-order
|
|
21
|
+
* cursor a browser follows, the per-file state, and one walk of a file at a
|
|
22
|
+
* time.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
26
|
+
import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
|
|
27
|
+
import { detectLanguage } from "./tracks/language-detect.js";
|
|
28
|
+
import { logger } from "../../utils/logger.js";
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One file of one torrent, as much of it as this layer is allowed to know.
|
|
33
|
+
*
|
|
34
|
+
* Four plain values and two functions: what it is called, how long it is, which
|
|
35
|
+
* of it is downloaded whole, and how to read a range of that without asking the
|
|
36
|
+
* swarm for anything. No torrent, no bitfield, no piece length — those are the
|
|
37
|
+
* torrent's words, and turning them into these is its job.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {object} HeldFile
|
|
40
|
+
* @property {string} sourceKey
|
|
41
|
+
* @property {number} fileIndex
|
|
42
|
+
* @property {string} name
|
|
43
|
+
* @property {number} length
|
|
44
|
+
* @property {() => Promise<Array<[number, number]>>} heldRanges - Ascending,
|
|
45
|
+
* non-overlapping, inclusive offsets within the file.
|
|
46
|
+
* @property {(start: number, end: number) => Promise<Buffer | null>} readHeld -
|
|
47
|
+
* Null where those bytes are not there, which is never fetched.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a range falls entirely inside one of the held runs.
|
|
52
|
+
*
|
|
53
|
+
* @param {Array<[number, number]> | null} ranges
|
|
54
|
+
* @param {number} start
|
|
55
|
+
* @param {number} end - Inclusive.
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function rangeHolds(ranges, start, end) {
|
|
59
|
+
if (!Array.isArray(ranges) || !(end >= start)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
for (const [from, to] of ranges) {
|
|
63
|
+
if (start >= from && end <= to) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
|
|
71
|
+
const byFile = new Map();
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A container over one file of a torrent, told how to read it.
|
|
77
|
+
*
|
|
78
|
+
* The container is given two readers and a predicate and never the torrent:
|
|
79
|
+
* whether a byte range is already downloaded, how to read one without asking
|
|
80
|
+
* the swarm, and how to read one that may need fetching. That is the whole of
|
|
81
|
+
* what this layer knows and the container does not.
|
|
82
|
+
*
|
|
83
|
+
* WHICH container is decided from the bytes, by the factory's sniff — the
|
|
84
|
+
* header is what the muxer wrote, and a file name is what somebody typed. Only
|
|
85
|
+
* where the head is not downloaded, and so cannot be sniffed without asking the
|
|
86
|
+
* swarm, does the name answer instead. One choice either way: the caller never
|
|
87
|
+
* decides a second time from a track's shape, which is two decisions from
|
|
88
|
+
* different evidence that have to agree.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} state - This file's state; the container is kept on it.
|
|
91
|
+
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
92
|
+
* @returns {Promise<import("./container/Container.js").Container | null>}
|
|
93
|
+
*/
|
|
94
|
+
async function containerOver(state, source) {
|
|
95
|
+
if (state.container !== null) {
|
|
96
|
+
return state.container;
|
|
97
|
+
}
|
|
98
|
+
const last = Number(source.length) - 1;
|
|
99
|
+
const held = async (start, end) => source.readHeld(start, Math.min(end, last));
|
|
100
|
+
const params = {
|
|
101
|
+
readRange: held,
|
|
102
|
+
readHeld: held,
|
|
103
|
+
// Answered from the list of ranges this file has downloaded WHOLE, taken
|
|
104
|
+
// once per pass rather than asked per cluster: a pass asks about every
|
|
105
|
+
// cluster of the file, and the torrent that knows is on the other thread.
|
|
106
|
+
isHeld: (start, end) => rangeHolds(state.held, start, Math.min(end, last)),
|
|
107
|
+
fileSize: source.length,
|
|
108
|
+
label: String(source.name ?? "")
|
|
109
|
+
};
|
|
110
|
+
const sniffed = await ContainerFactory.create(params);
|
|
111
|
+
if (sniffed) {
|
|
112
|
+
state.container = sniffed;
|
|
113
|
+
return state.container;
|
|
114
|
+
}
|
|
115
|
+
const ByName = ContainerFactory.byName(source.name);
|
|
116
|
+
state.container = ByName ? new ByName(params) : null;
|
|
117
|
+
return state.container;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The subtitle tracks of a file, read once and kept.
|
|
122
|
+
*
|
|
123
|
+
* The head and the Cues table are two short reads, and they ARE fetched if
|
|
124
|
+
* missing — they are kilobytes, they are needed before anything can be offered,
|
|
125
|
+
* and the codec probe has already pulled the head for every file that plays.
|
|
126
|
+
*
|
|
127
|
+
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
128
|
+
* @param {string} key - `sourceKey:fileIndex`.
|
|
129
|
+
* @returns {Promise<object | null>}
|
|
130
|
+
*/
|
|
131
|
+
async function planFor(source, key) {
|
|
132
|
+
const state = stateFor(key);
|
|
133
|
+
if (state.plan !== null) {
|
|
134
|
+
return state.plan;
|
|
135
|
+
}
|
|
136
|
+
// The head and the Cues table are two reads that DO wait on the swarm, so two
|
|
137
|
+
// callers arriving together would both make them. One promise, awaited by
|
|
138
|
+
// whoever asks while it is in flight.
|
|
139
|
+
if (!state.planPromise) {
|
|
140
|
+
state.planPromise = readPlan(source, state).finally(() => {
|
|
141
|
+
state.planPromise = null;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return state.planPromise;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The state kept for one file, created on first use.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} key - `sourceKey:fileIndex`.
|
|
151
|
+
* @returns {object}
|
|
152
|
+
*/
|
|
153
|
+
function stateFor(key) {
|
|
154
|
+
let state = byFile.get(key);
|
|
155
|
+
// A state that has been forgotten is not handed out again, even in the moment
|
|
156
|
+
// between the call and the walk that was still running finishing.
|
|
157
|
+
if (state?.forgotten === true) {
|
|
158
|
+
state = undefined;
|
|
159
|
+
}
|
|
160
|
+
if (!state) {
|
|
161
|
+
state = {
|
|
162
|
+
plan: null,
|
|
163
|
+
planPromise: null,
|
|
164
|
+
forgotten: false,
|
|
165
|
+
// One walk of a file at a time — see `serialize`.
|
|
166
|
+
chain: Promise.resolve(),
|
|
167
|
+
// The container over this file, built once. It caches what it has parsed
|
|
168
|
+
// — a `moov` box is tens of megabytes off a torrent — so building a fresh
|
|
169
|
+
// one per call would throw that away on every request.
|
|
170
|
+
container: null,
|
|
171
|
+
// Which byte ranges of this file are downloaded WHOLE, as of the start of
|
|
172
|
+
// the pass now running. Taken once and read many times: a pass asks about
|
|
173
|
+
// every cluster of the file, and what knows the answer is on the other
|
|
174
|
+
// thread.
|
|
175
|
+
held: null,
|
|
176
|
+
harvested: new Map(),
|
|
177
|
+
cues: new Map(),
|
|
178
|
+
seq: new Map(),
|
|
179
|
+
walked: new Set(),
|
|
180
|
+
// The found-order cursor of the last cue PUSHED for each track, so a
|
|
181
|
+
// second warmup pass sends only what a first one did not — the same
|
|
182
|
+
// found-order idea `?since=` uses for a browser's own pull.
|
|
183
|
+
pushed: new Map()
|
|
184
|
+
};
|
|
185
|
+
byFile.set(key, state);
|
|
186
|
+
}
|
|
187
|
+
return state;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run `work` after every walk of this file already started, and before any
|
|
192
|
+
* started after it.
|
|
193
|
+
*
|
|
194
|
+
* Both entry points here — a browser's own pull and the warmup that runs ahead
|
|
195
|
+
* of it — mark a cluster as walked only AFTER reading and parsing it, which is
|
|
196
|
+
* two suspension points later. Until 2.56.0 nothing stopped a second call
|
|
197
|
+
* arriving in between: `warmActiveFiles` runs on every verified piece AND on a
|
|
198
|
+
* 3 s timer, so on a fast download the same cluster was read and parsed several
|
|
199
|
+
* times over and the same line could be pushed twice under different `seq`
|
|
200
|
+
* numbers. Each of those reads is a WebTorrent file stream, which selects and
|
|
201
|
+
* deselects its pieces, so the repetition reached the piece picker as well.
|
|
202
|
+
*
|
|
203
|
+
* @template T
|
|
204
|
+
* @param {object} state
|
|
205
|
+
* @param {() => Promise<T>} work
|
|
206
|
+
* @returns {Promise<T>}
|
|
207
|
+
*/
|
|
208
|
+
function serialize(state, work) {
|
|
209
|
+
const run = state.chain.then(work, work);
|
|
210
|
+
// The queue must survive a failed walk, so what is chained is the settled
|
|
211
|
+
// form; the caller still sees the rejection.
|
|
212
|
+
state.chain = run.then(() => undefined, () => undefined);
|
|
213
|
+
return run;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read one file's subtitle plan — the tracks it declares and where the clusters
|
|
218
|
+
* holding them are. Called once per file; see `planFor`.
|
|
219
|
+
*
|
|
220
|
+
* @param {object} torrent
|
|
221
|
+
* @param {object} state
|
|
222
|
+
* @returns {Promise<object>}
|
|
223
|
+
*/
|
|
224
|
+
async function readPlan(source, state) {
|
|
225
|
+
// `declared` is what the container itself says about its subtitle tracks, in
|
|
226
|
+
// its own order. Empty means the container said nothing — which is a real
|
|
227
|
+
// answer and not a missing one: nothing is then shown unasked. An MP4 has no
|
|
228
|
+
// element that means "show this subtitle track by default", so it declares
|
|
229
|
+
// nothing however many tracks it carries.
|
|
230
|
+
const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
231
|
+
if (!source || !(Number(source.length) > 0)) {
|
|
232
|
+
state.plan = empty;
|
|
233
|
+
return state.plan;
|
|
234
|
+
}
|
|
235
|
+
state.held = await source.heldRanges();
|
|
236
|
+
const container = await containerOver(state, source);
|
|
237
|
+
if (!container) {
|
|
238
|
+
state.plan = empty;
|
|
239
|
+
return state.plan;
|
|
240
|
+
}
|
|
241
|
+
const plan = await container.readSubtitlePlan();
|
|
242
|
+
state.plan = plan ?? empty;
|
|
243
|
+
if (state.plan.tracks.length > 0) {
|
|
244
|
+
logger.info(
|
|
245
|
+
`subtitles: "${String(source.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
|
|
246
|
+
`of ${state.plan.declared.length} declared — ` +
|
|
247
|
+
state.plan.tracks
|
|
248
|
+
// `s:N` is the number the browser names (ffmpeg's own), and it differs
|
|
249
|
+
// from the file's track number whenever a picture track sits among them.
|
|
250
|
+
.map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
|
|
251
|
+
`${track.name ? `/${track.name}` : ""}(${track.clusterPositions.length} indexed)`)
|
|
252
|
+
.join(" ")
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return state.plan;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The order a cue was FOUND in, which is the only cursor a browser can follow.
|
|
260
|
+
*
|
|
261
|
+
* A cue's TIME cannot serve as one. Cues are harvested out of whichever
|
|
262
|
+
* clusters happen to be downloaded, and those are not contiguous, so the set
|
|
263
|
+
* grows in the middle as well as at the end. A browser that remembered "the
|
|
264
|
+
* latest time I hold" and asked for everything past it would never be sent the
|
|
265
|
+
* cues that turn up BEHIND that mark afterwards — which is exactly the stretch
|
|
266
|
+
* it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
|
|
267
|
+
* carried cues out to 1176 s, and from then on every cue between the two was
|
|
268
|
+
* filtered away for the rest of the session, with 59 of 276 clusters read.
|
|
269
|
+
*
|
|
270
|
+
* Found-order is monotonic by construction, so `?since=<n>` is exact however
|
|
271
|
+
* the file arrives.
|
|
272
|
+
*
|
|
273
|
+
* @param {{ seq: Map<number, number> }} state
|
|
274
|
+
* @param {number} trackNumber
|
|
275
|
+
* @returns {number}
|
|
276
|
+
*/
|
|
277
|
+
function nextSeq(state, trackNumber) {
|
|
278
|
+
const next = (state.seq.get(trackNumber) ?? 0) + 1;
|
|
279
|
+
state.seq.set(trackNumber, next);
|
|
280
|
+
return next;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Every cue of one track that can be read from what is already downloaded.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} torrent
|
|
287
|
+
* @param {number} fileIndex
|
|
288
|
+
* @param {string} sourceKey
|
|
289
|
+
* @param {number} trackNumber
|
|
290
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
291
|
+
*/
|
|
292
|
+
export async function cuesHeldFor(source, trackNumber) {
|
|
293
|
+
const key = `${source.sourceKey}:${source.fileIndex}`;
|
|
294
|
+
// A source that cannot say which of itself is downloaded makes every range
|
|
295
|
+
// read as "not there", so the walk reads nothing and returns an empty list —
|
|
296
|
+
// which is also what a file with no cues yet returns, and that is how this
|
|
297
|
+
// went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
|
|
298
|
+
// the browser served `WEBVTT` and nothing else). It is not repairable here,
|
|
299
|
+
// so it is said rather than swallowed.
|
|
300
|
+
if (typeof source.heldRanges !== "function" || typeof source.readHeld !== "function") {
|
|
301
|
+
logger.warn(
|
|
302
|
+
`subtitles: asked for cues of "${String(source.name ?? key).slice(0, 40)}" ` +
|
|
303
|
+
"on a source that cannot say which of it is downloaded — no cluster can be read, " +
|
|
304
|
+
"and the answer would be an empty document indistinguishable from a file with no cues"
|
|
305
|
+
);
|
|
306
|
+
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
307
|
+
}
|
|
308
|
+
const plan = await planFor(source, key);
|
|
309
|
+
const state = stateFor(key);
|
|
310
|
+
const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
|
|
311
|
+
if (!track) {
|
|
312
|
+
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
313
|
+
}
|
|
314
|
+
return serialize(state, () => walkFor(source, state, plan, track, trackNumber));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
|
|
319
|
+
* one file to one walk at a time.
|
|
320
|
+
*
|
|
321
|
+
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
322
|
+
* @param {object} state
|
|
323
|
+
* @param {object} plan
|
|
324
|
+
* @param {object} track
|
|
325
|
+
* @param {number} trackNumber
|
|
326
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
327
|
+
*/
|
|
328
|
+
async function walkFor(source, state, plan, track, trackNumber) {
|
|
329
|
+
// Taken once per pass: pieces keep arriving, and what may be read now is a
|
|
330
|
+
// different list from what could be read when the plan was built.
|
|
331
|
+
state.held = await source.heldRanges();
|
|
332
|
+
const container = await containerOver(state, source);
|
|
333
|
+
if (!container) {
|
|
334
|
+
return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// One question, whichever container this is. Matroska walks the clusters its
|
|
338
|
+
// Cues table names and fills every track from one walk; an MP4 reads the
|
|
339
|
+
// samples its own table states for this track. Nothing here chooses between
|
|
340
|
+
// them, which is the point: the container was chosen once, from the bytes.
|
|
341
|
+
const { found, covered, indexed } = await container.readHeldCues(plan, track, state);
|
|
342
|
+
|
|
343
|
+
for (const [number, cues] of found) {
|
|
344
|
+
let into = state.cues.get(number);
|
|
345
|
+
if (!into) {
|
|
346
|
+
into = [];
|
|
347
|
+
state.cues.set(number, into);
|
|
348
|
+
}
|
|
349
|
+
for (const cue of cues) {
|
|
350
|
+
into.push({ ...cue, seq: nextSeq(state, number) });
|
|
351
|
+
}
|
|
352
|
+
into.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
cues: state.cues.get(trackNumber) ?? [],
|
|
357
|
+
coveredClusters: covered,
|
|
358
|
+
indexedClusters: indexed,
|
|
359
|
+
track
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Walk whatever clusters have newly arrived, for every text track a file
|
|
365
|
+
* carries, and report what is new since the last call — so the cues can be
|
|
366
|
+
* PUSHED to a browser rather than left for it to come back and ask.
|
|
367
|
+
*
|
|
368
|
+
* `cuesHeldFor` already skips positions it has walked before (`state.walked`),
|
|
369
|
+
* so calling this on a timer or on every verified piece is cheap once a file
|
|
370
|
+
* is caught up: the only cost is deciding there is nothing new to read. It is
|
|
371
|
+
* `getSubtitleCues` run ahead of being asked, on the same state that call
|
|
372
|
+
* itself would build — nothing is duplicated, and a file nobody has opened
|
|
373
|
+
* costs nothing beyond this.
|
|
374
|
+
*
|
|
375
|
+
* @param {object} torrent
|
|
376
|
+
* @param {number} fileIndex
|
|
377
|
+
* @param {string} sourceKey
|
|
378
|
+
* @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
|
|
379
|
+
* One entry per track that gained at least one cue since the last call.
|
|
380
|
+
* `trackIndex` is `declaredIndex` — the track's position among ALL the file's
|
|
381
|
+
* subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
|
|
382
|
+
* knows. NOT the container's own track number, and not the position among the
|
|
383
|
+
* readable tracks either: counting those alone puts every text track after a
|
|
384
|
+
* picture-based one in the wrong place.
|
|
385
|
+
*/
|
|
386
|
+
export async function warmSubtitleCues(source) {
|
|
387
|
+
const key = `${source.sourceKey}:${source.fileIndex}`;
|
|
388
|
+
const plan = await planFor(source, key);
|
|
389
|
+
const state = stateFor(key);
|
|
390
|
+
const fresh = [];
|
|
391
|
+
const tracks = plan?.tracks ?? [];
|
|
392
|
+
for (let order = 0; order < tracks.length; order += 1) {
|
|
393
|
+
const track = tracks[order];
|
|
394
|
+
const held = await cuesHeldFor(source, track.trackNumber);
|
|
395
|
+
const since = state.pushed.get(track.trackNumber) ?? 0;
|
|
396
|
+
const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
|
|
397
|
+
if (newCues.length === 0) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
|
|
401
|
+
state.pushed.set(track.trackNumber, highest);
|
|
402
|
+
const codecId = held.track?.codecId ?? track.codecId;
|
|
403
|
+
const cues = TextSubtitleTrack.finalizeCues(newCues, codecId);
|
|
404
|
+
fresh.push({
|
|
405
|
+
// ffmpeg's own numbering, which is the only one the browser knows.
|
|
406
|
+
trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
407
|
+
cues,
|
|
408
|
+
language: held.track?.language ?? "",
|
|
409
|
+
// What the CUES say the language is, re-read on every push over every cue
|
|
410
|
+
// held so far rather than over this batch. A track whose container states
|
|
411
|
+
// no language is unreadable at the start of a session — a handful of cues
|
|
412
|
+
// is not a sample of a language, and the detector refuses to answer on one
|
|
413
|
+
// — so the answer has to be re-taken as the film downloads, and the label
|
|
414
|
+
// moved when it arrives. Costs about 6 ms per push, measured; pushes
|
|
415
|
+
// arrive about once a second per file being read.
|
|
416
|
+
detectedLanguage: detectLanguage(
|
|
417
|
+
TextSubtitleTrack.finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
|
|
418
|
+
),
|
|
419
|
+
// Where the browser should resume from if it has to ask again — after a
|
|
420
|
+
// reconnect, which loses the subscription these pushes ride on.
|
|
421
|
+
cursor: highest,
|
|
422
|
+
// What this batch is ABOUT, in film time, so a log can be read against
|
|
423
|
+
// the position being played.
|
|
424
|
+
spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
|
|
425
|
+
spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
|
|
426
|
+
walkedClusters: held.coveredClusters ?? 0,
|
|
427
|
+
indexedClusters: held.indexedClusters ?? 0
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return fresh;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The text subtitle tracks of a file, for the menu the viewer sees.
|
|
435
|
+
*
|
|
436
|
+
* @param {object} torrent
|
|
437
|
+
* @param {number} fileIndex
|
|
438
|
+
* @param {string} sourceKey
|
|
439
|
+
* @returns {Promise<object[]>}
|
|
440
|
+
*/
|
|
441
|
+
export async function subtitleTracksOf(source) {
|
|
442
|
+
const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
|
|
443
|
+
return (plan?.tracks ?? []).map((track, order) => ({
|
|
444
|
+
trackNumber: track.trackNumber,
|
|
445
|
+
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
446
|
+
codecId: track.codecId,
|
|
447
|
+
language: track.language,
|
|
448
|
+
name: track.name,
|
|
449
|
+
isDefault: track.isDefault,
|
|
450
|
+
indexedClusters: track.clusterPositions.length
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* What the container itself says about its subtitle tracks, in its own order
|
|
456
|
+
* and including the picture-based ones.
|
|
457
|
+
*
|
|
458
|
+
* Separate from `subtitleTracksOf`, which lists only what can be turned into
|
|
459
|
+
* WebVTT and is indexed by position in the subtitle API. This one exists to be
|
|
460
|
+
* lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
|
|
461
|
+
* stream, so leaving the picture ones out would shift it.
|
|
462
|
+
*
|
|
463
|
+
* @param {object} torrent
|
|
464
|
+
* @param {number} fileIndex
|
|
465
|
+
* @param {string} sourceKey
|
|
466
|
+
* @returns {Promise<object[]>}
|
|
467
|
+
*/
|
|
468
|
+
export async function declaredSubtitleTracksOf(source) {
|
|
469
|
+
const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
|
|
470
|
+
return plan?.declared ?? [];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Forget a file's cues — the torrent is gone, and holding them would keep the
|
|
475
|
+
* text of a film nobody is watching.
|
|
476
|
+
*
|
|
477
|
+
* @param {string} sourceKey
|
|
478
|
+
* @param {number} [fileIndex]
|
|
479
|
+
* @returns {void}
|
|
480
|
+
*/
|
|
481
|
+
export function forgetSubtitles(sourceKey, fileIndex) {
|
|
482
|
+
if (fileIndex === undefined) {
|
|
483
|
+
for (const key of [...byFile.keys()]) {
|
|
484
|
+
if (key.startsWith(`${sourceKey}:`)) {
|
|
485
|
+
forgetOne(key);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
forgetOne(`${sourceKey}:${fileIndex}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Drop one file's state, but not while a walk of it is still running: the
|
|
495
|
+
* record of which clusters have been read lives in that state, and a walk left
|
|
496
|
+
* writing into a discarded copy while a new one starts beside it is the one
|
|
497
|
+
* path that defeats the serialization above.
|
|
498
|
+
*
|
|
499
|
+
* @param {string} key
|
|
500
|
+
* @returns {void}
|
|
501
|
+
*/
|
|
502
|
+
function forgetOne(key) {
|
|
503
|
+
const state = byFile.get(key);
|
|
504
|
+
if (!state) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// Held, so that a walk started before this call is not left orphaned; the
|
|
508
|
+
// entry is dropped the moment the queue empties, and nothing is handed this
|
|
509
|
+
// state in the meantime.
|
|
510
|
+
state.forgotten = true;
|
|
511
|
+
void state.chain.then(() => {
|
|
512
|
+
if (byFile.get(key) === state) {
|
|
513
|
+
byFile.delete(key);
|
|
514
|
+
}
|
|
515
|
+
}, () => {
|
|
516
|
+
if (byFile.get(key) === state) {
|
|
517
|
+
byFile.delete(key);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|