@torrent-tv/proxy 2.87.4 → 2.87.6
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/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 +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/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/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/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/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 +2 -2
- 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/pool-adapter.js +1 -1
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- 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 +71 -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 -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 -0
- 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/{services/torrent → utils}/torrent-source-key.js +38 -38
|
@@ -1,230 +1,230 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Serve a subtitle as WebVTT, with the detected language reported in the
|
|
3
|
-
* `X-Subtitle-Language` / `X-Subtitle-Language-Name` response headers. Two
|
|
4
|
-
* modes:
|
|
5
|
-
*
|
|
6
|
-
* - Embedded track: ?sourceKey&fileIndex=<video>&trackIndex=<sub stream N>
|
|
7
|
-
* ffmpeg extracts the text subtitle stream (`-map 0:s:N -f webvtt`),
|
|
8
|
-
* streamed as it is produced.
|
|
9
|
-
* - External file: ?sourceKey&fileIndex=<subtitle file> (no trackIndex)
|
|
10
|
-
* the subtitle FILE is read, decoded (UTF-8/Windows-1251), and converted
|
|
11
|
-
* (.srt/.ass/.ssa → WebVTT) here on the proxy.
|
|
12
|
-
*
|
|
13
|
-
* The proxy owns subtitle conversion + language detection so no model or
|
|
14
|
-
* converter ships to the browser and detection sees the full text.
|
|
15
|
-
*
|
|
16
|
-
* @param {import("fastify").FastifyRequest} req
|
|
17
|
-
* @param {import("fastify").FastifyReply} reply
|
|
18
|
-
* @param {{
|
|
19
|
-
* sourceRegistry: ReturnType<import("../../../store/source-registry.js").createSourceRegistry>,
|
|
20
|
-
* torrentPool: import("../../../services/torrent/torrent-pool.js").TorrentPool,
|
|
21
|
-
* ffmpegBin: string,
|
|
22
|
-
* localBaseUrl: string
|
|
23
|
-
* }} deps
|
|
24
|
-
* @returns {Promise<void>}
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { deriveSourceKey } from "../../../
|
|
28
|
-
import { spawn } from "node:child_process";
|
|
29
|
-
import { TextSubtitleTrack } from "../../../services/media/tracks/TextSubtitleTrack.js";
|
|
30
|
-
import { SubtitleController } from "../../../services/server/controllers/SubtitleController.js";
|
|
31
|
-
import { logger } from "../../../utils/logger.js";
|
|
32
|
-
|
|
33
|
-
// Safety cap: no embedded extraction may outlive this.
|
|
34
|
-
const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
35
|
-
|
|
36
|
-
/** Set the detected-language response headers (no-op when detection failed). */
|
|
37
|
-
function setLanguageHeaders(reply, lang) {
|
|
38
|
-
if (lang && typeof lang.code === "string") {
|
|
39
|
-
reply.raw.setHeader("X-Subtitle-Language", lang.code);
|
|
40
|
-
if (typeof lang.name === "string") {
|
|
41
|
-
reply.raw.setHeader("X-Subtitle-Language-Name", encodeURIComponent(lang.name));
|
|
42
|
-
}
|
|
43
|
-
// These are custom headers on a cross-origin fetch — expose them.
|
|
44
|
-
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Subtitle-Language, X-Subtitle-Language-Name");
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers, subtitles }) {
|
|
49
|
-
const query = req.query ?? {};
|
|
50
|
-
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
|
|
51
|
-
const fileIndex = Number(query.fileIndex);
|
|
52
|
-
const hasTrackIndex = query.trackIndex !== undefined && query.trackIndex !== "";
|
|
53
|
-
const trackIndex = Number(query.trackIndex);
|
|
54
|
-
|
|
55
|
-
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
56
|
-
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Turning subtitles on is a fact about the VIEWER, and this is the only place
|
|
60
|
-
// that knows both the person and the file, so this is where it is recorded.
|
|
61
|
-
//
|
|
62
|
-
// It used to be recorded in the transport, against the CHANNEL, which the
|
|
63
|
-
// transport could only do by sniffing this path out of the request and
|
|
64
|
-
// deriving the torrent key itself. Two costs followed: a reconnect lost the
|
|
65
|
-
// subscription for the rest of the session, and the layer that carries bytes
|
|
66
|
-
// held application routing and torrent identity.
|
|
67
|
-
//
|
|
68
|
-
// The browser sends its registry key; the push side speaks the pool key, so
|
|
69
|
-
// the two are reconciled here — the same reconciliation the transport did.
|
|
70
|
-
const consumerId = typeof query.consumerId === "string" ? query.consumerId.trim() : "";
|
|
71
|
-
if (consumerId && hasTrackIndex && viewers) {
|
|
72
|
-
const record = sourceRegistry?.get(sourceKey);
|
|
73
|
-
if (record) {
|
|
74
|
-
try {
|
|
75
|
-
viewers.wantsCues(consumerId, await deriveSourceKey(record.sourceType, record.source), fileIndex);
|
|
76
|
-
} catch {
|
|
77
|
-
// A subscription that cannot be resolved costs this viewer pushed cues;
|
|
78
|
-
// it must not cost them the subtitles they asked for in this request.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Interface layer delegates to SubtitleController (orchestrator + domain),
|
|
84
|
-
// which owns external-file vs embedded-track branching and the cluster walk.
|
|
85
|
-
const controller = new SubtitleController({ sourceRegistry, torrentPool, subtitles });
|
|
86
|
-
const since = Number.parseInt(String(req.query?.since ?? ""), 10);
|
|
87
|
-
const after = Number.parseFloat(String(req.query?.after ?? ""));
|
|
88
|
-
const result = await controller.getSubtitle({
|
|
89
|
-
sourceKey,
|
|
90
|
-
fileIndex,
|
|
91
|
-
trackIndex: hasTrackIndex ? trackIndex : undefined,
|
|
92
|
-
since: Number.isInteger(since) ? since : null,
|
|
93
|
-
after: Number.isFinite(after) ? after : null
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
if (result.error) {
|
|
97
|
-
return reply.code(result.status ?? 400).send({ error: result.error });
|
|
98
|
-
}
|
|
99
|
-
if (result.vtt !== undefined) {
|
|
100
|
-
if (result.vtt !== null) {
|
|
101
|
-
// External file or cluster-held cues — controller already detected language.
|
|
102
|
-
const lang = result.language ?? null;
|
|
103
|
-
if (lang) setLanguageHeaders(reply, lang);
|
|
104
|
-
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
105
|
-
reply.header("cache-control", "no-store");
|
|
106
|
-
if (hasTrackIndex) {
|
|
107
|
-
reply.header("access-control-allow-origin", "*");
|
|
108
|
-
if (result.headers) {
|
|
109
|
-
for (const [k, v] of Object.entries(result.headers)) reply.header(k, String(v));
|
|
110
|
-
reply.raw.setHeader(
|
|
111
|
-
"Access-Control-Expose-Headers",
|
|
112
|
-
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return reply.send(result.vtt);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
// If controller returned pending, fall through to ffmpeg extraction below.
|
|
120
|
-
|
|
121
|
-
// ---- Embedded track — controller had no held cues, try cluster path directly for headers compatibility
|
|
122
|
-
// The controller's getSubtitle already attempted the cluster walk; reaching here means it returned pending.
|
|
123
|
-
if (!hasTrackIndex) {
|
|
124
|
-
// Should have been handled above — pending for external is unsupported format
|
|
125
|
-
return reply.code(422).send({ error: "Unsupported subtitle format" });
|
|
126
|
-
}
|
|
127
|
-
if (!Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
128
|
-
return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
132
|
-
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
133
|
-
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
134
|
-
|
|
135
|
-
const key = `${sourceKey}:${fileIndex}:${trackIndex}`;
|
|
136
|
-
const known = extractions.get(key);
|
|
137
|
-
if (known?.state === "done") {
|
|
138
|
-
setLanguageHeaders(reply, known.language);
|
|
139
|
-
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
140
|
-
reply.header("cache-control", "no-store");
|
|
141
|
-
reply.header("access-control-allow-origin", "*");
|
|
142
|
-
return reply.send(known.body);
|
|
143
|
-
}
|
|
144
|
-
if (known?.state === "failed") {
|
|
145
|
-
return reply.code(422).send({ error: known.error });
|
|
146
|
-
}
|
|
147
|
-
if (known?.state !== "running") {
|
|
148
|
-
startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex });
|
|
149
|
-
}
|
|
150
|
-
// Being prepared. The connection is NOT held: extracting an embedded track
|
|
151
|
-
// makes ffmpeg read the whole file, because subtitles are interleaved through
|
|
152
|
-
// it, and that means downloading the film for the sake of a few kilobytes of
|
|
153
|
-
// text. Measured 2026-08-19: track 0 of one release produced 3040 bytes over
|
|
154
|
-
// **752 seconds**, with the data channel idle the whole time — the browser
|
|
155
|
-
// gave up at its own sixty-second limit, and every retry started the same
|
|
156
|
-
// twelve-minute scan again. So the work runs once in the background and the
|
|
157
|
-
// caller is told to come back.
|
|
158
|
-
return reply.code(202).send({ pending: true });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Extractions by `sourceKey:fileIndex:trackIndex`, so the scan happens once per
|
|
163
|
-
* track however many times it is asked for.
|
|
164
|
-
*
|
|
165
|
-
* @type {Map<string, { state: "running" | "done" | "failed", body?: Buffer, language?: { code: string, name: string } | null, error?: string }>}
|
|
166
|
-
*/
|
|
167
|
-
const extractions = new Map();
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Run one extraction to completion in the background, keeping the result.
|
|
171
|
-
*
|
|
172
|
-
* @param {{ key: string, ffmpegBin: string, localBaseUrl: string, sourceKey: string, fileIndex: number, trackIndex: number }} params
|
|
173
|
-
* @returns {void}
|
|
174
|
-
*/
|
|
175
|
-
function startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex }) {
|
|
176
|
-
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
177
|
-
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
178
|
-
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
179
|
-
|
|
180
|
-
extractions.set(key, { state: "running" });
|
|
181
|
-
const startedAt = Date.now();
|
|
182
|
-
const ffmpeg = spawn(
|
|
183
|
-
ffmpegBin,
|
|
184
|
-
["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
|
|
185
|
-
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
let stderr = "";
|
|
189
|
-
ffmpeg.stderr.on("data", (chunk) => {
|
|
190
|
-
if (stderr.length < 4096) {
|
|
191
|
-
stderr += String(chunk);
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
/** @type {Buffer[]} */
|
|
196
|
-
const chunks = [];
|
|
197
|
-
ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
198
|
-
|
|
199
|
-
const killTimer = setTimeout(() => {
|
|
200
|
-
if (!ffmpeg.killed) {
|
|
201
|
-
ffmpeg.kill("SIGKILL");
|
|
202
|
-
}
|
|
203
|
-
}, EXTRACTION_TIMEOUT_MS);
|
|
204
|
-
killTimer.unref?.();
|
|
205
|
-
|
|
206
|
-
const settle = () => {
|
|
207
|
-
clearTimeout(killTimer);
|
|
208
|
-
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
209
|
-
const body = Buffer.concat(chunks);
|
|
210
|
-
if (body.length === 0) {
|
|
211
|
-
extractions.set(key, {
|
|
212
|
-
state: "failed",
|
|
213
|
-
error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}`
|
|
214
|
-
});
|
|
215
|
-
logger.warn(`subtitles ${key}: nothing produced after ${seconds}s`);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
// The whole document, decoded as one string, and only its cue text.
|
|
219
|
-
// Detecting on the first 4096 BYTES was wrong twice over: a byte cut lands
|
|
220
|
-
// mid-character on any non-Latin track, and most of those bytes are
|
|
221
|
-
// timestamps rather than words. This runs once per track in the background,
|
|
222
|
-
// so reading all of it costs nothing anybody waits for.
|
|
223
|
-
extractions.set(key, { state: "done", body, language: TextSubtitleTrack.detectLanguageFromVtt(body.toString("utf8")) });
|
|
224
|
-
logger.info(`subtitles ${key}: ${body.length} bytes in ${seconds}s`);
|
|
225
|
-
};
|
|
226
|
-
ffmpeg.once("close", settle);
|
|
227
|
-
ffmpeg.once("error", settle);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Serve a subtitle as WebVTT, with the detected language reported in the
|
|
3
|
+
* `X-Subtitle-Language` / `X-Subtitle-Language-Name` response headers. Two
|
|
4
|
+
* modes:
|
|
5
|
+
*
|
|
6
|
+
* - Embedded track: ?sourceKey&fileIndex=<video>&trackIndex=<sub stream N>
|
|
7
|
+
* ffmpeg extracts the text subtitle stream (`-map 0:s:N -f webvtt`),
|
|
8
|
+
* streamed as it is produced.
|
|
9
|
+
* - External file: ?sourceKey&fileIndex=<subtitle file> (no trackIndex)
|
|
10
|
+
* the subtitle FILE is read, decoded (UTF-8/Windows-1251), and converted
|
|
11
|
+
* (.srt/.ass/.ssa → WebVTT) here on the proxy.
|
|
12
|
+
*
|
|
13
|
+
* The proxy owns subtitle conversion + language detection so no model or
|
|
14
|
+
* converter ships to the browser and detection sees the full text.
|
|
15
|
+
*
|
|
16
|
+
* @param {import("fastify").FastifyRequest} req
|
|
17
|
+
* @param {import("fastify").FastifyReply} reply
|
|
18
|
+
* @param {{
|
|
19
|
+
* sourceRegistry: ReturnType<import("../../../store/source-registry.js").createSourceRegistry>,
|
|
20
|
+
* torrentPool: import("../../../services/torrent/torrent-pool.js").TorrentPool,
|
|
21
|
+
* ffmpegBin: string,
|
|
22
|
+
* localBaseUrl: string
|
|
23
|
+
* }} deps
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { deriveSourceKey } from "../../../utils/torrent-source-key.js";
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { TextSubtitleTrack } from "../../../services/media/tracks/TextSubtitleTrack.js";
|
|
30
|
+
import { SubtitleController } from "../../../services/server/controllers/SubtitleController.js";
|
|
31
|
+
import { logger } from "../../../utils/logger.js";
|
|
32
|
+
|
|
33
|
+
// Safety cap: no embedded extraction may outlive this.
|
|
34
|
+
const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/** Set the detected-language response headers (no-op when detection failed). */
|
|
37
|
+
function setLanguageHeaders(reply, lang) {
|
|
38
|
+
if (lang && typeof lang.code === "string") {
|
|
39
|
+
reply.raw.setHeader("X-Subtitle-Language", lang.code);
|
|
40
|
+
if (typeof lang.name === "string") {
|
|
41
|
+
reply.raw.setHeader("X-Subtitle-Language-Name", encodeURIComponent(lang.name));
|
|
42
|
+
}
|
|
43
|
+
// These are custom headers on a cross-origin fetch — expose them.
|
|
44
|
+
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Subtitle-Language, X-Subtitle-Language-Name");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers, subtitles }) {
|
|
49
|
+
const query = req.query ?? {};
|
|
50
|
+
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
|
|
51
|
+
const fileIndex = Number(query.fileIndex);
|
|
52
|
+
const hasTrackIndex = query.trackIndex !== undefined && query.trackIndex !== "";
|
|
53
|
+
const trackIndex = Number(query.trackIndex);
|
|
54
|
+
|
|
55
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
56
|
+
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Turning subtitles on is a fact about the VIEWER, and this is the only place
|
|
60
|
+
// that knows both the person and the file, so this is where it is recorded.
|
|
61
|
+
//
|
|
62
|
+
// It used to be recorded in the transport, against the CHANNEL, which the
|
|
63
|
+
// transport could only do by sniffing this path out of the request and
|
|
64
|
+
// deriving the torrent key itself. Two costs followed: a reconnect lost the
|
|
65
|
+
// subscription for the rest of the session, and the layer that carries bytes
|
|
66
|
+
// held application routing and torrent identity.
|
|
67
|
+
//
|
|
68
|
+
// The browser sends its registry key; the push side speaks the pool key, so
|
|
69
|
+
// the two are reconciled here — the same reconciliation the transport did.
|
|
70
|
+
const consumerId = typeof query.consumerId === "string" ? query.consumerId.trim() : "";
|
|
71
|
+
if (consumerId && hasTrackIndex && viewers) {
|
|
72
|
+
const record = sourceRegistry?.get(sourceKey);
|
|
73
|
+
if (record) {
|
|
74
|
+
try {
|
|
75
|
+
viewers.wantsCues(consumerId, await deriveSourceKey(record.sourceType, record.source), fileIndex);
|
|
76
|
+
} catch {
|
|
77
|
+
// A subscription that cannot be resolved costs this viewer pushed cues;
|
|
78
|
+
// it must not cost them the subtitles they asked for in this request.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Interface layer delegates to SubtitleController (orchestrator + domain),
|
|
84
|
+
// which owns external-file vs embedded-track branching and the cluster walk.
|
|
85
|
+
const controller = new SubtitleController({ sourceRegistry, torrentPool, subtitles });
|
|
86
|
+
const since = Number.parseInt(String(req.query?.since ?? ""), 10);
|
|
87
|
+
const after = Number.parseFloat(String(req.query?.after ?? ""));
|
|
88
|
+
const result = await controller.getSubtitle({
|
|
89
|
+
sourceKey,
|
|
90
|
+
fileIndex,
|
|
91
|
+
trackIndex: hasTrackIndex ? trackIndex : undefined,
|
|
92
|
+
since: Number.isInteger(since) ? since : null,
|
|
93
|
+
after: Number.isFinite(after) ? after : null
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (result.error) {
|
|
97
|
+
return reply.code(result.status ?? 400).send({ error: result.error });
|
|
98
|
+
}
|
|
99
|
+
if (result.vtt !== undefined) {
|
|
100
|
+
if (result.vtt !== null) {
|
|
101
|
+
// External file or cluster-held cues — controller already detected language.
|
|
102
|
+
const lang = result.language ?? null;
|
|
103
|
+
if (lang) setLanguageHeaders(reply, lang);
|
|
104
|
+
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
105
|
+
reply.header("cache-control", "no-store");
|
|
106
|
+
if (hasTrackIndex) {
|
|
107
|
+
reply.header("access-control-allow-origin", "*");
|
|
108
|
+
if (result.headers) {
|
|
109
|
+
for (const [k, v] of Object.entries(result.headers)) reply.header(k, String(v));
|
|
110
|
+
reply.raw.setHeader(
|
|
111
|
+
"Access-Control-Expose-Headers",
|
|
112
|
+
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return reply.send(result.vtt);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// If controller returned pending, fall through to ffmpeg extraction below.
|
|
120
|
+
|
|
121
|
+
// ---- Embedded track — controller had no held cues, try cluster path directly for headers compatibility
|
|
122
|
+
// The controller's getSubtitle already attempted the cluster walk; reaching here means it returned pending.
|
|
123
|
+
if (!hasTrackIndex) {
|
|
124
|
+
// Should have been handled above — pending for external is unsupported format
|
|
125
|
+
return reply.code(422).send({ error: "Unsupported subtitle format" });
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
128
|
+
return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
132
|
+
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
133
|
+
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
134
|
+
|
|
135
|
+
const key = `${sourceKey}:${fileIndex}:${trackIndex}`;
|
|
136
|
+
const known = extractions.get(key);
|
|
137
|
+
if (known?.state === "done") {
|
|
138
|
+
setLanguageHeaders(reply, known.language);
|
|
139
|
+
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
140
|
+
reply.header("cache-control", "no-store");
|
|
141
|
+
reply.header("access-control-allow-origin", "*");
|
|
142
|
+
return reply.send(known.body);
|
|
143
|
+
}
|
|
144
|
+
if (known?.state === "failed") {
|
|
145
|
+
return reply.code(422).send({ error: known.error });
|
|
146
|
+
}
|
|
147
|
+
if (known?.state !== "running") {
|
|
148
|
+
startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex });
|
|
149
|
+
}
|
|
150
|
+
// Being prepared. The connection is NOT held: extracting an embedded track
|
|
151
|
+
// makes ffmpeg read the whole file, because subtitles are interleaved through
|
|
152
|
+
// it, and that means downloading the film for the sake of a few kilobytes of
|
|
153
|
+
// text. Measured 2026-08-19: track 0 of one release produced 3040 bytes over
|
|
154
|
+
// **752 seconds**, with the data channel idle the whole time — the browser
|
|
155
|
+
// gave up at its own sixty-second limit, and every retry started the same
|
|
156
|
+
// twelve-minute scan again. So the work runs once in the background and the
|
|
157
|
+
// caller is told to come back.
|
|
158
|
+
return reply.code(202).send({ pending: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Extractions by `sourceKey:fileIndex:trackIndex`, so the scan happens once per
|
|
163
|
+
* track however many times it is asked for.
|
|
164
|
+
*
|
|
165
|
+
* @type {Map<string, { state: "running" | "done" | "failed", body?: Buffer, language?: { code: string, name: string } | null, error?: string }>}
|
|
166
|
+
*/
|
|
167
|
+
const extractions = new Map();
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run one extraction to completion in the background, keeping the result.
|
|
171
|
+
*
|
|
172
|
+
* @param {{ key: string, ffmpegBin: string, localBaseUrl: string, sourceKey: string, fileIndex: number, trackIndex: number }} params
|
|
173
|
+
* @returns {void}
|
|
174
|
+
*/
|
|
175
|
+
function startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, trackIndex }) {
|
|
176
|
+
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
177
|
+
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
178
|
+
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
179
|
+
|
|
180
|
+
extractions.set(key, { state: "running" });
|
|
181
|
+
const startedAt = Date.now();
|
|
182
|
+
const ffmpeg = spawn(
|
|
183
|
+
ffmpegBin,
|
|
184
|
+
["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
|
|
185
|
+
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
let stderr = "";
|
|
189
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
190
|
+
if (stderr.length < 4096) {
|
|
191
|
+
stderr += String(chunk);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/** @type {Buffer[]} */
|
|
196
|
+
const chunks = [];
|
|
197
|
+
ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
198
|
+
|
|
199
|
+
const killTimer = setTimeout(() => {
|
|
200
|
+
if (!ffmpeg.killed) {
|
|
201
|
+
ffmpeg.kill("SIGKILL");
|
|
202
|
+
}
|
|
203
|
+
}, EXTRACTION_TIMEOUT_MS);
|
|
204
|
+
killTimer.unref?.();
|
|
205
|
+
|
|
206
|
+
const settle = () => {
|
|
207
|
+
clearTimeout(killTimer);
|
|
208
|
+
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
209
|
+
const body = Buffer.concat(chunks);
|
|
210
|
+
if (body.length === 0) {
|
|
211
|
+
extractions.set(key, {
|
|
212
|
+
state: "failed",
|
|
213
|
+
error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}`
|
|
214
|
+
});
|
|
215
|
+
logger.warn(`subtitles ${key}: nothing produced after ${seconds}s`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// The whole document, decoded as one string, and only its cue text.
|
|
219
|
+
// Detecting on the first 4096 BYTES was wrong twice over: a byte cut lands
|
|
220
|
+
// mid-character on any non-Latin track, and most of those bytes are
|
|
221
|
+
// timestamps rather than words. This runs once per track in the background,
|
|
222
|
+
// so reading all of it costs nothing anybody waits for.
|
|
223
|
+
extractions.set(key, { state: "done", body, language: TextSubtitleTrack.detectLanguageFromVtt(body.toString("utf8")) });
|
|
224
|
+
logger.info(`subtitles ${key}: ${body.length} bytes in ${seconds}s`);
|
|
225
|
+
};
|
|
226
|
+
ffmpeg.once("close", settle);
|
|
227
|
+
ffmpeg.once("error", settle);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Accept the player's report that a delivered fragment sits far from the edge
|
|
3
|
-
* of what it has buffered.
|
|
4
|
-
*
|
|
5
|
-
* This is the earliest statement that exists of a stream coming apart, and
|
|
6
|
-
* until now it lived only in the browser's console. Measured 2026-08-21 on
|
|
7
|
-
* `JUFD665.mp4`: four of these across half a minute, each naming a gap of
|
|
8
|
-
* ~33.5 s, while the buffer stood still at 4571.1 s — and then hls.js gave up
|
|
9
|
-
* and jumped the viewer 16.8 s forward. The proxy is the only side that can say
|
|
10
|
-
* what the gap MEANS, because only it knows which boundary the segment it
|
|
11
|
-
* produced actually holds, and whether that is the one its number claims.
|
|
12
|
-
*
|
|
13
|
-
* POST /api/transcode-sessions/:sessionId/fragment-far
|
|
14
|
-
* Body: { sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }
|
|
15
|
-
*
|
|
16
|
-
* `track` is hls.js's own name for the stream the fragment belongs to ("main"
|
|
17
|
-
* or "audio"). It matters because picture and sound are produced by two
|
|
18
|
-
* different sessions positioned by two different runs — which is how they come
|
|
19
|
-
* apart in the first place — so a report answered against the wrong one is a
|
|
20
|
-
* confident statement about a stream nobody asked about.
|
|
21
|
-
*
|
|
22
|
-
* Diagnostic only — it changes nothing about the encode. Invalid body → 400,
|
|
23
|
-
* unknown session → 404, ok → 204.
|
|
24
|
-
*
|
|
25
|
-
* @param {import("fastify").FastifyRequest} req
|
|
26
|
-
* @param {import("fastify").FastifyReply} reply
|
|
27
|
-
* @param {{ serving: object }} deps
|
|
28
|
-
* @returns {Promise<void>}
|
|
29
|
-
*/
|
|
30
|
-
export async function handleApiTranscodeSessionFragmentFarPost(req, reply, { serving }) {
|
|
31
|
-
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
32
|
-
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
33
|
-
const sn = Number(body.sn);
|
|
34
|
-
const track = typeof body.track === "string" ? body.track : "";
|
|
35
|
-
const fragStartSec = Number(body.fragStartSec);
|
|
36
|
-
const bufferEndSec = Number(body.bufferEndSec);
|
|
37
|
-
const currentTimeSec = Number(body.currentTimeSec);
|
|
38
|
-
if (
|
|
39
|
-
!sessionId ||
|
|
40
|
-
!Number.isInteger(sn) ||
|
|
41
|
-
sn < 0 ||
|
|
42
|
-
!Number.isFinite(fragStartSec) ||
|
|
43
|
-
!Number.isFinite(bufferEndSec) ||
|
|
44
|
-
!Number.isFinite(currentTimeSec)
|
|
45
|
-
) {
|
|
46
|
-
return reply.code(400).send({ error: "sn (integer >=0), fragStartSec, bufferEndSec and currentTimeSec are required." });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const recorded = serving.recordFragmentFar(sessionId, {
|
|
50
|
-
sn,
|
|
51
|
-
track,
|
|
52
|
-
fragStartSec,
|
|
53
|
-
bufferEndSec,
|
|
54
|
-
currentTimeSec
|
|
55
|
-
});
|
|
56
|
-
if (!recorded) {
|
|
57
|
-
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
58
|
-
}
|
|
59
|
-
return reply.code(204).send();
|
|
60
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Accept the player's report that a delivered fragment sits far from the edge
|
|
3
|
+
* of what it has buffered.
|
|
4
|
+
*
|
|
5
|
+
* This is the earliest statement that exists of a stream coming apart, and
|
|
6
|
+
* until now it lived only in the browser's console. Measured 2026-08-21 on
|
|
7
|
+
* `JUFD665.mp4`: four of these across half a minute, each naming a gap of
|
|
8
|
+
* ~33.5 s, while the buffer stood still at 4571.1 s — and then hls.js gave up
|
|
9
|
+
* and jumped the viewer 16.8 s forward. The proxy is the only side that can say
|
|
10
|
+
* what the gap MEANS, because only it knows which boundary the segment it
|
|
11
|
+
* produced actually holds, and whether that is the one its number claims.
|
|
12
|
+
*
|
|
13
|
+
* POST /api/transcode-sessions/:sessionId/fragment-far
|
|
14
|
+
* Body: { sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }
|
|
15
|
+
*
|
|
16
|
+
* `track` is hls.js's own name for the stream the fragment belongs to ("main"
|
|
17
|
+
* or "audio"). It matters because picture and sound are produced by two
|
|
18
|
+
* different sessions positioned by two different runs — which is how they come
|
|
19
|
+
* apart in the first place — so a report answered against the wrong one is a
|
|
20
|
+
* confident statement about a stream nobody asked about.
|
|
21
|
+
*
|
|
22
|
+
* Diagnostic only — it changes nothing about the encode. Invalid body → 400,
|
|
23
|
+
* unknown session → 404, ok → 204.
|
|
24
|
+
*
|
|
25
|
+
* @param {import("fastify").FastifyRequest} req
|
|
26
|
+
* @param {import("fastify").FastifyReply} reply
|
|
27
|
+
* @param {{ serving: object }} deps
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
export async function handleApiTranscodeSessionFragmentFarPost(req, reply, { serving }) {
|
|
31
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
32
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
33
|
+
const sn = Number(body.sn);
|
|
34
|
+
const track = typeof body.track === "string" ? body.track : "";
|
|
35
|
+
const fragStartSec = Number(body.fragStartSec);
|
|
36
|
+
const bufferEndSec = Number(body.bufferEndSec);
|
|
37
|
+
const currentTimeSec = Number(body.currentTimeSec);
|
|
38
|
+
if (
|
|
39
|
+
!sessionId ||
|
|
40
|
+
!Number.isInteger(sn) ||
|
|
41
|
+
sn < 0 ||
|
|
42
|
+
!Number.isFinite(fragStartSec) ||
|
|
43
|
+
!Number.isFinite(bufferEndSec) ||
|
|
44
|
+
!Number.isFinite(currentTimeSec)
|
|
45
|
+
) {
|
|
46
|
+
return reply.code(400).send({ error: "sn (integer >=0), fragStartSec, bufferEndSec and currentTimeSec are required." });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const recorded = serving.recordFragmentFar(sessionId, {
|
|
50
|
+
sn,
|
|
51
|
+
track,
|
|
52
|
+
fragStartSec,
|
|
53
|
+
bufferEndSec,
|
|
54
|
+
currentTimeSec
|
|
55
|
+
});
|
|
56
|
+
if (!recorded) {
|
|
57
|
+
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
58
|
+
}
|
|
59
|
+
return reply.code(204).send();
|
|
60
|
+
}
|