@torrent-tv/proxy 2.87.20 → 2.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +133 -133
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +50 -50
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +106 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -217
- 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 +138 -138
- package/routes/api/transcode-sessions/post.js +173 -173
- 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 +380 -380
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/server.js +725 -725
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +376 -376
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +813 -813
- package/services/encode/EncodeRuns.js +1235 -1235
- 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 +758 -758
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2184 -2184
- 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 +1141 -1141
- 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 +315 -315
- 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 +754 -754
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +456 -456
- package/services/encode/segment-formats/index.js +85 -85
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +515 -515
- 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 +245 -245
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +931 -931
- 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 +1150 -1150
- package/services/server/ViewerRequests.js +760 -760
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/playback-readiness.js +696 -696
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +992 -992
- 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 +539 -539
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1931 -1908
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1240 -1240
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +382 -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/episode-naming.js +182 -0
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2675 -2675
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +765 -765
- 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 +371 -371
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/torrent/worker/worker.js +1129 -1129
- package/services/transport/data-channel-handler.js +1369 -1369
- 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 +691 -691
- 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-inventory.test.js +212 -212
- 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 -92
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +43 -43
- 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 +162 -162
- 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/episode-naming.test.js +131 -0
- 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-presentation-clock.test.js +147 -147
- 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 +401 -401
- 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 +595 -595
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +403 -365
- 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/playback-readiness.test.js +453 -453
- package/test/playback-start-regressions.test.js +99 -99
- 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 +12 -2
- 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 +209 -209
- 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,72 +1,72 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A stream of bytes with no torrent behind it, for measuring delivery alone.
|
|
3
|
-
*
|
|
4
|
-
* GET /api/delivery-sink?bytes=8388608
|
|
5
|
-
*
|
|
6
|
-
* The delivery freeze (roadmap item 11) takes hundreds of megabytes to appear
|
|
7
|
-
* and needs the real transport to appear at all, so reproducing it means
|
|
8
|
-
* pushing gigabytes through a data channel on demand. Doing that through a real
|
|
9
|
-
* file drags in the swarm, the encoder and the disk, none of which is under
|
|
10
|
-
* test and each of which has its own stalls; this route removes them and leaves
|
|
11
|
-
* the path the question is about: the same handler, the same chunk loop, the
|
|
12
|
-
* same backpressure, the same channel.
|
|
13
|
-
*
|
|
14
|
-
* The body is a repeating pattern rather than zeroes, so a receiver can verify
|
|
15
|
-
* it got the bytes it was promised in the order they were sent — a silent
|
|
16
|
-
* truncation would otherwise read as a successful transfer.
|
|
17
|
-
*
|
|
18
|
-
* Enabled only when the proxy was started with `--delivery-sink`; without the
|
|
19
|
-
* flag the route answers 404, so an ordinary install has no such endpoint.
|
|
20
|
-
*
|
|
21
|
-
* @param {import("fastify").FastifyRequest} req
|
|
22
|
-
* @param {import("fastify").FastifyReply} reply
|
|
23
|
-
* @param {{ enabled: boolean }} deps
|
|
24
|
-
* @returns {Promise<void>}
|
|
25
|
-
*/
|
|
26
|
-
export async function handleApiDeliverySinkGet(req, reply, { enabled } = {}) {
|
|
27
|
-
if (enabled !== true) {
|
|
28
|
-
return reply.code(404).send({ error: "Not found." });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const requested = Number((req.query ?? {}).bytes);
|
|
32
|
-
const total = Number.isFinite(requested)
|
|
33
|
-
? Math.min(Math.max(Math.trunc(requested), 0), MAX_SINK_BYTES)
|
|
34
|
-
: DEFAULT_SINK_BYTES;
|
|
35
|
-
const requestedChunk = Number((req.query ?? {}).chunkBytes);
|
|
36
|
-
const chunkBytes = Number.isFinite(requestedChunk)
|
|
37
|
-
? Math.min(Math.max(Math.trunc(requestedChunk), 1024), CHUNK_BYTES)
|
|
38
|
-
: CHUNK_BYTES;
|
|
39
|
-
|
|
40
|
-
// One buffer, reused: allocating a fresh chunk per iteration would make this
|
|
41
|
-
// route measure the allocator as much as the transport.
|
|
42
|
-
const chunk = Buffer.alloc(chunkBytes);
|
|
43
|
-
for (let i = 0; i < chunkBytes; i += 1) {
|
|
44
|
-
chunk[i] = i % 251; // 251 is prime, so the pattern does not align to any power of two.
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
reply.header("content-type", "application/octet-stream");
|
|
48
|
-
reply.header("content-length", String(total));
|
|
49
|
-
reply.header("cache-control", "no-store");
|
|
50
|
-
|
|
51
|
-
let sent = 0;
|
|
52
|
-
const stream = new ReadableStream({
|
|
53
|
-
pull(controller) {
|
|
54
|
-
if (sent >= total) {
|
|
55
|
-
controller.close();
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const size = Math.min(chunkBytes, total - sent);
|
|
59
|
-
controller.enqueue(size === chunkBytes ? chunk : chunk.subarray(0, size));
|
|
60
|
-
sent += size;
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
return reply.send(stream);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Chunk size, matching what a body read hands the send loop for a real file. */
|
|
68
|
-
const CHUNK_BYTES = 64 * 1024;
|
|
69
|
-
/** Default when no size is asked for: about one segment of 1080p. */
|
|
70
|
-
const DEFAULT_SINK_BYTES = 8 * 1024 * 1024;
|
|
71
|
-
/** Ceiling, so one request cannot ask the proxy for an unbounded stream. */
|
|
72
|
-
const MAX_SINK_BYTES = 64 * 1024 * 1024;
|
|
1
|
+
/**
|
|
2
|
+
* A stream of bytes with no torrent behind it, for measuring delivery alone.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/delivery-sink?bytes=8388608
|
|
5
|
+
*
|
|
6
|
+
* The delivery freeze (roadmap item 11) takes hundreds of megabytes to appear
|
|
7
|
+
* and needs the real transport to appear at all, so reproducing it means
|
|
8
|
+
* pushing gigabytes through a data channel on demand. Doing that through a real
|
|
9
|
+
* file drags in the swarm, the encoder and the disk, none of which is under
|
|
10
|
+
* test and each of which has its own stalls; this route removes them and leaves
|
|
11
|
+
* the path the question is about: the same handler, the same chunk loop, the
|
|
12
|
+
* same backpressure, the same channel.
|
|
13
|
+
*
|
|
14
|
+
* The body is a repeating pattern rather than zeroes, so a receiver can verify
|
|
15
|
+
* it got the bytes it was promised in the order they were sent — a silent
|
|
16
|
+
* truncation would otherwise read as a successful transfer.
|
|
17
|
+
*
|
|
18
|
+
* Enabled only when the proxy was started with `--delivery-sink`; without the
|
|
19
|
+
* flag the route answers 404, so an ordinary install has no such endpoint.
|
|
20
|
+
*
|
|
21
|
+
* @param {import("fastify").FastifyRequest} req
|
|
22
|
+
* @param {import("fastify").FastifyReply} reply
|
|
23
|
+
* @param {{ enabled: boolean }} deps
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
export async function handleApiDeliverySinkGet(req, reply, { enabled } = {}) {
|
|
27
|
+
if (enabled !== true) {
|
|
28
|
+
return reply.code(404).send({ error: "Not found." });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const requested = Number((req.query ?? {}).bytes);
|
|
32
|
+
const total = Number.isFinite(requested)
|
|
33
|
+
? Math.min(Math.max(Math.trunc(requested), 0), MAX_SINK_BYTES)
|
|
34
|
+
: DEFAULT_SINK_BYTES;
|
|
35
|
+
const requestedChunk = Number((req.query ?? {}).chunkBytes);
|
|
36
|
+
const chunkBytes = Number.isFinite(requestedChunk)
|
|
37
|
+
? Math.min(Math.max(Math.trunc(requestedChunk), 1024), CHUNK_BYTES)
|
|
38
|
+
: CHUNK_BYTES;
|
|
39
|
+
|
|
40
|
+
// One buffer, reused: allocating a fresh chunk per iteration would make this
|
|
41
|
+
// route measure the allocator as much as the transport.
|
|
42
|
+
const chunk = Buffer.alloc(chunkBytes);
|
|
43
|
+
for (let i = 0; i < chunkBytes; i += 1) {
|
|
44
|
+
chunk[i] = i % 251; // 251 is prime, so the pattern does not align to any power of two.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
reply.header("content-type", "application/octet-stream");
|
|
48
|
+
reply.header("content-length", String(total));
|
|
49
|
+
reply.header("cache-control", "no-store");
|
|
50
|
+
|
|
51
|
+
let sent = 0;
|
|
52
|
+
const stream = new ReadableStream({
|
|
53
|
+
pull(controller) {
|
|
54
|
+
if (sent >= total) {
|
|
55
|
+
controller.close();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const size = Math.min(chunkBytes, total - sent);
|
|
59
|
+
controller.enqueue(size === chunkBytes ? chunk : chunk.subarray(0, size));
|
|
60
|
+
sent += size;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return reply.send(stream);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Chunk size, matching what a body read hands the send loop for a real file. */
|
|
68
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
69
|
+
/** Default when no size is asked for: about one segment of 1080p. */
|
|
70
|
+
const DEFAULT_SINK_BYTES = 8 * 1024 * 1024;
|
|
71
|
+
/** Ceiling, so one request cannot ask the proxy for an unbounded stream. */
|
|
72
|
+
const MAX_SINK_BYTES = 64 * 1024 * 1024;
|
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file `GET /api/link-probe?bytes=N` — bytes to measure the link with.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS EXISTS. A viewer's link is measured from completed transfers, and
|
|
5
|
-
* the only transfers that counted were segments of the film. So the figure came
|
|
6
|
-
* into being after playback had begun — after the quality offer had been made,
|
|
7
|
-
* and after the start-up decisions that want it. Worse, the page's report of
|
|
8
|
-
* its OWN facts was skipped when there was no figure, so at a cold open — the
|
|
9
|
-
* moment where where-the-viewer-is matters most — the page said nothing at all
|
|
10
|
-
* (2026-09-14).
|
|
11
|
-
*
|
|
12
|
-
* There is nothing to wait for: a proxy can send bytes the moment its channel
|
|
13
|
-
* is up, which is when the page opens rather than when a film is chosen.
|
|
14
|
-
*
|
|
15
|
-
* NOT `delivery-sink`, which is the load stand's and answers any size asked
|
|
16
|
-
* for. This is part of the product, always on, and bounded — the browser's own
|
|
17
|
-
* sizing stops at 2 MiB and this refuses more, so a caller cannot turn it into
|
|
18
|
-
* a way of drawing a proxy owner's uplink.
|
|
19
|
-
*
|
|
20
|
-
* The body is zeros. What is being measured is the wire, and zeros cross it at
|
|
21
|
-
* the same speed as anything else; producing them costs one allocation.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The most this route will send for one ask.
|
|
26
|
-
*
|
|
27
|
-
* The figure is only ever used to decide what a link can carry, and the most
|
|
28
|
-
* anything this product offers is a 1080p rung at around 12 Mbit/s. Two
|
|
29
|
-
* mebibytes inside the browser's minimum measurable transfer (50 ms) is
|
|
30
|
-
* 336 Mbit/s, twenty-eight times that rung: a link too fast to time at this
|
|
31
|
-
* size is beyond anything a decision here distinguishes.
|
|
32
|
-
*/
|
|
33
|
-
export const MAX_LINK_PROBE_BYTES = 2 * 1024 * 1024;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* @param {import("fastify").FastifyRequest} req
|
|
37
|
-
* @param {import("fastify").FastifyReply} reply
|
|
38
|
-
* @returns {Promise<void>}
|
|
39
|
-
*/
|
|
40
|
-
export async function handleApiLinkProbeGet(req, reply) {
|
|
41
|
-
const asked = Number(/** @type {{ bytes?: string }} */ (req.query)?.bytes);
|
|
42
|
-
if (!Number.isFinite(asked) || asked <= 0) {
|
|
43
|
-
return reply.code(400).send({ error: "bytes (>0) is required." });
|
|
44
|
-
}
|
|
45
|
-
const bytes = Math.min(Math.floor(asked), MAX_LINK_PROBE_BYTES);
|
|
46
|
-
return reply
|
|
47
|
-
.code(200)
|
|
48
|
-
.header("Content-Type", "application/octet-stream")
|
|
49
|
-
.header("Cache-Control", "no-store")
|
|
50
|
-
.send(Buffer.alloc(bytes));
|
|
51
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file `GET /api/link-probe?bytes=N` — bytes to measure the link with.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. A viewer's link is measured from completed transfers, and
|
|
5
|
+
* the only transfers that counted were segments of the film. So the figure came
|
|
6
|
+
* into being after playback had begun — after the quality offer had been made,
|
|
7
|
+
* and after the start-up decisions that want it. Worse, the page's report of
|
|
8
|
+
* its OWN facts was skipped when there was no figure, so at a cold open — the
|
|
9
|
+
* moment where where-the-viewer-is matters most — the page said nothing at all
|
|
10
|
+
* (2026-09-14).
|
|
11
|
+
*
|
|
12
|
+
* There is nothing to wait for: a proxy can send bytes the moment its channel
|
|
13
|
+
* is up, which is when the page opens rather than when a film is chosen.
|
|
14
|
+
*
|
|
15
|
+
* NOT `delivery-sink`, which is the load stand's and answers any size asked
|
|
16
|
+
* for. This is part of the product, always on, and bounded — the browser's own
|
|
17
|
+
* sizing stops at 2 MiB and this refuses more, so a caller cannot turn it into
|
|
18
|
+
* a way of drawing a proxy owner's uplink.
|
|
19
|
+
*
|
|
20
|
+
* The body is zeros. What is being measured is the wire, and zeros cross it at
|
|
21
|
+
* the same speed as anything else; producing them costs one allocation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The most this route will send for one ask.
|
|
26
|
+
*
|
|
27
|
+
* The figure is only ever used to decide what a link can carry, and the most
|
|
28
|
+
* anything this product offers is a 1080p rung at around 12 Mbit/s. Two
|
|
29
|
+
* mebibytes inside the browser's minimum measurable transfer (50 ms) is
|
|
30
|
+
* 336 Mbit/s, twenty-eight times that rung: a link too fast to time at this
|
|
31
|
+
* size is beyond anything a decision here distinguishes.
|
|
32
|
+
*/
|
|
33
|
+
export const MAX_LINK_PROBE_BYTES = 2 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import("fastify").FastifyRequest} req
|
|
37
|
+
* @param {import("fastify").FastifyReply} reply
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
export async function handleApiLinkProbeGet(req, reply) {
|
|
41
|
+
const asked = Number(/** @type {{ bytes?: string }} */ (req.query)?.bytes);
|
|
42
|
+
if (!Number.isFinite(asked) || asked <= 0) {
|
|
43
|
+
return reply.code(400).send({ error: "bytes (>0) is required." });
|
|
44
|
+
}
|
|
45
|
+
const bytes = Math.min(Math.floor(asked), MAX_LINK_PROBE_BYTES);
|
|
46
|
+
return reply
|
|
47
|
+
.code(200)
|
|
48
|
+
.header("Content-Type", "application/octet-stream")
|
|
49
|
+
.header("Cache-Control", "no-store")
|
|
50
|
+
.send(Buffer.alloc(bytes));
|
|
51
|
+
}
|
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Refresh metadata for audio tracks whose sidecar headers were unavailable
|
|
3
|
-
* when the playback plan was built.
|
|
4
|
-
*
|
|
5
|
-
* POST /api/playback-plan/audio-tracks
|
|
6
|
-
*
|
|
7
|
-
* @param {import("fastify").FastifyRequest} req
|
|
8
|
-
* @param {import("fastify").FastifyReply} reply
|
|
9
|
-
* @param {{ playbackPlanner: object }} deps
|
|
10
|
-
*/
|
|
11
|
-
export async function handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner }) {
|
|
12
|
-
const payload = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
13
|
-
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
14
|
-
const fileIndex = Number(payload.fileIndex);
|
|
15
|
-
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
16
|
-
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
17
|
-
}
|
|
18
|
-
try {
|
|
19
|
-
const { PlaybackController } = await import("../../../../services/server/controllers/PlaybackController.js");
|
|
20
|
-
const controller = new PlaybackController({ playbackPlanner });
|
|
21
|
-
return reply.send(await controller.refreshAudioTracks({ sourceKey, fileIndex }));
|
|
22
|
-
} catch (error) {
|
|
23
|
-
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
24
|
-
return reply.code(404).send({ error: error.message });
|
|
25
|
-
}
|
|
26
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
-
return reply.code(500).send({ error: `Failed to refresh audio track metadata: ${message}` });
|
|
28
|
-
}
|
|
29
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Refresh metadata for audio tracks whose sidecar headers were unavailable
|
|
3
|
+
* when the playback plan was built.
|
|
4
|
+
*
|
|
5
|
+
* POST /api/playback-plan/audio-tracks
|
|
6
|
+
*
|
|
7
|
+
* @param {import("fastify").FastifyRequest} req
|
|
8
|
+
* @param {import("fastify").FastifyReply} reply
|
|
9
|
+
* @param {{ playbackPlanner: object }} deps
|
|
10
|
+
*/
|
|
11
|
+
export async function handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner }) {
|
|
12
|
+
const payload = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
13
|
+
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
14
|
+
const fileIndex = Number(payload.fileIndex);
|
|
15
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
16
|
+
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const { PlaybackController } = await import("../../../../services/server/controllers/PlaybackController.js");
|
|
20
|
+
const controller = new PlaybackController({ playbackPlanner });
|
|
21
|
+
return reply.send(await controller.refreshAudioTracks({ sourceKey, fileIndex }));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
24
|
+
return reply.code(404).send({ error: error.message });
|
|
25
|
+
}
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
return reply.code(500).send({ error: `Failed to refresh audio track metadata: ${message}` });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Determine the best playback mode (direct stream or HLS transcode) for a
|
|
3
|
-
* torrent file and return the corresponding plan.
|
|
4
|
-
*
|
|
5
|
-
* POST /api/playback-plan
|
|
6
|
-
*
|
|
7
|
-
* @param {import("fastify").FastifyRequest} req
|
|
8
|
-
* @param {import("fastify").FastifyReply} reply
|
|
9
|
-
* @param {{ playbackPlanner: ReturnType<import("../../../services/media/playback-planner.js").createPlaybackPlanner> }} deps
|
|
10
|
-
* @returns {Promise<void>}
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Extract a plain object from the request body, guarding against
|
|
15
|
-
* non-object payloads (arrays, primitives, null).
|
|
16
|
-
*
|
|
17
|
-
* @param {unknown} body
|
|
18
|
-
* @returns {Record<string, unknown>}
|
|
19
|
-
*/
|
|
20
|
-
function getPayload(body) {
|
|
21
|
-
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
22
|
-
return body;
|
|
23
|
-
}
|
|
24
|
-
return {};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
28
|
-
const payload = getPayload(req.body);
|
|
29
|
-
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
30
|
-
const fileIndex = Number(payload.fileIndex);
|
|
31
|
-
const userAgent = typeof payload.userAgent === "string" ? payload.userAgent : "";
|
|
32
|
-
|
|
33
|
-
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
34
|
-
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Interface delegates to PlaybackController (orchestrator + domain). Keeps route thin.
|
|
38
|
-
const { PlaybackController } = await import("../../../services/server/controllers/PlaybackController.js");
|
|
39
|
-
const controller = new PlaybackController({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner });
|
|
40
|
-
try {
|
|
41
|
-
const plan = await controller.getPlan({ sourceKey, fileIndex, userAgent, maxWaitMs: 8_000 });
|
|
42
|
-
return reply.send(plan);
|
|
43
|
-
} catch (error) {
|
|
44
|
-
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
45
|
-
return reply.code(404).send({ error: error.message });
|
|
46
|
-
}
|
|
47
|
-
if (error instanceof Error && error.code === "FILE_NOT_FOUND") {
|
|
48
|
-
return reply.code(404).send({ error: error.message });
|
|
49
|
-
}
|
|
50
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
-
return reply.code(500).send({ error: `Failed to prepare playback plan: ${message}` });
|
|
52
|
-
}
|
|
53
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Determine the best playback mode (direct stream or HLS transcode) for a
|
|
3
|
+
* torrent file and return the corresponding plan.
|
|
4
|
+
*
|
|
5
|
+
* POST /api/playback-plan
|
|
6
|
+
*
|
|
7
|
+
* @param {import("fastify").FastifyRequest} req
|
|
8
|
+
* @param {import("fastify").FastifyReply} reply
|
|
9
|
+
* @param {{ playbackPlanner: ReturnType<import("../../../services/media/playback-planner.js").createPlaybackPlanner> }} deps
|
|
10
|
+
* @returns {Promise<void>}
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract a plain object from the request body, guarding against
|
|
15
|
+
* non-object payloads (arrays, primitives, null).
|
|
16
|
+
*
|
|
17
|
+
* @param {unknown} body
|
|
18
|
+
* @returns {Record<string, unknown>}
|
|
19
|
+
*/
|
|
20
|
+
function getPayload(body) {
|
|
21
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
22
|
+
return body;
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
28
|
+
const payload = getPayload(req.body);
|
|
29
|
+
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
30
|
+
const fileIndex = Number(payload.fileIndex);
|
|
31
|
+
const userAgent = typeof payload.userAgent === "string" ? payload.userAgent : "";
|
|
32
|
+
|
|
33
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
34
|
+
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Interface delegates to PlaybackController (orchestrator + domain). Keeps route thin.
|
|
38
|
+
const { PlaybackController } = await import("../../../services/server/controllers/PlaybackController.js");
|
|
39
|
+
const controller = new PlaybackController({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner });
|
|
40
|
+
try {
|
|
41
|
+
const plan = await controller.getPlan({ sourceKey, fileIndex, userAgent, maxWaitMs: 8_000 });
|
|
42
|
+
return reply.send(plan);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
45
|
+
return reply.code(404).send({ error: error.message });
|
|
46
|
+
}
|
|
47
|
+
if (error instanceof Error && error.code === "FILE_NOT_FOUND") {
|
|
48
|
+
return reply.code(404).send({ error: error.message });
|
|
49
|
+
}
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
return reply.code(500).send({ error: `Failed to prepare playback plan: ${message}` });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -1,100 +1,106 @@
|
|
|
1
|
-
import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* List the files of a registered source (torrent file OR magnet).
|
|
5
|
-
*
|
|
6
|
-
* GET /api/sources/:sourceKey/files
|
|
7
|
-
*
|
|
8
|
-
* The browser parses `.torrent` files locally, but a magnet URI carries no
|
|
9
|
-
* file list — the metadata comes from the swarm and can take a while to
|
|
10
|
-
* arrive on a cold magnet. Rather than block the request until it does (a
|
|
11
|
-
* single long request racing the transport timeout, which surfaced as a
|
|
12
|
-
* premature "no peers" error while the metadata was in fact still arriving),
|
|
13
|
-
* this waits only a short per-request budget: if the metadata is not ready it
|
|
14
|
-
* returns `{ pending: true }` while the fetch continues in the background. The
|
|
15
|
-
* caller polls again until the file list comes back — mirroring the cold-
|
|
16
|
-
* torrent playback-plan poll.
|
|
17
|
-
*
|
|
18
|
-
* @param {import("fastify").FastifyRequest} req
|
|
19
|
-
* @param {import("fastify").FastifyReply} reply
|
|
20
|
-
* @param {{
|
|
21
|
-
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
22
|
-
* torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
|
|
23
|
-
* }} deps
|
|
24
|
-
* @returns {Promise<void>}
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/** Sentinel resolved when the per-request wait elapses before metadata. */
|
|
28
|
-
const PENDING = Symbol("pending");
|
|
29
|
-
|
|
30
|
-
export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
31
|
-
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
32
|
-
if (!sourceKey) {
|
|
33
|
-
return reply.code(400).send({ error: "sourceKey is required." });
|
|
34
|
-
}
|
|
35
|
-
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
36
|
-
if (!sourceRecord) {
|
|
37
|
-
return reply.code(404).send({ error: "Source key was not found." });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// How long to wait within THIS request before returning `pending`. Well
|
|
41
|
-
// under the transport's request timeout so a single poll never races it.
|
|
42
|
-
const rawWait = Number(req.query?.maxWaitMs);
|
|
43
|
-
const maxWaitMs = Number.isFinite(rawWait) && rawWait > 0 ? Math.min(rawWait, 20_000) : 8_000;
|
|
44
|
-
|
|
45
|
-
// getTorrent dedupes concurrent/repeated calls via the pool's in-flight map,
|
|
46
|
-
// so polling keeps joining the same background fetch (metadata keeps
|
|
47
|
-
// downloading between polls). Race it against the wait budget; if the wait
|
|
48
|
-
// wins, the fetch is left running for the next poll to observe.
|
|
49
|
-
let timer;
|
|
50
|
-
const waitPromise = new Promise((resolve) => {
|
|
51
|
-
timer = setTimeout(() => resolve(PENDING), maxWaitMs);
|
|
52
|
-
timer.unref?.();
|
|
53
|
-
});
|
|
54
|
-
const torrentPromise = torrentPool
|
|
55
|
-
.getTorrent(sourceRecord.sourceType, sourceRecord.source)
|
|
56
|
-
// Swallow so a rejection that loses the race is not an unhandled rejection;
|
|
57
|
-
// the next poll re-issues getTorrent and re-observes any real error.
|
|
58
|
-
.catch((error) => (error instanceof Error ? error : new Error(String(error))));
|
|
59
|
-
|
|
60
|
-
let result;
|
|
61
|
-
try {
|
|
62
|
-
result = await Promise.race([torrentPromise, waitPromise]);
|
|
63
|
-
} finally {
|
|
64
|
-
clearTimeout(timer);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (result === PENDING) {
|
|
68
|
-
return reply.send({ pending: true });
|
|
69
|
-
}
|
|
70
|
-
if (result instanceof Error) {
|
|
71
|
-
return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const torrent = result;
|
|
75
|
-
// WHAT IS IN THIS TORRENT, decided here and nowhere else. The browser used to
|
|
76
|
-
// decide it again — a list of video extensions in its parser and a second,
|
|
77
|
-
// shorter pair inside its picker — and the three answers had already diverged
|
|
78
|
-
// (measured 2026-09-12: `.dat` was offered there and not counted here, which
|
|
79
|
-
// also decides whether a sidecar whose name matches nothing can belong to the
|
|
80
|
-
// only video present). It ships the paths already relative to the torrent
|
|
81
|
-
// root, so there is no stripping rule on the other side either.
|
|
82
|
-
const contents = contentsOf(torrent);
|
|
83
|
-
return reply.send({
|
|
84
|
-
name: torrent.name ?? "",
|
|
85
|
-
infoHash: torrent.infoHash ?? "",
|
|
86
|
-
// In the order a person reads them — by folder, then by name, with runs of
|
|
87
|
-
// digits compared as numbers. A torrent's own order is whatever the tool
|
|
88
|
-
// that made it chose, and it is routinely by size.
|
|
89
|
-
files: contents.files(),
|
|
90
|
-
// The pictures, each with what belongs to it. By index, because the files
|
|
91
|
-
// themselves are in the list above and saying them twice is how two copies
|
|
92
|
-
// of one fact start.
|
|
93
|
-
items: contents.items.map((item) => ({
|
|
94
|
-
fileIndex: item.fileIndex,
|
|
95
|
-
audio: item.audio.map((part) => part.fileIndex),
|
|
96
|
-
subtitles: item.subtitles.map((part) => part.fileIndex),
|
|
97
|
-
images: item.images.map((part) => part.fileIndex)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1
|
+
import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List the files of a registered source (torrent file OR magnet).
|
|
5
|
+
*
|
|
6
|
+
* GET /api/sources/:sourceKey/files
|
|
7
|
+
*
|
|
8
|
+
* The browser parses `.torrent` files locally, but a magnet URI carries no
|
|
9
|
+
* file list — the metadata comes from the swarm and can take a while to
|
|
10
|
+
* arrive on a cold magnet. Rather than block the request until it does (a
|
|
11
|
+
* single long request racing the transport timeout, which surfaced as a
|
|
12
|
+
* premature "no peers" error while the metadata was in fact still arriving),
|
|
13
|
+
* this waits only a short per-request budget: if the metadata is not ready it
|
|
14
|
+
* returns `{ pending: true }` while the fetch continues in the background. The
|
|
15
|
+
* caller polls again until the file list comes back — mirroring the cold-
|
|
16
|
+
* torrent playback-plan poll.
|
|
17
|
+
*
|
|
18
|
+
* @param {import("fastify").FastifyRequest} req
|
|
19
|
+
* @param {import("fastify").FastifyReply} reply
|
|
20
|
+
* @param {{
|
|
21
|
+
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
22
|
+
* torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
|
|
23
|
+
* }} deps
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Sentinel resolved when the per-request wait elapses before metadata. */
|
|
28
|
+
const PENDING = Symbol("pending");
|
|
29
|
+
|
|
30
|
+
export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
31
|
+
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
32
|
+
if (!sourceKey) {
|
|
33
|
+
return reply.code(400).send({ error: "sourceKey is required." });
|
|
34
|
+
}
|
|
35
|
+
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
36
|
+
if (!sourceRecord) {
|
|
37
|
+
return reply.code(404).send({ error: "Source key was not found." });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// How long to wait within THIS request before returning `pending`. Well
|
|
41
|
+
// under the transport's request timeout so a single poll never races it.
|
|
42
|
+
const rawWait = Number(req.query?.maxWaitMs);
|
|
43
|
+
const maxWaitMs = Number.isFinite(rawWait) && rawWait > 0 ? Math.min(rawWait, 20_000) : 8_000;
|
|
44
|
+
|
|
45
|
+
// getTorrent dedupes concurrent/repeated calls via the pool's in-flight map,
|
|
46
|
+
// so polling keeps joining the same background fetch (metadata keeps
|
|
47
|
+
// downloading between polls). Race it against the wait budget; if the wait
|
|
48
|
+
// wins, the fetch is left running for the next poll to observe.
|
|
49
|
+
let timer;
|
|
50
|
+
const waitPromise = new Promise((resolve) => {
|
|
51
|
+
timer = setTimeout(() => resolve(PENDING), maxWaitMs);
|
|
52
|
+
timer.unref?.();
|
|
53
|
+
});
|
|
54
|
+
const torrentPromise = torrentPool
|
|
55
|
+
.getTorrent(sourceRecord.sourceType, sourceRecord.source)
|
|
56
|
+
// Swallow so a rejection that loses the race is not an unhandled rejection;
|
|
57
|
+
// the next poll re-issues getTorrent and re-observes any real error.
|
|
58
|
+
.catch((error) => (error instanceof Error ? error : new Error(String(error))));
|
|
59
|
+
|
|
60
|
+
let result;
|
|
61
|
+
try {
|
|
62
|
+
result = await Promise.race([torrentPromise, waitPromise]);
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (result === PENDING) {
|
|
68
|
+
return reply.send({ pending: true });
|
|
69
|
+
}
|
|
70
|
+
if (result instanceof Error) {
|
|
71
|
+
return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const torrent = result;
|
|
75
|
+
// WHAT IS IN THIS TORRENT, decided here and nowhere else. The browser used to
|
|
76
|
+
// decide it again — a list of video extensions in its parser and a second,
|
|
77
|
+
// shorter pair inside its picker — and the three answers had already diverged
|
|
78
|
+
// (measured 2026-09-12: `.dat` was offered there and not counted here, which
|
|
79
|
+
// also decides whether a sidecar whose name matches nothing can belong to the
|
|
80
|
+
// only video present). It ships the paths already relative to the torrent
|
|
81
|
+
// root, so there is no stripping rule on the other side either.
|
|
82
|
+
const contents = contentsOf(torrent);
|
|
83
|
+
return reply.send({
|
|
84
|
+
name: torrent.name ?? "",
|
|
85
|
+
infoHash: torrent.infoHash ?? "",
|
|
86
|
+
// In the order a person reads them — by folder, then by name, with runs of
|
|
87
|
+
// digits compared as numbers. A torrent's own order is whatever the tool
|
|
88
|
+
// that made it chose, and it is routinely by size.
|
|
89
|
+
files: contents.files(),
|
|
90
|
+
// The pictures, each with what belongs to it. By index, because the files
|
|
91
|
+
// themselves are in the list above and saying them twice is how two copies
|
|
92
|
+
// of one fact start.
|
|
93
|
+
items: contents.items.map((item) => ({
|
|
94
|
+
fileIndex: item.fileIndex,
|
|
95
|
+
audio: item.audio.map((part) => part.fileIndex),
|
|
96
|
+
subtitles: item.subtitles.map((part) => part.fileIndex),
|
|
97
|
+
images: item.images.map((part) => part.fileIndex),
|
|
98
|
+
// What the name says about which episode this is, in the release's own
|
|
99
|
+
// numbering — read by the page to ask which episode of the show it is.
|
|
100
|
+
episode: item.episode
|
|
101
|
+
})),
|
|
102
|
+
// One film, a series, or not known — whether one identification of the
|
|
103
|
+
// whole release applies to its pictures at all.
|
|
104
|
+
shape: contents.shape
|
|
105
|
+
});
|
|
106
|
+
}
|