@torrent-tv/proxy 2.87.7 → 2.87.8
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 +4 -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/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/data-channel-handler.js +21 -8
- 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/data-channel-viewer-lifecycle.test.js +92 -0
- 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 -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,142 +1,142 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file How well the priority map is being served, measured where somebody waits.
|
|
3
|
-
*
|
|
4
|
-
* The map says what matters most. Nothing said whether what mattered most was
|
|
5
|
-
* actually delivered first — so "is the prioritisation any good" had no answer
|
|
6
|
-
* of any kind, for either of the two things that read the map.
|
|
7
|
-
*
|
|
8
|
-
* The measure is the same for both, and it is a wait recorded against the rank
|
|
9
|
-
* the map gave the thing waited for AT THE MOMENT it was asked for. Read that
|
|
10
|
-
* way it says which part is wrong rather than whether the whole scheme is:
|
|
11
|
-
*
|
|
12
|
-
* - long waits at the TOP rank mean the urgent zone is not being served first,
|
|
13
|
-
* which is a fault in whoever acts on the map;
|
|
14
|
-
* - long waits further down with none at the top mean the zones are the wrong
|
|
15
|
-
* width — the urgent one too narrow, so the viewer reaches material that was
|
|
16
|
-
* only ever ranked "soon";
|
|
17
|
-
* - a rank with no waits at all is not a good sign or a bad one, it is silence,
|
|
18
|
-
* and it is reported as such rather than as a zero.
|
|
19
|
-
*
|
|
20
|
-
* There is nothing in here about encoders, torrents, sessions or viewers: it is
|
|
21
|
-
* given a rank and a number of milliseconds.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** How many waits to keep per rank. A median wants a sample, not a history. */
|
|
25
|
-
const HISTORY = 200;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Ranks are collapsed into bands before they are counted.
|
|
29
|
-
*
|
|
30
|
-
* The map's ranks are as many as the film needs — 100 down to 1 on a long file
|
|
31
|
-
* — and a table with a hundred rows says nothing a reader can hold. What is
|
|
32
|
-
* being asked is coarse: was the thing waited for what the viewer needs NOW,
|
|
33
|
-
* what they reach shortly, or the rest of the film. So the top rank is its own
|
|
34
|
-
* band, the next few are the second, and everything below is the third.
|
|
35
|
-
*
|
|
36
|
-
* @param {number} rank
|
|
37
|
-
* @param {number} topRank - The highest rank the map currently states.
|
|
38
|
-
* @returns {"now" | "soon" | "later"}
|
|
39
|
-
*/
|
|
40
|
-
export function bandOf(rank, topRank) {
|
|
41
|
-
if (!Number.isFinite(rank) || rank <= 0) {
|
|
42
|
-
return "later";
|
|
43
|
-
}
|
|
44
|
-
const top = Number.isFinite(topRank) && topRank > 0 ? topRank : rank;
|
|
45
|
-
if (rank >= top) {
|
|
46
|
-
return "now";
|
|
47
|
-
}
|
|
48
|
-
// Within a tenth of the top: what a viewer reaches while watching what they
|
|
49
|
-
// hold. A tenth is the map's own shape — its zones widen geometrically — and
|
|
50
|
-
// not a threshold chosen for this table.
|
|
51
|
-
return rank >= top - Math.max(1, Math.round(top / 10)) ? "soon" : "later";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export class WaitLedger {
|
|
55
|
-
/** @type {Map<string, Map<string, number[]>>} */
|
|
56
|
-
#waits = new Map();
|
|
57
|
-
|
|
58
|
-
/** @type {Map<string, Map<string, number>>} */
|
|
59
|
-
#counts = new Map();
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Somebody waited this long for something the map ranked this highly.
|
|
63
|
-
*
|
|
64
|
-
* @param {string} key - What the waits belong to: an output, or a file.
|
|
65
|
-
* @param {number} waitedMs
|
|
66
|
-
* @param {number} rank - The map's rank for the thing waited for.
|
|
67
|
-
* @param {number} topRank - The highest rank the map states, so the rank can
|
|
68
|
-
* be read as a position rather than as an absolute number.
|
|
69
|
-
*/
|
|
70
|
-
note(key, waitedMs, rank, topRank) {
|
|
71
|
-
if (!key || !Number.isFinite(waitedMs) || waitedMs < 0) {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const band = bandOf(rank, topRank);
|
|
75
|
-
let byBand = this.#waits.get(key);
|
|
76
|
-
if (!byBand) {
|
|
77
|
-
byBand = new Map();
|
|
78
|
-
this.#waits.set(key, byBand);
|
|
79
|
-
}
|
|
80
|
-
const held = byBand.get(band) ?? [];
|
|
81
|
-
held.push(waitedMs);
|
|
82
|
-
while (held.length > HISTORY) {
|
|
83
|
-
held.shift();
|
|
84
|
-
}
|
|
85
|
-
byBand.set(band, held);
|
|
86
|
-
|
|
87
|
-
let counts = this.#counts.get(key);
|
|
88
|
-
if (!counts) {
|
|
89
|
-
counts = new Map();
|
|
90
|
-
this.#counts.set(key, counts);
|
|
91
|
-
}
|
|
92
|
-
counts.set(band, (counts.get(band) ?? 0) + 1);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* What the waits say, in one line, or null while nothing has waited.
|
|
97
|
-
*
|
|
98
|
-
* The count is the whole run and the median and worst are the recent sample,
|
|
99
|
-
* because those answer different questions: how often, and how badly.
|
|
100
|
-
*
|
|
101
|
-
* @param {string} key
|
|
102
|
-
* @returns {string | null}
|
|
103
|
-
*/
|
|
104
|
-
describe(key) {
|
|
105
|
-
const byBand = this.#waits.get(key);
|
|
106
|
-
if (!byBand || byBand.size === 0) {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
const counts = this.#counts.get(key) ?? new Map();
|
|
110
|
-
const parts = [];
|
|
111
|
-
for (const band of ["now", "soon", "later"]) {
|
|
112
|
-
const held = byBand.get(band);
|
|
113
|
-
if (!held || held.length === 0) {
|
|
114
|
-
// Said out loud, because an absent band and a band that never waited
|
|
115
|
-
// are the same silence and neither is a zero.
|
|
116
|
-
parts.push(`${band} none`);
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
const sorted = [...held].sort((a, b) => a - b);
|
|
120
|
-
const median = sorted[Math.floor(sorted.length / 2)];
|
|
121
|
-
parts.push(
|
|
122
|
-
`${band} ${counts.get(band) ?? held.length} wait(s) median ${Math.round(median)}ms ` +
|
|
123
|
-
`worst ${Math.round(sorted[sorted.length - 1])}ms`
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
return parts.join(", ");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* @param {string} key
|
|
131
|
-
*/
|
|
132
|
-
forget(key) {
|
|
133
|
-
this.#waits.delete(key);
|
|
134
|
-
this.#counts.delete(key);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* One ledger for the process, because the question is asked in two layers and
|
|
140
|
-
* the answer is comparable only if the scale is the same.
|
|
141
|
-
*/
|
|
142
|
-
export const waits = new WaitLedger();
|
|
1
|
+
/**
|
|
2
|
+
* @file How well the priority map is being served, measured where somebody waits.
|
|
3
|
+
*
|
|
4
|
+
* The map says what matters most. Nothing said whether what mattered most was
|
|
5
|
+
* actually delivered first — so "is the prioritisation any good" had no answer
|
|
6
|
+
* of any kind, for either of the two things that read the map.
|
|
7
|
+
*
|
|
8
|
+
* The measure is the same for both, and it is a wait recorded against the rank
|
|
9
|
+
* the map gave the thing waited for AT THE MOMENT it was asked for. Read that
|
|
10
|
+
* way it says which part is wrong rather than whether the whole scheme is:
|
|
11
|
+
*
|
|
12
|
+
* - long waits at the TOP rank mean the urgent zone is not being served first,
|
|
13
|
+
* which is a fault in whoever acts on the map;
|
|
14
|
+
* - long waits further down with none at the top mean the zones are the wrong
|
|
15
|
+
* width — the urgent one too narrow, so the viewer reaches material that was
|
|
16
|
+
* only ever ranked "soon";
|
|
17
|
+
* - a rank with no waits at all is not a good sign or a bad one, it is silence,
|
|
18
|
+
* and it is reported as such rather than as a zero.
|
|
19
|
+
*
|
|
20
|
+
* There is nothing in here about encoders, torrents, sessions or viewers: it is
|
|
21
|
+
* given a rank and a number of milliseconds.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** How many waits to keep per rank. A median wants a sample, not a history. */
|
|
25
|
+
const HISTORY = 200;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ranks are collapsed into bands before they are counted.
|
|
29
|
+
*
|
|
30
|
+
* The map's ranks are as many as the film needs — 100 down to 1 on a long file
|
|
31
|
+
* — and a table with a hundred rows says nothing a reader can hold. What is
|
|
32
|
+
* being asked is coarse: was the thing waited for what the viewer needs NOW,
|
|
33
|
+
* what they reach shortly, or the rest of the film. So the top rank is its own
|
|
34
|
+
* band, the next few are the second, and everything below is the third.
|
|
35
|
+
*
|
|
36
|
+
* @param {number} rank
|
|
37
|
+
* @param {number} topRank - The highest rank the map currently states.
|
|
38
|
+
* @returns {"now" | "soon" | "later"}
|
|
39
|
+
*/
|
|
40
|
+
export function bandOf(rank, topRank) {
|
|
41
|
+
if (!Number.isFinite(rank) || rank <= 0) {
|
|
42
|
+
return "later";
|
|
43
|
+
}
|
|
44
|
+
const top = Number.isFinite(topRank) && topRank > 0 ? topRank : rank;
|
|
45
|
+
if (rank >= top) {
|
|
46
|
+
return "now";
|
|
47
|
+
}
|
|
48
|
+
// Within a tenth of the top: what a viewer reaches while watching what they
|
|
49
|
+
// hold. A tenth is the map's own shape — its zones widen geometrically — and
|
|
50
|
+
// not a threshold chosen for this table.
|
|
51
|
+
return rank >= top - Math.max(1, Math.round(top / 10)) ? "soon" : "later";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class WaitLedger {
|
|
55
|
+
/** @type {Map<string, Map<string, number[]>>} */
|
|
56
|
+
#waits = new Map();
|
|
57
|
+
|
|
58
|
+
/** @type {Map<string, Map<string, number>>} */
|
|
59
|
+
#counts = new Map();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Somebody waited this long for something the map ranked this highly.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} key - What the waits belong to: an output, or a file.
|
|
65
|
+
* @param {number} waitedMs
|
|
66
|
+
* @param {number} rank - The map's rank for the thing waited for.
|
|
67
|
+
* @param {number} topRank - The highest rank the map states, so the rank can
|
|
68
|
+
* be read as a position rather than as an absolute number.
|
|
69
|
+
*/
|
|
70
|
+
note(key, waitedMs, rank, topRank) {
|
|
71
|
+
if (!key || !Number.isFinite(waitedMs) || waitedMs < 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const band = bandOf(rank, topRank);
|
|
75
|
+
let byBand = this.#waits.get(key);
|
|
76
|
+
if (!byBand) {
|
|
77
|
+
byBand = new Map();
|
|
78
|
+
this.#waits.set(key, byBand);
|
|
79
|
+
}
|
|
80
|
+
const held = byBand.get(band) ?? [];
|
|
81
|
+
held.push(waitedMs);
|
|
82
|
+
while (held.length > HISTORY) {
|
|
83
|
+
held.shift();
|
|
84
|
+
}
|
|
85
|
+
byBand.set(band, held);
|
|
86
|
+
|
|
87
|
+
let counts = this.#counts.get(key);
|
|
88
|
+
if (!counts) {
|
|
89
|
+
counts = new Map();
|
|
90
|
+
this.#counts.set(key, counts);
|
|
91
|
+
}
|
|
92
|
+
counts.set(band, (counts.get(band) ?? 0) + 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What the waits say, in one line, or null while nothing has waited.
|
|
97
|
+
*
|
|
98
|
+
* The count is the whole run and the median and worst are the recent sample,
|
|
99
|
+
* because those answer different questions: how often, and how badly.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} key
|
|
102
|
+
* @returns {string | null}
|
|
103
|
+
*/
|
|
104
|
+
describe(key) {
|
|
105
|
+
const byBand = this.#waits.get(key);
|
|
106
|
+
if (!byBand || byBand.size === 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const counts = this.#counts.get(key) ?? new Map();
|
|
110
|
+
const parts = [];
|
|
111
|
+
for (const band of ["now", "soon", "later"]) {
|
|
112
|
+
const held = byBand.get(band);
|
|
113
|
+
if (!held || held.length === 0) {
|
|
114
|
+
// Said out loud, because an absent band and a band that never waited
|
|
115
|
+
// are the same silence and neither is a zero.
|
|
116
|
+
parts.push(`${band} none`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const sorted = [...held].sort((a, b) => a - b);
|
|
120
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
121
|
+
parts.push(
|
|
122
|
+
`${band} ${counts.get(band) ?? held.length} wait(s) median ${Math.round(median)}ms ` +
|
|
123
|
+
`worst ${Math.round(sorted[sorted.length - 1])}ms`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return parts.join(", ");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} key
|
|
131
|
+
*/
|
|
132
|
+
forget(key) {
|
|
133
|
+
this.#waits.delete(key);
|
|
134
|
+
this.#counts.delete(key);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One ledger for the process, because the question is asked in two layers and
|
|
140
|
+
* the answer is comparable only if the scale is the same.
|
|
141
|
+
*/
|
|
142
|
+
export const waits = new WaitLedger();
|
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Subtitle controller — interface layer over SubtitleOrchestrator.
|
|
3
|
-
*
|
|
4
|
-
* Routes (HTTP or data-channel) call this, not the domain module directly.
|
|
5
|
-
* Handles external files vs embedded tracks branching, header setting, and
|
|
6
|
-
* cursor/covered-cluster bookkeeping. Domain work (cluster walk, conversion,
|
|
7
|
-
* language detection) stays in orchestrator/domain.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
-
|
|
12
|
-
function readFileFully(file, maxBytes) {
|
|
13
|
-
return new Promise((resolve, reject) => {
|
|
14
|
-
const stream = file.createReadStream();
|
|
15
|
-
const chunks = [];
|
|
16
|
-
let total = 0;
|
|
17
|
-
stream.on("data", (chunk) => {
|
|
18
|
-
total += chunk.length;
|
|
19
|
-
if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
|
|
20
|
-
chunks.push(chunk);
|
|
21
|
-
});
|
|
22
|
-
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
23
|
-
stream.on("error", reject);
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export class SubtitleController {
|
|
28
|
-
/**
|
|
29
|
-
* @param {object} deps
|
|
30
|
-
* @param {object} deps.sourceRegistry
|
|
31
|
-
* @param {object} deps.torrentPool
|
|
32
|
-
* @param {import("../../media/SubtitleOrchestrator.js").SubtitleOrchestrator} deps.subtitles -
|
|
33
|
-
* HANDED IN rather than built here. A controller translates a request for
|
|
34
|
-
* an orchestrator and reaches every layer below it THROUGH that one; putting
|
|
35
|
-
* the media layer and the torrent's cue reading together is composition, and
|
|
36
|
-
* composition belongs to the one place that already knows about both.
|
|
37
|
-
*/
|
|
38
|
-
constructor({ sourceRegistry, torrentPool, subtitles }) {
|
|
39
|
-
this.sourceRegistry = sourceRegistry;
|
|
40
|
-
this.torrentPool = torrentPool;
|
|
41
|
-
this.orchestrator = subtitles;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Serve external subtitle file or embedded track.
|
|
46
|
-
* Returns { vtt, language, headers } or { error, status }.
|
|
47
|
-
*/
|
|
48
|
-
async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
|
|
49
|
-
const rec = this.sourceRegistry.get(sourceKey);
|
|
50
|
-
if (!rec) return { error: "Source key was not found.", status: 404 };
|
|
51
|
-
const torrent = await this.torrentPool.getTorrent(rec.sourceType, rec.source);
|
|
52
|
-
const file = torrent.files[fileIndex];
|
|
53
|
-
if (!file) return { error: "File index was not found in torrent.", status: 404 };
|
|
54
|
-
|
|
55
|
-
const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
|
|
56
|
-
if (!hasTrack) {
|
|
57
|
-
const name = file.name ?? "";
|
|
58
|
-
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
59
|
-
try {
|
|
60
|
-
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
61
|
-
const converted = this.orchestrator.fileAsVtt(bytes, ext);
|
|
62
|
-
if (!converted) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
|
|
63
|
-
return { ...converted, headers: {} };
|
|
64
|
-
} catch (e) {
|
|
65
|
-
return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const idx = Number(trackIndex);
|
|
70
|
-
if (!Number.isInteger(idx) || idx < 0) return { error: "trackIndex must be a non-negative integer.", status: 400 };
|
|
71
|
-
|
|
72
|
-
// Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
|
|
73
|
-
const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
|
|
74
|
-
const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
|
|
75
|
-
// Also try domain's declaredIndex-agnostic lookup via getCues path — keep compat with existing subtitle-cues declaredIndex
|
|
76
|
-
let held = null;
|
|
77
|
-
try {
|
|
78
|
-
// Need trackNumber for domain call — find via declared workspace
|
|
79
|
-
const domainTracks = await this.orchestrator.getDeclaredTracks(torrent, fileIndex, sourceKey);
|
|
80
|
-
// If not found, fall back to direct cuesHeldFor via trackNumber from tracks list
|
|
81
|
-
const target = track ?? domainTracks.find((t) => t.declaredIndex === idx) ?? null;
|
|
82
|
-
const trackNumber = target?.trackNumber ?? track?.trackNumber;
|
|
83
|
-
if (trackNumber != null) {
|
|
84
|
-
held = await this.orchestrator.getCues(torrent, fileIndex, sourceKey, trackNumber);
|
|
85
|
-
}
|
|
86
|
-
} catch {}
|
|
87
|
-
if (held && Array.isArray(held.cues)) {
|
|
88
|
-
const cursor = held.cues.reduce((h, c) => Math.max(h, Number(c.seq) || 0), 0);
|
|
89
|
-
const fresh = Number.isInteger(since) ? held.cues.filter((c) => (Number(c.seq) || 0) > since)
|
|
90
|
-
: Number.isFinite(after) ? held.cues.filter((c) => c.startSeconds > after) : held.cues;
|
|
91
|
-
const codecId = held.track?.codecId ?? track?.codecId ?? "";
|
|
92
|
-
const { vtt, language } = this.orchestrator.cuesAsVtt(fresh, held.cues, codecId);
|
|
93
|
-
return {
|
|
94
|
-
vtt,
|
|
95
|
-
language,
|
|
96
|
-
headers: {
|
|
97
|
-
"X-Subtitle-Covered-Clusters": String(held.coveredClusters ?? 0),
|
|
98
|
-
"X-Subtitle-Indexed-Clusters": String(held.indexedClusters ?? 0),
|
|
99
|
-
"X-Subtitle-Cursor": String(cursor)
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
return { pending: true, status: 202 };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async warm(torrent, fileIndex, sourceKey) {
|
|
107
|
-
return this.orchestrator.warm(torrent, fileIndex, sourceKey);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Subtitle controller — interface layer over SubtitleOrchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Routes (HTTP or data-channel) call this, not the domain module directly.
|
|
5
|
+
* Handles external files vs embedded tracks branching, header setting, and
|
|
6
|
+
* cursor/covered-cluster bookkeeping. Domain work (cluster walk, conversion,
|
|
7
|
+
* language detection) stays in orchestrator/domain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function readFileFully(file, maxBytes) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const stream = file.createReadStream();
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let total = 0;
|
|
17
|
+
stream.on("data", (chunk) => {
|
|
18
|
+
total += chunk.length;
|
|
19
|
+
if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
|
|
20
|
+
chunks.push(chunk);
|
|
21
|
+
});
|
|
22
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
23
|
+
stream.on("error", reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SubtitleController {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} deps
|
|
30
|
+
* @param {object} deps.sourceRegistry
|
|
31
|
+
* @param {object} deps.torrentPool
|
|
32
|
+
* @param {import("../../media/SubtitleOrchestrator.js").SubtitleOrchestrator} deps.subtitles -
|
|
33
|
+
* HANDED IN rather than built here. A controller translates a request for
|
|
34
|
+
* an orchestrator and reaches every layer below it THROUGH that one; putting
|
|
35
|
+
* the media layer and the torrent's cue reading together is composition, and
|
|
36
|
+
* composition belongs to the one place that already knows about both.
|
|
37
|
+
*/
|
|
38
|
+
constructor({ sourceRegistry, torrentPool, subtitles }) {
|
|
39
|
+
this.sourceRegistry = sourceRegistry;
|
|
40
|
+
this.torrentPool = torrentPool;
|
|
41
|
+
this.orchestrator = subtitles;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Serve external subtitle file or embedded track.
|
|
46
|
+
* Returns { vtt, language, headers } or { error, status }.
|
|
47
|
+
*/
|
|
48
|
+
async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
|
|
49
|
+
const rec = this.sourceRegistry.get(sourceKey);
|
|
50
|
+
if (!rec) return { error: "Source key was not found.", status: 404 };
|
|
51
|
+
const torrent = await this.torrentPool.getTorrent(rec.sourceType, rec.source);
|
|
52
|
+
const file = torrent.files[fileIndex];
|
|
53
|
+
if (!file) return { error: "File index was not found in torrent.", status: 404 };
|
|
54
|
+
|
|
55
|
+
const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
|
|
56
|
+
if (!hasTrack) {
|
|
57
|
+
const name = file.name ?? "";
|
|
58
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
59
|
+
try {
|
|
60
|
+
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
61
|
+
const converted = this.orchestrator.fileAsVtt(bytes, ext);
|
|
62
|
+
if (!converted) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
|
|
63
|
+
return { ...converted, headers: {} };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const idx = Number(trackIndex);
|
|
70
|
+
if (!Number.isInteger(idx) || idx < 0) return { error: "trackIndex must be a non-negative integer.", status: 400 };
|
|
71
|
+
|
|
72
|
+
// Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
|
|
73
|
+
const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
|
|
74
|
+
const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
|
|
75
|
+
// Also try domain's declaredIndex-agnostic lookup via getCues path — keep compat with existing subtitle-cues declaredIndex
|
|
76
|
+
let held = null;
|
|
77
|
+
try {
|
|
78
|
+
// Need trackNumber for domain call — find via declared workspace
|
|
79
|
+
const domainTracks = await this.orchestrator.getDeclaredTracks(torrent, fileIndex, sourceKey);
|
|
80
|
+
// If not found, fall back to direct cuesHeldFor via trackNumber from tracks list
|
|
81
|
+
const target = track ?? domainTracks.find((t) => t.declaredIndex === idx) ?? null;
|
|
82
|
+
const trackNumber = target?.trackNumber ?? track?.trackNumber;
|
|
83
|
+
if (trackNumber != null) {
|
|
84
|
+
held = await this.orchestrator.getCues(torrent, fileIndex, sourceKey, trackNumber);
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
if (held && Array.isArray(held.cues)) {
|
|
88
|
+
const cursor = held.cues.reduce((h, c) => Math.max(h, Number(c.seq) || 0), 0);
|
|
89
|
+
const fresh = Number.isInteger(since) ? held.cues.filter((c) => (Number(c.seq) || 0) > since)
|
|
90
|
+
: Number.isFinite(after) ? held.cues.filter((c) => c.startSeconds > after) : held.cues;
|
|
91
|
+
const codecId = held.track?.codecId ?? track?.codecId ?? "";
|
|
92
|
+
const { vtt, language } = this.orchestrator.cuesAsVtt(fresh, held.cues, codecId);
|
|
93
|
+
return {
|
|
94
|
+
vtt,
|
|
95
|
+
language,
|
|
96
|
+
headers: {
|
|
97
|
+
"X-Subtitle-Covered-Clusters": String(held.coveredClusters ?? 0),
|
|
98
|
+
"X-Subtitle-Indexed-Clusters": String(held.indexedClusters ?? 0),
|
|
99
|
+
"X-Subtitle-Cursor": String(cursor)
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { pending: true, status: 202 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async warm(torrent, fileIndex, sourceKey) {
|
|
107
|
+
return this.orchestrator.warm(torrent, fileIndex, sourceKey);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared HTTP responses used by more than one route.
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* The answer given when no output suits this viewer: nothing this proxy holds
|
|
6
|
-
* or could produce is admitted by their link. Machine-readable, because the
|
|
7
|
-
* page has to explain it to the viewer and offer what they can do; the
|
|
8
|
-
* figures are the ones the decision was made on.
|
|
9
|
-
*
|
|
10
|
-
* 409 and not a 5xx: nothing is wrong with the proxy, and repeating the same
|
|
11
|
-
* request against the same link gives the same answer.
|
|
12
|
-
*
|
|
13
|
-
* @param {import("fastify").FastifyReply} reply
|
|
14
|
-
* @param {{ reason: string, figures: object | null, wantedKey?: string }} details
|
|
15
|
-
* @returns {import("fastify").FastifyReply}
|
|
16
|
-
*/
|
|
17
|
-
export function replyOutputUnavailable(reply, details) {
|
|
18
|
-
return reply.code(409).send({
|
|
19
|
-
error: `No output suits this viewer: ${details?.reason ?? "unknown"}.`,
|
|
20
|
-
outcome: "output-unavailable",
|
|
21
|
-
reason: details?.reason ?? "",
|
|
22
|
-
figures: details?.figures ?? null
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The answer given when this machine cannot take the output asked for: nothing
|
|
28
|
-
* it has shown it can encode fits, or it has no place for one more encoder.
|
|
29
|
-
* The page answers it by asking the rest of the pool before anything plays.
|
|
30
|
-
*
|
|
31
|
-
* @param {import("fastify").FastifyReply} reply
|
|
32
|
-
* @param {{ reason: string, figures?: object | null }} details
|
|
33
|
-
* @returns {import("fastify").FastifyReply}
|
|
34
|
-
*/
|
|
35
|
-
export function replyNoCapacity(reply, details) {
|
|
36
|
-
return reply.code(409).send({
|
|
37
|
-
error: `This proxy cannot take this video now: ${details?.reason ?? "unknown"}.`,
|
|
38
|
-
outcome: "no-capacity",
|
|
39
|
-
reason: details?.reason ?? "",
|
|
40
|
-
figures: details?.figures ?? null
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The answer given when an address this viewer was already given cannot be
|
|
46
|
-
* given again without risking a piece under a header it may not match. The
|
|
47
|
-
* page answers it by starting a new viewing where the picture is — which is
|
|
48
|
-
* why it is not a "retry": the same request would be answered the same way.
|
|
49
|
-
*
|
|
50
|
-
* @param {import("fastify").FastifyReply} reply
|
|
51
|
-
* @param {{ reason: string }} details
|
|
52
|
-
* @returns {import("fastify").FastifyReply}
|
|
53
|
-
*/
|
|
54
|
-
export function replyAssignmentLost(reply, details) {
|
|
55
|
-
return reply.code(409).send({
|
|
56
|
-
error: `This part was answered by an output that has gone: ${details?.reason ?? "unknown"}.`,
|
|
57
|
-
outcome: "assignment-lost",
|
|
58
|
-
reason: details?.reason ?? ""
|
|
59
|
-
});
|
|
60
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP responses used by more than one route.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* The answer given when no output suits this viewer: nothing this proxy holds
|
|
6
|
+
* or could produce is admitted by their link. Machine-readable, because the
|
|
7
|
+
* page has to explain it to the viewer and offer what they can do; the
|
|
8
|
+
* figures are the ones the decision was made on.
|
|
9
|
+
*
|
|
10
|
+
* 409 and not a 5xx: nothing is wrong with the proxy, and repeating the same
|
|
11
|
+
* request against the same link gives the same answer.
|
|
12
|
+
*
|
|
13
|
+
* @param {import("fastify").FastifyReply} reply
|
|
14
|
+
* @param {{ reason: string, figures: object | null, wantedKey?: string }} details
|
|
15
|
+
* @returns {import("fastify").FastifyReply}
|
|
16
|
+
*/
|
|
17
|
+
export function replyOutputUnavailable(reply, details) {
|
|
18
|
+
return reply.code(409).send({
|
|
19
|
+
error: `No output suits this viewer: ${details?.reason ?? "unknown"}.`,
|
|
20
|
+
outcome: "output-unavailable",
|
|
21
|
+
reason: details?.reason ?? "",
|
|
22
|
+
figures: details?.figures ?? null
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The answer given when this machine cannot take the output asked for: nothing
|
|
28
|
+
* it has shown it can encode fits, or it has no place for one more encoder.
|
|
29
|
+
* The page answers it by asking the rest of the pool before anything plays.
|
|
30
|
+
*
|
|
31
|
+
* @param {import("fastify").FastifyReply} reply
|
|
32
|
+
* @param {{ reason: string, figures?: object | null }} details
|
|
33
|
+
* @returns {import("fastify").FastifyReply}
|
|
34
|
+
*/
|
|
35
|
+
export function replyNoCapacity(reply, details) {
|
|
36
|
+
return reply.code(409).send({
|
|
37
|
+
error: `This proxy cannot take this video now: ${details?.reason ?? "unknown"}.`,
|
|
38
|
+
outcome: "no-capacity",
|
|
39
|
+
reason: details?.reason ?? "",
|
|
40
|
+
figures: details?.figures ?? null
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The answer given when an address this viewer was already given cannot be
|
|
46
|
+
* given again without risking a piece under a header it may not match. The
|
|
47
|
+
* page answers it by starting a new viewing where the picture is — which is
|
|
48
|
+
* why it is not a "retry": the same request would be answered the same way.
|
|
49
|
+
*
|
|
50
|
+
* @param {import("fastify").FastifyReply} reply
|
|
51
|
+
* @param {{ reason: string }} details
|
|
52
|
+
* @returns {import("fastify").FastifyReply}
|
|
53
|
+
*/
|
|
54
|
+
export function replyAssignmentLost(reply, details) {
|
|
55
|
+
return reply.code(409).send({
|
|
56
|
+
error: `This part was answered by an output that has gone: ${details?.reason ?? "unknown"}.`,
|
|
57
|
+
outcome: "assignment-lost",
|
|
58
|
+
reason: details?.reason ?? ""
|
|
59
|
+
});
|
|
60
|
+
}
|