@torrent-tv/proxy 2.86.0 → 2.87.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/CHANGELOG.md +2138 -1915
- package/CLAUDE.md +318 -197
- package/assets/calibration/NOTICE.md +2 -2
- package/bin/cli.js +684 -616
- package/biome.json +409 -233
- package/docs/container-architecture.md +218 -213
- package/docs/download-architecture.md +7 -7
- package/docs/encode-architecture.md +287 -11
- package/docs/encode-run-state.md +2 -2
- package/docs/logs.md +97 -97
- package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
- package/openspec/changes/adaptive-bitrate/design.md +2 -2
- package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
- package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
- package/openspec/changes/chunked-request-bodies/design.md +1 -1
- package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
- package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
- package/openspec/changes/cold-start/design.md +1 -1
- package/openspec/changes/disk-cap/proposal.md +1 -1
- package/openspec/changes/proxy-observability/proposal.md +2 -2
- package/openspec/changes/track-selection/proposal.md +1 -1
- package/openspec/changes/transcode-quality/proposal.md +1 -1
- package/package.json +2 -2
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/routes/api/playback-plan/post.js +2 -2
- package/routes/api/sources/files/get.js +1 -1
- package/routes/api/sources/stats/get.js +1 -1
- package/routes/api/sources/warm/post.js +10 -3
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
- package/routes/api/transcode-sessions/net-report/post.js +129 -94
- package/routes/api/transcode-sessions/post.js +160 -139
- package/routes/api/transcode-sessions/progress/get.js +3 -3
- package/routes/api/transcode-sessions/release/post.js +3 -3
- package/routes/api/transcode-sessions/seek/post.js +12 -3
- package/routes/stream/get.js +2 -2
- package/routes/transcode/audio-file/get.js +15 -6
- package/routes/transcode/audio-warm/get.js +8 -5
- package/routes/transcode/session-file/get.js +429 -268
- package/routes/transcode/variant-file/get.js +47 -7
- package/routes/transcode/variant-warm/get.js +18 -6
- package/scripts/render-run-graph.js +1 -1
- package/server.js +720 -465
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -0
- package/services/encode/EncodeAdmission.js +332 -0
- package/services/{orchestrators → encode}/EncodeOrchestrator.js +285 -17
- package/services/encode/EncodeRun.js +16 -9
- package/services/encode/EncodeRuns.js +1219 -0
- package/services/encode/Encoder.js +18 -2
- package/services/encode/EncoderSelection.js +38 -0
- package/services/encode/LocalObservations.js +359 -0
- package/services/encode/NvencEncoder.js +5 -0
- package/services/encode/OutputOpening.js +745 -0
- package/services/encode/OutputTimes.js +579 -0
- package/services/encode/Renditions.js +2162 -0
- package/services/encode/RunProgress.js +84 -0
- package/services/encode/SoftwareEncoder.js +12 -7
- package/services/encode/args.js +402 -186
- package/services/encode/calibration.js +361 -0
- package/services/encode/contention.js +128 -128
- package/services/encode/encode-run-state.js +16 -20
- package/services/encode/fingerprint.js +322 -0
- package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
- package/services/{learned-median.js → encode/learned-median.js} +11 -0
- package/services/encode/output/EncodedOutput.js +57 -0
- package/services/encode/output/Output.js +131 -0
- package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
- package/services/encode/output/OutputSpec.js +390 -0
- package/services/{output → encode/output}/Timeline.js +12 -37
- package/services/encode/output/index.js +20 -0
- package/services/encode/output/ladder.js +32 -0
- package/services/{output → encode/output}/playlists.js +3 -0
- package/services/encode/output-key-format.js +21 -0
- package/services/encode/quality/EncodeCost.js +1083 -0
- package/services/encode/quality/HostLoad.js +525 -0
- package/services/encode/quality/HostTimings.js +249 -0
- package/services/encode/quality/OutputQualityState.js +28 -0
- package/services/encode/quality/QualityController.js +931 -0
- package/services/encode/quality/QualityOffer.js +416 -0
- package/services/encode/quality/drain-threat.js +47 -0
- package/services/encode/quality/link-budget.js +254 -0
- package/services/encode/quality/output-format.js +266 -0
- package/services/encode/quality/serving-output.js +216 -0
- package/services/encode/quality/visible-rung.js +57 -0
- package/services/encode/run-command.js +23 -20
- package/services/encode/run-inputs.js +42 -0
- package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
- package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
- package/services/encode/segment-formats/init-compat.js +324 -0
- package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
- package/services/encode/throughput.js +97 -0
- package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
- package/services/media/KeyframeTables.js +316 -0
- package/services/{source → media}/SourceFile.js +7 -55
- package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
- package/services/media/SubtitleOrchestrator.js +167 -0
- package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
- package/services/{container → media/container}/AviContainer.js +266 -266
- package/services/{container → media/container}/Container.js +596 -601
- package/services/media/container/ContainerFactory.js +56 -0
- package/services/media/container/KeyframeTable.js +272 -0
- package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
- package/services/{container → media/container}/Mp4Container.js +1244 -1244
- package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
- package/services/media/keyframe-probe.js +157 -0
- package/services/media/media-info-probe.js +93 -0
- package/services/{playback-planner.js → media/playback-planner.js} +82 -39
- package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
- package/services/{tracks → media/tracks}/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -0
- package/services/server/SegmentServing.js +1146 -0
- package/services/server/ViewerRequests.js +522 -0
- package/services/server/controllers/PlaybackController.js +36 -0
- package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
- package/services/server/wire-outputs.js +958 -0
- package/services/storage/Diagnostics.js +202 -0
- package/services/storage/MachineBudget.js +235 -0
- package/services/{piece-store → storage}/allowance.js +107 -107
- package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
- package/services/{files → storage/files}/CompletedFiles.js +116 -1
- package/services/{disk → storage}/free.js +33 -0
- package/services/storage/machine-memory.js +56 -0
- package/services/{memory-report.js → storage/memory-report.js} +575 -596
- package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
- package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
- package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
- package/services/storage/wire.js +175 -0
- package/services/{download → torrent/download}/SwarmSelection.js +2 -2
- package/services/{download → torrent/download}/registry.js +2 -2
- package/services/torrent/files.js +1 -1
- package/services/torrent/piece-store-of.js +29 -0
- package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
- package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
- package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
- package/services/{torrent-worker → torrent/worker}/client.js +77 -58
- package/services/torrent/worker/held-bytes.js +155 -0
- package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
- package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
- package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
- package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
- package/services/torrent/worker/resume-warm.js +107 -0
- package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
- package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
- package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
- package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
- package/services/{health-collector.js → transport/health-collector.js} +6 -35
- package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
- package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
- package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
- package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
- package/services/viewer/Assignments.js +340 -0
- package/services/{priority → viewer}/PriorityMap.js +390 -390
- package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
- package/services/viewer/Viewer.js +657 -483
- package/services/viewer/Viewers.js +368 -257
- package/services/viewer/active-output.js +37 -54
- package/services/viewer/audio-start.js +4 -3
- package/services/viewer/buffer-trend.js +81 -0
- package/services/viewer/choices.js +647 -0
- package/services/viewer/positions.js +126 -120
- package/services/viewer/report-intake.js +46 -45
- package/test/asset-paths.test.js +28 -0
- package/test/audio-inventory.test.js +3 -3
- package/test/audio-track-bitrate.test.js +40 -0
- package/test/auto-quality-step.test.js +707 -515
- package/test/available-share.test.js +1 -1
- package/test/background-fill.test.js +1 -1
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +1 -1
- package/test/concurrent-cost.test.js +13 -3
- package/test/container-media-info.test.js +3 -3
- package/test/core-dumps.test.js +1 -1
- package/test/coverage-follows-the-disk.test.js +40 -8
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
- package/test/cut-times-timeline.test.js +2 -2
- package/test/cuts-follow-published-grid.test.js +9 -8
- package/test/data-channel-frame.test.js +1 -1
- package/test/dead-torrent-handle.test.js +1 -1
- package/test/declared-rates.test.js +2 -2
- package/test/declared-tracks.test.js +2 -2
- package/test/decode-cost-fit.test.js +1 -1
- package/test/decode-cost.test.js +34 -25
- package/test/delivery-probe.test.js +1 -1
- package/test/demand-register.test.js +4 -4
- package/test/dht-bootstrap.test.js +1 -1
- package/test/drain-threat.test.js +65 -0
- package/test/duplicate-add.test.js +1 -1
- package/test/encode-admission.test.js +146 -0
- package/test/encode-cost.test.js +199 -10
- package/test/encode-orchestrator.test.js +39 -1
- package/test/encode-plan-viewers.test.js +2 -2
- package/test/encode-run.test.js +2 -2
- package/test/encode-slope.test.js +1 -1
- package/test/encoded-output.test.js +39 -0
- package/test/encoder-readings.test.js +1 -1
- package/test/every-claimant-is-told-its-share.test.js +152 -0
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
- package/test/failed-start-breaker.test.js +81 -30
- package/test/fastest-wires.test.js +1 -1
- package/test/ffmpeg-stream-counts.test.js +1 -1
- package/test/file-edges.test.js +3 -3
- package/test/fmp4-self-contained.test.js +1 -1
- package/test/fmp4-timestamps.test.js +1 -1
- package/test/fmp4-track-completeness.test.js +1 -1
- package/test/format-keyed-outputs.test.js +78 -0
- package/test/health-metrics.test.js +3 -2
- package/test/held-request-width.test.js +22 -23
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -0
- package/test/helpers/manager.js +57 -0
- package/test/helpers/output-spec.js +63 -0
- package/test/host-load.test.js +1 -1
- package/test/index-accuracy-is-about-the-file.test.js +128 -0
- package/test/init-compat.test.js +219 -0
- package/test/input-lost-quiets-the-plan.test.js +1 -1
- package/test/input-unavailable.test.js +1 -1
- package/test/keeping-period.test.js +4 -4
- package/test/keyframe-index-accuracy.test.js +97 -45
- package/test/keyframe-table-budget.test.js +28 -33
- package/test/keyframes-belong-to-the-file.test.js +174 -57
- package/test/learned-median.test.js +1 -1
- package/test/letting-go-asks-who-is-watching.test.js +191 -0
- package/test/limit-row-by-area.test.js +60 -0
- package/test/link-budget.test.js +111 -0
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -0
- package/test/look-ahead-coverage.test.js +1 -1
- package/test/manager-surface.test.js +294 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +1 -1
- package/test/memory-budget.test.js +2 -2
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +1 -1
- package/test/one-authority.test.js +45 -41
- package/test/one-budget.test.js +149 -0
- package/test/one-output-one-session.test.js +204 -0
- package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
- package/test/one-serving-output-per-height.test.js +458 -0
- package/test/orchestrator-wired.test.js +46 -45
- package/test/output-catalog.test.js +39 -0
- package/test/output-shape.test.js +4 -51
- package/test/output-spec.test.js +251 -157
- package/test/output-speed.test.js +17 -15
- package/test/outputs-being-prepared.test.js +40 -0
- package/test/packet-witness-ring.test.js +2 -2
- package/test/packet-witness.test.js +1 -1
- package/test/piece-disk-store.test.js +2 -2
- package/test/piece-from-whole-file.test.js +1 -1
- package/test/piece-lru.test.js +1 -1
- package/test/piece-reader.test.js +2 -2
- package/test/piece-store-eviction.test.js +1 -1
- package/test/piece-store-never-refuses.test.js +1 -1
- package/test/piece-store-reservations.test.js +1 -1
- package/test/piece-store-slow-disk.test.js +1 -1
- package/test/piece-tail.test.js +1 -1
- package/test/plan-host-timings.test.js +1 -1
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +4 -4
- package/test/priority-map-emptied.test.js +204 -205
- package/test/priority-map-per-output.test.js +215 -209
- package/test/priority-map.test.js +1 -1
- package/test/probe-wedge-certainty.test.js +1 -1
- package/test/produced-copy-choice.test.js +15 -60
- package/test/quality-offer.test.js +162 -0
- package/test/quality-variants.test.js +1229 -1167
- package/test/rate-control-in-the-output.test.js +180 -0
- package/test/read-survives-withdrawal.test.js +2 -2
- package/test/read-window.test.js +10 -3
- package/test/resume-warm.test.js +1 -1
- package/test/run-inputs.test.js +80 -0
- package/test/run-position-follows-published-grid.test.js +13 -11
- package/test/run-progress.test.js +65 -0
- package/test/same-height-switch.test.js +664 -0
- package/test/seek-frees-the-viewers-requests.test.js +217 -0
- package/test/seek-landing.test.js +13 -13
- package/test/seek-target-not-superseded.test.js +28 -29
- package/test/segment-closed-on-both-branches.test.js +96 -0
- package/test/segment-cut-times.test.js +1 -1
- package/test/segment-hold-supersede.test.js +28 -35
- package/test/segment-judged-by-its-own-header.test.js +221 -0
- package/test/segment-serve-wiring.test.js +130 -39
- package/test/segment-store-eviction.test.js +22 -7
- package/test/segment-store.test.js +98 -14
- package/test/segment-true-start.test.js +1 -1
- package/test/segments-are-shared.test.js +17 -16
- package/test/send-chunk-bytes.test.js +1 -1
- package/test/serving-existing-output.test.js +102 -0
- package/test/serving-output.test.js +102 -0
- package/test/shared-piece-store.test.js +1 -1
- package/test/source-file.test.js +1 -1
- package/test/stale-request-after-seek.test.js +17 -21
- package/test/startup-readings.test.js +10 -5
- package/test/subtitle-cue-framing.test.js +5 -5
- package/test/subtitle-cue-source.test.js +36 -38
- package/test/subtitle-cue-walk.test.js +11 -10
- package/test/subtitle-defaults.test.js +1 -1
- package/test/subtitle-language.test.js +4 -4
- package/test/subtitle-track-numbering.test.js +5 -4
- package/test/supply-margin.test.js +1 -1
- package/test/swarm-follows-readers.test.js +3 -3
- package/test/swarm-reach.test.js +1 -1
- package/test/swarm-selection.test.js +3 -3
- package/test/tail-duplication.test.js +1 -1
- package/test/the-channel-has-both-ends.test.js +91 -0
- package/test/the-torrent-thread-serves-bytes.test.js +107 -0
- package/test/thread-pool.test.js +3 -3
- package/test/timeline.test.js +1 -12
- package/test/torrent-cost.test.js +1 -1
- package/test/tracks-begin-together.test.js +38 -24
- package/test/tunnel-renewal.test.js +1 -1
- package/test/two-viewers-one-picture.test.js +120 -87
- package/test/upload-hurry.test.js +3 -3
- package/test/usrsctp-state.test.js +1 -1
- package/test/video-facts.test.js +1 -1
- package/test/viewer-assignments.test.js +143 -0
- package/test/viewer-outputs.test.js +60 -52
- package/test/viewer-presence.test.js +22 -25
- package/test/viewer-requests-admission.test.js +89 -0
- package/test/viewer-states.test.js +189 -202
- package/test/viewer-subtitle-subscription.test.js +1 -1
- package/test/viewer.test.js +139 -136
- package/test/viewing-generations.test.js +390 -0
- package/test/visible-rung.test.js +37 -0
- package/test/wait-ledger.test.js +1 -1
- package/test/webrtc-shim.test.js +3 -3
- package/test/wedge-certainty.test.js +2 -2
- package/test/withdraw-piece-claim.test.js +2 -2
- package/test/worker-channel.test.js +1 -31
- package/utils/client-log-file.js +151 -151
- package/scripts/read-container-index.mjs +0 -108
- package/services/container/ContainerFactory.js +0 -122
- package/services/controllers/PlaybackController.js +0 -33
- package/services/disk/DiskSpace.js +0 -150
- package/services/disk/wire.js +0 -61
- package/services/hls-session-manager.js +0 -9608
- package/services/orchestrators/SubtitleOrchestrator.js +0 -160
- package/services/output/Output.js +0 -94
- package/services/output/OutputSpec.js +0 -202
- package/services/output/index.js +0 -1
- package/services/output/ladder.js +0 -26
- package/services/produced-index.js +0 -222
- package/services/quality/EncodeCost.js +0 -555
- package/services/torrent-worker/container-tracks.js +0 -414
- package/services/viewer/link-readings.js +0 -56
- package/test/produced-index.test.js +0 -142
- /package/{services → bin}/thread-pool.js +0 -0
- /package/services/{available-share.js → encode/available-share.js} +0 -0
- /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
- /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
- /package/services/{host-load.js → encode/host-load.js} +0 -0
- /package/services/{output → encode/output}/cut-grid.js +0 -0
- /package/services/{output → encode/output}/rates.js +0 -0
- /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
- /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
- /package/services/{container → media/container}/ebml-reader.js +0 -0
- /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
- /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
- /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
- /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
- /package/services/{tracks → media/tracks}/index.js +0 -0
- /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
- /package/services/{disk → storage}/keep.js +0 -0
- /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
- /package/services/{disk → storage}/returns.js +0 -0
- /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
- /package/services/{demand → torrent/demand}/Urgency.js +0 -0
- /package/services/{demand → torrent/demand}/Window.js +0 -0
- /package/services/{demand → torrent/demand}/index.js +0 -0
- /package/services/{demand → torrent/demand}/pieces.js +0 -0
- /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
- /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
- /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
- /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
- /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
- /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
- /package/services/{registry-api.js → transport/registry-api.js} +0 -0
- /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
- /package/services/{priority → viewer}/WaitLedger.js +0 -0
package/utils/client-log-file.js
CHANGED
|
@@ -1,151 +1,151 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Where a browser's own log lands: a file beside the proxy's, per session.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS EXISTS. Both halves of every failure this project investigates live
|
|
5
|
-
* in two places at once — what the proxy did, and what the page saw — and until
|
|
6
|
-
* now the second half went to the SERVER's standard output on the droplet.
|
|
7
|
-
* Every release recreates that container and destroys it. On 2026-09-13 a
|
|
8
|
-
* viewer reported a desync and a frozen picture, and the analysis ran on half
|
|
9
|
-
* the evidence for exactly this reason.
|
|
10
|
-
*
|
|
11
|
-
* Written here instead, next to the proxy's own log, on the same durable
|
|
12
|
-
* directory (`/data` on the addon host). One file per viewing session, named so
|
|
13
|
-
* the two halves join without guessing: the session's start, then the torrent.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
18
|
-
|
|
19
|
-
/** One file may reach this before it is rotated. */
|
|
20
|
-
const MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Make a name safe for a file and short enough to read in a directory listing.
|
|
24
|
-
*
|
|
25
|
-
* Deliberately strict: a torrent name is arbitrary bytes chosen by a stranger,
|
|
26
|
-
* and it is about to become a path.
|
|
27
|
-
*
|
|
28
|
-
* @param {string} value
|
|
29
|
-
* @param {number} max
|
|
30
|
-
* @returns {string}
|
|
31
|
-
*/
|
|
32
|
-
function safeName(value, max) {
|
|
33
|
-
const cleaned = String(value ?? "")
|
|
34
|
-
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
35
|
-
.replace(/-+/g, "-")
|
|
36
|
-
.replace(/^[-.]+|[-.]+$/g, "");
|
|
37
|
-
return cleaned.slice(0, max);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* `2026-09-13T16:05:24.123Z` → `20260913-160524`, which sorts and reads.
|
|
42
|
-
*
|
|
43
|
-
* @param {string} iso
|
|
44
|
-
* @returns {string}
|
|
45
|
-
*/
|
|
46
|
-
function stamp(iso) {
|
|
47
|
-
const when = new Date(iso);
|
|
48
|
-
const at = Number.isNaN(when.getTime()) ? new Date() : when;
|
|
49
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
50
|
-
return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}` +
|
|
51
|
-
`-${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* A sink that writes one session's browser lines to its own file.
|
|
56
|
-
*
|
|
57
|
-
* @param {string} proxyLogPath - The proxy's own log file; its directory is
|
|
58
|
-
* used, so the two halves are always side by side.
|
|
59
|
-
* @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
|
|
60
|
-
*/
|
|
61
|
-
export function createClientLogFiles(proxyLogPath) {
|
|
62
|
-
const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
|
|
63
|
-
? dirname(proxyLogPath)
|
|
64
|
-
: "";
|
|
65
|
-
/** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
|
|
66
|
-
const open = new Map();
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The file for this session, opened on first use.
|
|
70
|
-
*
|
|
71
|
-
* Keyed by the session's own id rather than by the name, because the name
|
|
72
|
-
* gains the torrent the moment one is chosen and the earlier lines — the
|
|
73
|
-
* page opening, the proxy being chosen, a connection failing — belong to the
|
|
74
|
-
* same session and must not start a second file.
|
|
75
|
-
*
|
|
76
|
-
* @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
|
|
77
|
-
* @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
|
|
78
|
-
*/
|
|
79
|
-
function fileFor(session) {
|
|
80
|
-
if (!directory) return null;
|
|
81
|
-
const key = session.sessionId;
|
|
82
|
-
const existing = open.get(key);
|
|
83
|
-
if (existing) return existing;
|
|
84
|
-
const film = session.torrentName
|
|
85
|
-
? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
|
|
86
|
-
: "no-torrent-yet";
|
|
87
|
-
const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
|
|
88
|
-
try {
|
|
89
|
-
mkdirSync(directory, { recursive: true });
|
|
90
|
-
const record = {
|
|
91
|
-
stream: createWriteStream(path, { flags: "a" }),
|
|
92
|
-
path,
|
|
93
|
-
bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
|
|
94
|
-
};
|
|
95
|
-
// A file that stops being writable must not take the proxy down with it,
|
|
96
|
-
// and must not be retried on every line.
|
|
97
|
-
record.stream.on("error", () => open.delete(key));
|
|
98
|
-
open.set(key, record);
|
|
99
|
-
return record;
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
write(session, lines) {
|
|
107
|
-
const record = fileFor(session);
|
|
108
|
-
if (!record) return;
|
|
109
|
-
for (const line of lines) {
|
|
110
|
-
const text = `${line}\n`;
|
|
111
|
-
record.bytes += Buffer.byteLength(text);
|
|
112
|
-
record.stream.write(text);
|
|
113
|
-
}
|
|
114
|
-
if (record.bytes >= MAX_BYTES) {
|
|
115
|
-
try {
|
|
116
|
-
record.stream.end(() => {
|
|
117
|
-
try {
|
|
118
|
-
renameSync(record.path, `${record.path}.1`);
|
|
119
|
-
} catch {
|
|
120
|
-
// Renaming a file that has gone leaves nothing to do.
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
} catch {
|
|
124
|
-
// A rotation that cannot happen leaves the file growing, which is
|
|
125
|
-
// better than losing the session's log to an exception.
|
|
126
|
-
}
|
|
127
|
-
open.delete(session.sessionId);
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
/**
|
|
131
|
-
* Finish every open file.
|
|
132
|
-
*
|
|
133
|
-
* Awaitable, and that is not a convenience: a write stream opens and
|
|
134
|
-
* flushes asynchronously, so a process that ends without waiting loses the
|
|
135
|
-
* last lines of every session — which are the ones a crash is explained by.
|
|
136
|
-
*
|
|
137
|
-
* @returns {Promise<void>}
|
|
138
|
-
*/
|
|
139
|
-
async close() {
|
|
140
|
-
const ending = [...open.values()].map((record) => new Promise((resolve) => {
|
|
141
|
-
try {
|
|
142
|
-
record.stream.end(resolve);
|
|
143
|
-
} catch {
|
|
144
|
-
resolve(undefined);
|
|
145
|
-
}
|
|
146
|
-
}));
|
|
147
|
-
open.clear();
|
|
148
|
-
await Promise.all(ending);
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Where a browser's own log lands: a file beside the proxy's, per session.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Both halves of every failure this project investigates live
|
|
5
|
+
* in two places at once — what the proxy did, and what the page saw — and until
|
|
6
|
+
* now the second half went to the SERVER's standard output on the droplet.
|
|
7
|
+
* Every release recreates that container and destroys it. On 2026-09-13 a
|
|
8
|
+
* viewer reported a desync and a frozen picture, and the analysis ran on half
|
|
9
|
+
* the evidence for exactly this reason.
|
|
10
|
+
*
|
|
11
|
+
* Written here instead, next to the proxy's own log, on the same durable
|
|
12
|
+
* directory (`/data` on the addon host). One file per viewing session, named so
|
|
13
|
+
* the two halves join without guessing: the session's start, then the torrent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** One file may reach this before it is rotated. */
|
|
20
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Make a name safe for a file and short enough to read in a directory listing.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately strict: a torrent name is arbitrary bytes chosen by a stranger,
|
|
26
|
+
* and it is about to become a path.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} value
|
|
29
|
+
* @param {number} max
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function safeName(value, max) {
|
|
33
|
+
const cleaned = String(value ?? "")
|
|
34
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
35
|
+
.replace(/-+/g, "-")
|
|
36
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
37
|
+
return cleaned.slice(0, max);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `2026-09-13T16:05:24.123Z` → `20260913-160524`, which sorts and reads.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} iso
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function stamp(iso) {
|
|
47
|
+
const when = new Date(iso);
|
|
48
|
+
const at = Number.isNaN(when.getTime()) ? new Date() : when;
|
|
49
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
50
|
+
return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}` +
|
|
51
|
+
`-${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A sink that writes one session's browser lines to its own file.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} proxyLogPath - The proxy's own log file; its directory is
|
|
58
|
+
* used, so the two halves are always side by side.
|
|
59
|
+
* @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
|
|
60
|
+
*/
|
|
61
|
+
export function createClientLogFiles(proxyLogPath) {
|
|
62
|
+
const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
|
|
63
|
+
? dirname(proxyLogPath)
|
|
64
|
+
: "";
|
|
65
|
+
/** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
|
|
66
|
+
const open = new Map();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The file for this session, opened on first use.
|
|
70
|
+
*
|
|
71
|
+
* Keyed by the session's own id rather than by the name, because the name
|
|
72
|
+
* gains the torrent the moment one is chosen and the earlier lines — the
|
|
73
|
+
* page opening, the proxy being chosen, a connection failing — belong to the
|
|
74
|
+
* same session and must not start a second file.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
|
|
77
|
+
* @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
|
|
78
|
+
*/
|
|
79
|
+
function fileFor(session) {
|
|
80
|
+
if (!directory) return null;
|
|
81
|
+
const key = session.sessionId;
|
|
82
|
+
const existing = open.get(key);
|
|
83
|
+
if (existing) return existing;
|
|
84
|
+
const film = session.torrentName
|
|
85
|
+
? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
|
|
86
|
+
: "no-torrent-yet";
|
|
87
|
+
const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(directory, { recursive: true });
|
|
90
|
+
const record = {
|
|
91
|
+
stream: createWriteStream(path, { flags: "a" }),
|
|
92
|
+
path,
|
|
93
|
+
bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
|
|
94
|
+
};
|
|
95
|
+
// A file that stops being writable must not take the proxy down with it,
|
|
96
|
+
// and must not be retried on every line.
|
|
97
|
+
record.stream.on("error", () => open.delete(key));
|
|
98
|
+
open.set(key, record);
|
|
99
|
+
return record;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
write(session, lines) {
|
|
107
|
+
const record = fileFor(session);
|
|
108
|
+
if (!record) return;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const text = `${line}\n`;
|
|
111
|
+
record.bytes += Buffer.byteLength(text);
|
|
112
|
+
record.stream.write(text);
|
|
113
|
+
}
|
|
114
|
+
if (record.bytes >= MAX_BYTES) {
|
|
115
|
+
try {
|
|
116
|
+
record.stream.end(() => {
|
|
117
|
+
try {
|
|
118
|
+
renameSync(record.path, `${record.path}.1`);
|
|
119
|
+
} catch {
|
|
120
|
+
// Renaming a file that has gone leaves nothing to do.
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
// A rotation that cannot happen leaves the file growing, which is
|
|
125
|
+
// better than losing the session's log to an exception.
|
|
126
|
+
}
|
|
127
|
+
open.delete(session.sessionId);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
/**
|
|
131
|
+
* Finish every open file.
|
|
132
|
+
*
|
|
133
|
+
* Awaitable, and that is not a convenience: a write stream opens and
|
|
134
|
+
* flushes asynchronously, so a process that ends without waiting loses the
|
|
135
|
+
* last lines of every session — which are the ones a crash is explained by.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<void>}
|
|
138
|
+
*/
|
|
139
|
+
async close() {
|
|
140
|
+
const ending = [...open.values()].map((record) => new Promise((resolve) => {
|
|
141
|
+
try {
|
|
142
|
+
record.stream.end(resolve);
|
|
143
|
+
} catch {
|
|
144
|
+
resolve(undefined);
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
open.clear();
|
|
148
|
+
await Promise.all(ending);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What this proxy would cut a file at, read from a real torrent.
|
|
3
|
-
*
|
|
4
|
-
* The keyframe readers take two short ranged reads, so the index of a 7 GB film
|
|
5
|
-
* can be examined over the swarm in seconds without downloading it. Run against
|
|
6
|
-
* a `.torrent` file to see what the container's own table says, per track, and
|
|
7
|
-
* what the reader hands to the cut list.
|
|
8
|
-
*
|
|
9
|
-
* node scripts/read-container-index.mjs path/to/film.torrent [more.torrent …]
|
|
10
|
-
*
|
|
11
|
-
* Developer tool: nothing here runs in a session, and no viewer is involved.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import fs from "node:fs";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
import MemoryChunkStore from "memory-chunk-store";
|
|
17
|
-
import WebTorrent from "webtorrent";
|
|
18
|
-
import { readMatroskaKeyframeTimes } from "../services/container-index/matroska.js";
|
|
19
|
-
import { readMp4KeyframeTimes } from "../services/container-index/mp4.js";
|
|
20
|
-
import { readAviKeyframeTimes } from "../services/container-index/avi.js";
|
|
21
|
-
|
|
22
|
-
const torrents = process.argv.slice(2);
|
|
23
|
-
if (torrents.length === 0) {
|
|
24
|
-
console.error("usage: node scripts/read-container-index.mjs <file.torrent> [...]");
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Collect one byte range of a torrent file.
|
|
30
|
-
*
|
|
31
|
-
* @param {import("webtorrent").TorrentFile} file
|
|
32
|
-
* @param {number} start
|
|
33
|
-
* @param {number} end - Inclusive.
|
|
34
|
-
* @returns {Promise<Buffer>}
|
|
35
|
-
*/
|
|
36
|
-
function readRangeOf(file, start, end) {
|
|
37
|
-
return new Promise((resolve, reject) => {
|
|
38
|
-
const chunks = [];
|
|
39
|
-
const stream = file.createReadStream({ start, end });
|
|
40
|
-
stream.on("data", (chunk) => chunks.push(chunk));
|
|
41
|
-
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
42
|
-
stream.on("error", reject);
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Say what a set of times looks like: how many, where it starts, how far apart.
|
|
48
|
-
*
|
|
49
|
-
* @param {string} label
|
|
50
|
-
* @param {number[] | null} times
|
|
51
|
-
* @returns {void}
|
|
52
|
-
*/
|
|
53
|
-
function describe(label, times) {
|
|
54
|
-
if (!times || times.length === 0) {
|
|
55
|
-
console.log(`${label}: no index`);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const gaps = times.slice(1).map((time, index) => time - times[index]).sort((left, right) => left - right);
|
|
59
|
-
const median = gaps.length > 0 ? gaps[Math.floor(gaps.length / 2)] : 0;
|
|
60
|
-
console.log(
|
|
61
|
-
`${label}: ${times.length} times, first ${times.slice(0, 4).map((time) => time.toFixed(3)).join(" ")}, ` +
|
|
62
|
-
`median gap ${median.toFixed(3)}s, last ${times[times.length - 1].toFixed(3)}`
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
for (const torrentPath of torrents) {
|
|
67
|
-
// uTP is off: binding its socket needs a permission this does not have on
|
|
68
|
-
// every developer machine, and TCP peers are enough to read an index.
|
|
69
|
-
const client = new WebTorrent({ utp: false });
|
|
70
|
-
console.log(`\n=== ${path.basename(torrentPath)} ===`);
|
|
71
|
-
await new Promise((resolve) => {
|
|
72
|
-
// In memory, deliberately. The file store creates the whole file on disk at
|
|
73
|
-
// its full length before a byte of it is wanted — reading the index of four
|
|
74
|
-
// films that way filled 34 GB and then failed with the disk full. Only two
|
|
75
|
-
// short ranges are ever read here, so they can simply be held.
|
|
76
|
-
client.add(fs.readFileSync(torrentPath), { store: MemoryChunkStore }, async (torrent) => {
|
|
77
|
-
try {
|
|
78
|
-
// Nothing is wanted up front; the ranged reads below select what they need.
|
|
79
|
-
torrent.deselect(0, torrent.pieces.length - 1, 0);
|
|
80
|
-
const file = torrent.files
|
|
81
|
-
.filter((candidate) => /\.(mkv|mp4|avi)$/i.test(candidate.name))
|
|
82
|
-
.sort((left, right) => right.length - left.length)[0];
|
|
83
|
-
if (!file) {
|
|
84
|
-
console.log("no video file in this torrent");
|
|
85
|
-
return resolve();
|
|
86
|
-
}
|
|
87
|
-
console.log(`file: ${file.name} (${(file.length / 1e9).toFixed(2)} GB)`);
|
|
88
|
-
const readRange = async (start, end) => {
|
|
89
|
-
const last = Math.min(end, file.length - 1);
|
|
90
|
-
return start > last ? null : readRangeOf(file, start, last);
|
|
91
|
-
};
|
|
92
|
-
if (/\.mkv$/i.test(file.name)) {
|
|
93
|
-
describe("matroska", await readMatroskaKeyframeTimes(readRange, file.length));
|
|
94
|
-
} else if (/\.mp4$/i.test(file.name)) {
|
|
95
|
-
describe("mp4", await readMp4KeyframeTimes(readRange, file.length));
|
|
96
|
-
} else {
|
|
97
|
-
describe("avi", await readAviKeyframeTimes(readRange, file.length));
|
|
98
|
-
}
|
|
99
|
-
} catch (error) {
|
|
100
|
-
console.log(`failed: ${error?.message ?? error}`);
|
|
101
|
-
} finally {
|
|
102
|
-
resolve();
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
await new Promise((done) => client.destroy(done));
|
|
107
|
-
}
|
|
108
|
-
process.exit(0);
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Container factory — detects format and returns the precise Container subclass.
|
|
3
|
-
*
|
|
4
|
-
* Sniffs first 16 bytes (same as container-index/index.js) and instantiates
|
|
5
|
-
* MatroskaContainer / Mp4Container / AviContainer. Falls back to null (unknown).
|
|
6
|
-
* Orchestrators depend on this, not on concrete constructors.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { MatroskaContainer } from "./MatroskaContainer.js";
|
|
10
|
-
import { Mp4Container } from "./Mp4Container.js";
|
|
11
|
-
import { AviContainer } from "./AviContainer.js";
|
|
12
|
-
import { logger } from "../../utils/logger.js";
|
|
13
|
-
|
|
14
|
-
const SNIFF_BYTES = 16;
|
|
15
|
-
|
|
16
|
-
export class ContainerFactory {
|
|
17
|
-
/**
|
|
18
|
-
* The container these bytes are, built over them.
|
|
19
|
-
*
|
|
20
|
-
* `params` is passed through whole, so a caller with a torrent's two readers
|
|
21
|
-
* gets a container that has both — see {@link Container}'s constructor.
|
|
22
|
-
*
|
|
23
|
-
* @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string, readHeld?: Function, isHeld?: Function }} params
|
|
24
|
-
* @returns {Promise<import("./Container.js").Container|null>}
|
|
25
|
-
*/
|
|
26
|
-
static async create(params) {
|
|
27
|
-
const { readRange, fileSize } = params;
|
|
28
|
-
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) return null;
|
|
29
|
-
const head = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
|
|
30
|
-
if (!head) return null;
|
|
31
|
-
if (MatroskaContainer.detect(head)) return new MatroskaContainer(params);
|
|
32
|
-
if (Mp4Container.detect(head)) return new Mp4Container(params);
|
|
33
|
-
if (AviContainer.detect(head)) return new AviContainer(params);
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The container a file NAME suggests, for the moment the bytes cannot be
|
|
39
|
-
* sniffed.
|
|
40
|
-
*
|
|
41
|
-
* The head of a file nobody has opened is not downloaded, and the cue walk
|
|
42
|
-
* asks the swarm for nothing — so on that one path the name is all there is.
|
|
43
|
-
* It is a fallback and never a preference: the bytes decide wherever they can
|
|
44
|
-
* be read, because a name is what somebody typed and a header is what the
|
|
45
|
-
* muxer wrote.
|
|
46
|
-
*
|
|
47
|
-
* @param {string} name
|
|
48
|
-
* @returns {typeof MatroskaContainer | typeof Mp4Container | null}
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Where a file's real keyframes are, read from the container's own tables
|
|
53
|
-
* rather than by scanning the media.
|
|
54
|
-
*
|
|
55
|
-
* The problem it solves: on the video-COPY path ffmpeg can only cut segments
|
|
56
|
-
* at the source's existing keyframes. A playlist declaring an even grid
|
|
57
|
-
* instead is false, and players punish it — either walking the whole file to
|
|
58
|
-
* rebuild the timeline, or presenting audio with no picture because a segment
|
|
59
|
-
* begins with nothing decodable (both seen in the field 2026-08-02; the file
|
|
60
|
-
* measured had 10.43 s keyframe spacing against our declared 4 s).
|
|
61
|
-
*
|
|
62
|
-
* Scanning is not an option: the file is served from a torrent, and a full
|
|
63
|
-
* packet scan of 5.5 GB found 77 keyframes in 45 s without finishing.
|
|
64
|
-
* Containers already store the table, and a couple of point reads get it —
|
|
65
|
-
* 16 KB and 0.8 s for 570 keyframes on that same file.
|
|
66
|
-
*
|
|
67
|
-
* This is the sniff plus the container's own reading, which is why it lives
|
|
68
|
-
* on the factory: doing it anywhere else meant a third place that decided
|
|
69
|
-
* what a file is.
|
|
70
|
-
*
|
|
71
|
-
* @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string }} params
|
|
72
|
-
* @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>}
|
|
73
|
-
* Ascending seconds, or null times where this file has no readable index —
|
|
74
|
-
* the caller must then not claim to know the grid. The format is which
|
|
75
|
-
* container answered, reported whether or not it produced anything: how
|
|
76
|
-
* often an index disagrees with its own file is a question about the
|
|
77
|
-
* CONTAINER, and a measurement that does not say which one cannot answer it.
|
|
78
|
-
*/
|
|
79
|
-
static async readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
80
|
-
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
81
|
-
return { times: null, format: "unknown", tolerance: 0 };
|
|
82
|
-
}
|
|
83
|
-
const startedAt = Date.now();
|
|
84
|
-
let container = null;
|
|
85
|
-
try {
|
|
86
|
-
container = await ContainerFactory.create({ readRange, fileSize, label });
|
|
87
|
-
} catch (error) {
|
|
88
|
-
logger.warn(`container-index: failed to read "${label}": ${error?.message ?? error}`);
|
|
89
|
-
return { times: null, format: "unrecognised", tolerance: 0 };
|
|
90
|
-
}
|
|
91
|
-
if (!container) {
|
|
92
|
-
return { times: null, format: "unrecognised", tolerance: 0 };
|
|
93
|
-
}
|
|
94
|
-
const format = container.formatName;
|
|
95
|
-
let index = null;
|
|
96
|
-
try {
|
|
97
|
-
index = await container.readKeyframeIndex();
|
|
98
|
-
} catch (error) {
|
|
99
|
-
// A malformed or partly-downloaded index must never take playback down —
|
|
100
|
-
// it only means the grid is unknown, which the caller already handles.
|
|
101
|
-
logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
|
|
102
|
-
return { times: null, format, tolerance: 0 };
|
|
103
|
-
}
|
|
104
|
-
const elapsedMs = Date.now() - startedAt;
|
|
105
|
-
const times = index && Array.isArray(index.times) ? index.times : null;
|
|
106
|
-
if (times) {
|
|
107
|
-
logger.info(
|
|
108
|
-
`container-index: ${times.length} keyframes from the ${format} index in ${elapsedMs}ms for "${label}"`
|
|
109
|
-
);
|
|
110
|
-
} else {
|
|
111
|
-
logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
|
|
112
|
-
}
|
|
113
|
-
return { times, format, tolerance: Number.isFinite(index?.tolerance) ? index.tolerance : 0 };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
static byName(name) {
|
|
117
|
-
const text = String(name ?? "");
|
|
118
|
-
if (/\.(mp4|m4v|m4a)$/i.test(text)) return Mp4Container;
|
|
119
|
-
if (/\.(mkv|mka|webm)$/i.test(text)) return MatroskaContainer;
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Playback controller — interface layer over playback planning.
|
|
3
|
-
*
|
|
4
|
-
* Thin adapter between HTTP/routes and the application orchestrators.
|
|
5
|
-
* Does not parse containers itself — delegates to containerOrchestrator and
|
|
6
|
-
* the existing playback-planner service. Exists so routes depend on a
|
|
7
|
-
* controller contract, not on service internals.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { containerOrchestrator } from "../orchestrators/ContainerOrchestrator.js";
|
|
11
|
-
|
|
12
|
-
export class PlaybackController {
|
|
13
|
-
/**
|
|
14
|
-
* @param {object} deps
|
|
15
|
-
* @param {import("../torrent-pool.js").TorrentPool} deps.torrentPool
|
|
16
|
-
* @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} deps.sourceRegistry
|
|
17
|
-
* @param {string} deps.ffmpegBin
|
|
18
|
-
* @param {string} deps.localBaseUrl
|
|
19
|
-
* @param {ReturnType<import("../playback-planner.js").createPlaybackPlanner>} deps.playbackPlanner
|
|
20
|
-
*/
|
|
21
|
-
constructor({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner }) {
|
|
22
|
-
this.torrentPool = torrentPool;
|
|
23
|
-
this.sourceRegistry = sourceRegistry;
|
|
24
|
-
this.ffmpegBin = ffmpegBin;
|
|
25
|
-
this.localBaseUrl = localBaseUrl;
|
|
26
|
-
this.playbackPlanner = playbackPlanner;
|
|
27
|
-
this.containers = containerOrchestrator;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async getPlan(params) {
|
|
31
|
-
return this.playbackPlanner.getPlan(params);
|
|
32
|
-
}
|
|
33
|
-
}
|