@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
|
@@ -1,111 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file
|
|
3
|
-
*
|
|
2
|
+
* @file Reading the subtitle cues a file already holds, and the plan of its
|
|
3
|
+
* subtitle tracks.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* they are needed. A region nobody has watched has no cues, and that is
|
|
11
|
-
* correct — there is nobody to show them to.
|
|
5
|
+
* It walks ONLY what is downloaded: switching subtitles on must never pull
|
|
6
|
+
* bytes the viewer is not waiting for. The head and the Cues table are the one
|
|
7
|
+
* exception and are fetched — they are kilobytes, they are needed before
|
|
8
|
+
* anything can be offered, and the codec probe has already pulled the head for
|
|
9
|
+
* every file that plays.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* **The torrent is not here, and that is the point.** This layer is handed a
|
|
12
|
+
* `HeldFile`: a name, a length, which ranges are downloaded whole, and how to
|
|
13
|
+
* read one of them without fetching. Piece length, file offsets and the
|
|
14
|
+
* bitfield are the torrent's words and stay in the torrent's thread
|
|
15
|
+
* (`torrent/worker/held-bytes.js`). Until 2026-09-15 this whole file lived
|
|
16
|
+
* there, which put a container parse — the media layer's work — in the thread
|
|
17
|
+
* that owns the swarm, and made every answer something to carry back across a
|
|
18
|
+
* channel.
|
|
19
|
+
*
|
|
20
|
+
* What is genuinely this file's own, and travels with it: the found-order
|
|
21
|
+
* cursor a browser follows, the per-file state, and one walk of a file at a
|
|
22
|
+
* time.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
|
-
import { ContainerFactory } from "
|
|
21
|
-
import { TextSubtitleTrack } from "
|
|
22
|
-
import { detectLanguage } from "
|
|
25
|
+
import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
26
|
+
import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
|
|
27
|
+
import { detectLanguage } from "./tracks/language-detect.js";
|
|
23
28
|
import { logger } from "../../utils/logger.js";
|
|
24
29
|
|
|
25
|
-
/** How long a read of already-held bytes may take before it is given up. */
|
|
26
|
-
const READ_ABANDON_MS = 30_000;
|
|
27
30
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
31
|
+
/**
|
|
32
|
+
* One file of one torrent, as much of it as this layer is allowed to know.
|
|
33
|
+
*
|
|
34
|
+
* Four plain values and two functions: what it is called, how long it is, which
|
|
35
|
+
* of it is downloaded whole, and how to read a range of that without asking the
|
|
36
|
+
* swarm for anything. No torrent, no bitfield, no piece length — those are the
|
|
37
|
+
* torrent's words, and turning them into these is its job.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {object} HeldFile
|
|
40
|
+
* @property {string} sourceKey
|
|
41
|
+
* @property {number} fileIndex
|
|
42
|
+
* @property {string} name
|
|
43
|
+
* @property {number} length
|
|
44
|
+
* @property {() => Promise<Array<[number, number]>>} heldRanges - Ascending,
|
|
45
|
+
* non-overlapping, inclusive offsets within the file.
|
|
46
|
+
* @property {(start: number, end: number) => Promise<Buffer | null>} readHeld -
|
|
47
|
+
* Null where those bytes are not there, which is never fetched.
|
|
48
|
+
*/
|
|
30
49
|
|
|
31
50
|
/**
|
|
32
|
-
* Whether
|
|
51
|
+
* Whether a range falls entirely inside one of the held runs.
|
|
33
52
|
*
|
|
34
|
-
* @param {
|
|
35
|
-
* @param {
|
|
36
|
-
* @param {number} start - Offset within the FILE.
|
|
53
|
+
* @param {Array<[number, number]> | null} ranges
|
|
54
|
+
* @param {number} start
|
|
37
55
|
* @param {number} end - Inclusive.
|
|
38
56
|
* @returns {boolean}
|
|
39
57
|
*/
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
const offset = Number(file?.offset) || 0;
|
|
43
|
-
if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
|
|
58
|
+
function rangeHolds(ranges, start, end) {
|
|
59
|
+
if (!Array.isArray(ranges) || !(end >= start)) {
|
|
44
60
|
return false;
|
|
45
61
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!torrent.bitfield.get(index)) {
|
|
50
|
-
return false;
|
|
62
|
+
for (const [from, to] of ranges) {
|
|
63
|
+
if (start >= from && end <= to) {
|
|
64
|
+
return true;
|
|
51
65
|
}
|
|
52
66
|
}
|
|
53
|
-
return
|
|
67
|
+
return false;
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* @param {object} file
|
|
61
|
-
* @param {number} start
|
|
62
|
-
* @param {number} end - Inclusive.
|
|
63
|
-
* @returns {Promise<Buffer | null>}
|
|
64
|
-
*/
|
|
65
|
-
function readHeld(file, start, end) {
|
|
66
|
-
return new Promise((resolve) => {
|
|
67
|
-
const chunks = [];
|
|
68
|
-
let stream;
|
|
69
|
-
try {
|
|
70
|
-
stream = file.createReadStream({ start, end });
|
|
71
|
-
} catch {
|
|
72
|
-
resolve(null);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
let settled = false;
|
|
76
|
-
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
77
|
-
let abandon = null;
|
|
78
|
-
const settle = (value) => {
|
|
79
|
-
if (settled) {
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
settled = true;
|
|
83
|
-
if (abandon !== null) {
|
|
84
|
-
clearTimeout(abandon);
|
|
85
|
-
}
|
|
86
|
-
if (value === null) {
|
|
87
|
-
stream.destroy?.();
|
|
88
|
-
}
|
|
89
|
-
resolve(value);
|
|
90
|
-
};
|
|
91
|
-
// A read of bytes the torrent already holds either answers or it does not.
|
|
92
|
-
// This is not a measurement of anything and no figure is derived from it:
|
|
93
|
-
// it is the point past which such a read is presumed lost, so that one
|
|
94
|
-
// stream which never ends cannot hold this file's walk — and with it the
|
|
95
|
-
// browser's own request for its subtitles — for the rest of the session.
|
|
96
|
-
abandon = setTimeout(() => {
|
|
97
|
-
logger.info(
|
|
98
|
-
`subtitles: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
|
|
99
|
-
`did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
|
|
100
|
-
);
|
|
101
|
-
settle(null);
|
|
102
|
-
}, READ_ABANDON_MS);
|
|
103
|
-
abandon.unref?.();
|
|
104
|
-
stream.on("data", (chunk) => chunks.push(chunk));
|
|
105
|
-
stream.on("end", () => settle(Buffer.concat(chunks)));
|
|
106
|
-
stream.on("error", () => settle(null));
|
|
107
|
-
});
|
|
108
|
-
}
|
|
70
|
+
/** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
|
|
71
|
+
const byFile = new Map();
|
|
72
|
+
|
|
73
|
+
|
|
109
74
|
|
|
110
75
|
/**
|
|
111
76
|
* A container over one file of a torrent, told how to read it.
|
|
@@ -123,28 +88,31 @@ function readHeld(file, start, end) {
|
|
|
123
88
|
* different evidence that have to agree.
|
|
124
89
|
*
|
|
125
90
|
* @param {object} state - This file's state; the container is kept on it.
|
|
126
|
-
* @param {
|
|
127
|
-
* @
|
|
128
|
-
* @returns {Promise<import("../container/Container.js").Container | null>}
|
|
91
|
+
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
92
|
+
* @returns {Promise<import("./container/Container.js").Container | null>}
|
|
129
93
|
*/
|
|
130
|
-
async function containerOver(state,
|
|
94
|
+
async function containerOver(state, source) {
|
|
131
95
|
if (state.container !== null) {
|
|
132
96
|
return state.container;
|
|
133
97
|
}
|
|
134
|
-
const
|
|
98
|
+
const last = Number(source.length) - 1;
|
|
99
|
+
const held = async (start, end) => source.readHeld(start, Math.min(end, last));
|
|
135
100
|
const params = {
|
|
136
101
|
readRange: held,
|
|
137
102
|
readHeld: held,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
103
|
+
// Answered from the list of ranges this file has downloaded WHOLE, taken
|
|
104
|
+
// once per pass rather than asked per cluster: a pass asks about every
|
|
105
|
+
// cluster of the file, and the torrent that knows is on the other thread.
|
|
106
|
+
isHeld: (start, end) => rangeHolds(state.held, start, Math.min(end, last)),
|
|
107
|
+
fileSize: source.length,
|
|
108
|
+
label: String(source.name ?? "")
|
|
141
109
|
};
|
|
142
110
|
const sniffed = await ContainerFactory.create(params);
|
|
143
111
|
if (sniffed) {
|
|
144
112
|
state.container = sniffed;
|
|
145
113
|
return state.container;
|
|
146
114
|
}
|
|
147
|
-
const ByName = ContainerFactory.byName(
|
|
115
|
+
const ByName = ContainerFactory.byName(source.name);
|
|
148
116
|
state.container = ByName ? new ByName(params) : null;
|
|
149
117
|
return state.container;
|
|
150
118
|
}
|
|
@@ -156,12 +124,11 @@ async function containerOver(state, torrent, file) {
|
|
|
156
124
|
* missing — they are kilobytes, they are needed before anything can be offered,
|
|
157
125
|
* and the codec probe has already pulled the head for every file that plays.
|
|
158
126
|
*
|
|
159
|
-
* @param {
|
|
160
|
-
* @param {number} fileIndex
|
|
127
|
+
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
161
128
|
* @param {string} key - `sourceKey:fileIndex`.
|
|
162
129
|
* @returns {Promise<object | null>}
|
|
163
130
|
*/
|
|
164
|
-
async function planFor(
|
|
131
|
+
async function planFor(source, key) {
|
|
165
132
|
const state = stateFor(key);
|
|
166
133
|
if (state.plan !== null) {
|
|
167
134
|
return state.plan;
|
|
@@ -170,7 +137,7 @@ async function planFor(torrent, fileIndex, key) {
|
|
|
170
137
|
// callers arriving together would both make them. One promise, awaited by
|
|
171
138
|
// whoever asks while it is in flight.
|
|
172
139
|
if (!state.planPromise) {
|
|
173
|
-
state.planPromise = readPlan(
|
|
140
|
+
state.planPromise = readPlan(source, state).finally(() => {
|
|
174
141
|
state.planPromise = null;
|
|
175
142
|
});
|
|
176
143
|
}
|
|
@@ -201,6 +168,11 @@ function stateFor(key) {
|
|
|
201
168
|
// — a `moov` box is tens of megabytes off a torrent — so building a fresh
|
|
202
169
|
// one per call would throw that away on every request.
|
|
203
170
|
container: null,
|
|
171
|
+
// Which byte ranges of this file are downloaded WHOLE, as of the start of
|
|
172
|
+
// the pass now running. Taken once and read many times: a pass asks about
|
|
173
|
+
// every cluster of the file, and what knows the answer is on the other
|
|
174
|
+
// thread.
|
|
175
|
+
held: null,
|
|
204
176
|
harvested: new Map(),
|
|
205
177
|
cues: new Map(),
|
|
206
178
|
seq: new Map(),
|
|
@@ -246,23 +218,22 @@ function serialize(state, work) {
|
|
|
246
218
|
* holding them are. Called once per file; see `planFor`.
|
|
247
219
|
*
|
|
248
220
|
* @param {object} torrent
|
|
249
|
-
* @param {number} fileIndex
|
|
250
221
|
* @param {object} state
|
|
251
222
|
* @returns {Promise<object>}
|
|
252
223
|
*/
|
|
253
|
-
async function readPlan(
|
|
254
|
-
const file = torrent?.files?.[fileIndex];
|
|
224
|
+
async function readPlan(source, state) {
|
|
255
225
|
// `declared` is what the container itself says about its subtitle tracks, in
|
|
256
226
|
// its own order. Empty means the container said nothing — which is a real
|
|
257
227
|
// answer and not a missing one: nothing is then shown unasked. An MP4 has no
|
|
258
228
|
// element that means "show this subtitle track by default", so it declares
|
|
259
229
|
// nothing however many tracks it carries.
|
|
260
230
|
const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
261
|
-
if (!
|
|
231
|
+
if (!source || !(Number(source.length) > 0)) {
|
|
262
232
|
state.plan = empty;
|
|
263
233
|
return state.plan;
|
|
264
234
|
}
|
|
265
|
-
|
|
235
|
+
state.held = await source.heldRanges();
|
|
236
|
+
const container = await containerOver(state, source);
|
|
266
237
|
if (!container) {
|
|
267
238
|
state.plan = empty;
|
|
268
239
|
return state.plan;
|
|
@@ -271,7 +242,7 @@ async function readPlan(torrent, fileIndex, state) {
|
|
|
271
242
|
state.plan = plan ?? empty;
|
|
272
243
|
if (state.plan.tracks.length > 0) {
|
|
273
244
|
logger.info(
|
|
274
|
-
`subtitles: "${String(
|
|
245
|
+
`subtitles: "${String(source.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
|
|
275
246
|
`of ${state.plan.declared.length} declared — ` +
|
|
276
247
|
state.plan.tracks
|
|
277
248
|
// `s:N` is the number the browser names (ffmpeg's own), and it differs
|
|
@@ -318,47 +289,47 @@ function nextSeq(state, trackNumber) {
|
|
|
318
289
|
* @param {number} trackNumber
|
|
319
290
|
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
320
291
|
*/
|
|
321
|
-
export async function cuesHeldFor(
|
|
322
|
-
const key = `${sourceKey}:${fileIndex}`;
|
|
323
|
-
// A
|
|
324
|
-
// "not
|
|
292
|
+
export async function cuesHeldFor(source, trackNumber) {
|
|
293
|
+
const key = `${source.sourceKey}:${source.fileIndex}`;
|
|
294
|
+
// A source that cannot say which of itself is downloaded makes every range
|
|
295
|
+
// read as "not there", so the walk reads nothing and returns an empty list —
|
|
325
296
|
// which is also what a file with no cues yet returns, and that is how this
|
|
326
297
|
// went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
|
|
327
|
-
// the browser served `WEBVTT` and nothing else).
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
|
|
298
|
+
// the browser served `WEBVTT` and nothing else). It is not repairable here,
|
|
299
|
+
// so it is said rather than swallowed.
|
|
300
|
+
if (typeof source.heldRanges !== "function" || typeof source.readHeld !== "function") {
|
|
331
301
|
logger.warn(
|
|
332
|
-
`subtitles: asked for cues of "${String(
|
|
333
|
-
"on a
|
|
302
|
+
`subtitles: asked for cues of "${String(source.name ?? key).slice(0, 40)}" ` +
|
|
303
|
+
"on a source that cannot say which of it is downloaded — no cluster can be read, " +
|
|
334
304
|
"and the answer would be an empty document indistinguishable from a file with no cues"
|
|
335
305
|
);
|
|
336
306
|
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
337
307
|
}
|
|
338
|
-
const plan = await planFor(
|
|
308
|
+
const plan = await planFor(source, key);
|
|
339
309
|
const state = stateFor(key);
|
|
340
310
|
const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
|
|
341
311
|
if (!track) {
|
|
342
312
|
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
343
313
|
}
|
|
344
|
-
return serialize(state, () => walkFor(
|
|
314
|
+
return serialize(state, () => walkFor(source, state, plan, track, trackNumber));
|
|
345
315
|
}
|
|
346
316
|
|
|
347
317
|
/**
|
|
348
318
|
* The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
|
|
349
319
|
* one file to one walk at a time.
|
|
350
320
|
*
|
|
351
|
-
* @param {
|
|
352
|
-
* @param {number} fileIndex
|
|
321
|
+
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
353
322
|
* @param {object} state
|
|
354
323
|
* @param {object} plan
|
|
355
324
|
* @param {object} track
|
|
356
325
|
* @param {number} trackNumber
|
|
357
326
|
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
358
327
|
*/
|
|
359
|
-
async function walkFor(
|
|
360
|
-
|
|
361
|
-
|
|
328
|
+
async function walkFor(source, state, plan, track, trackNumber) {
|
|
329
|
+
// Taken once per pass: pieces keep arriving, and what may be read now is a
|
|
330
|
+
// different list from what could be read when the plan was built.
|
|
331
|
+
state.held = await source.heldRanges();
|
|
332
|
+
const container = await containerOver(state, source);
|
|
362
333
|
if (!container) {
|
|
363
334
|
return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
|
|
364
335
|
}
|
|
@@ -412,15 +383,15 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
|
|
|
412
383
|
* readable tracks either: counting those alone puts every text track after a
|
|
413
384
|
* picture-based one in the wrong place.
|
|
414
385
|
*/
|
|
415
|
-
export async function warmSubtitleCues(
|
|
416
|
-
const key = `${sourceKey}:${fileIndex}`;
|
|
417
|
-
const plan = await planFor(
|
|
386
|
+
export async function warmSubtitleCues(source) {
|
|
387
|
+
const key = `${source.sourceKey}:${source.fileIndex}`;
|
|
388
|
+
const plan = await planFor(source, key);
|
|
418
389
|
const state = stateFor(key);
|
|
419
390
|
const fresh = [];
|
|
420
391
|
const tracks = plan?.tracks ?? [];
|
|
421
392
|
for (let order = 0; order < tracks.length; order += 1) {
|
|
422
393
|
const track = tracks[order];
|
|
423
|
-
const held = await cuesHeldFor(
|
|
394
|
+
const held = await cuesHeldFor(source, track.trackNumber);
|
|
424
395
|
const since = state.pushed.get(track.trackNumber) ?? 0;
|
|
425
396
|
const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
|
|
426
397
|
if (newCues.length === 0) {
|
|
@@ -467,8 +438,8 @@ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
|
|
|
467
438
|
* @param {string} sourceKey
|
|
468
439
|
* @returns {Promise<object[]>}
|
|
469
440
|
*/
|
|
470
|
-
export async function subtitleTracksOf(
|
|
471
|
-
const plan = await planFor(
|
|
441
|
+
export async function subtitleTracksOf(source) {
|
|
442
|
+
const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
|
|
472
443
|
return (plan?.tracks ?? []).map((track, order) => ({
|
|
473
444
|
trackNumber: track.trackNumber,
|
|
474
445
|
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
@@ -494,8 +465,8 @@ export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
|
|
|
494
465
|
* @param {string} sourceKey
|
|
495
466
|
* @returns {Promise<object[]>}
|
|
496
467
|
*/
|
|
497
|
-
export async function declaredSubtitleTracksOf(
|
|
498
|
-
const plan = await planFor(
|
|
468
|
+
export async function declaredSubtitleTracksOf(source) {
|
|
469
|
+
const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
|
|
499
470
|
return plan?.declared ?? [];
|
|
500
471
|
}
|
|
501
472
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Subtitle orchestrator — the track list and the cues, behind the
|
|
3
|
+
* Container/Track abstraction.
|
|
4
|
+
*
|
|
5
|
+
* Provides per-file track list and cue streaming, with the same "only already
|
|
6
|
+
* downloaded clusters" rule as before. Controllers (HTTP or data-channel)
|
|
7
|
+
* depend on this, not on the worker module directly. Delegates to
|
|
8
|
+
* `ContainerOrchestrator` for track enumeration so subtitle tracks and their
|
|
9
|
+
* flags come from the unified `ContainerTrack` hierarchy.
|
|
10
|
+
*
|
|
11
|
+
* **The cue reading is HANDED IN, not imported, and that is the layer boundary.**
|
|
12
|
+
* Which clusters may be read, where the cursor stands and what one walk at a
|
|
13
|
+
* time means are the TORRENT's rules, and they live in
|
|
14
|
+
* `torrent-worker/subtitle-cues.js`. This file used to import them, so the media
|
|
15
|
+
* layer named the torrent's worker — the one direction the layers may not go.
|
|
16
|
+
* Now the wiring puts the two together (`server/controllers/SubtitleController.js`) and
|
|
17
|
+
* this can be exercised with four plain functions.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { logger } from "../../utils/logger.js";
|
|
21
|
+
import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
|
|
22
|
+
import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
|
|
23
|
+
import { detectLanguage } from "./tracks/language-detect.js";
|
|
24
|
+
|
|
25
|
+
export class SubtitleOrchestrator {
|
|
26
|
+
/**
|
|
27
|
+
* @param {import("./ContainerOrchestrator.js").ContainerOrchestrator} containerOrchestrator
|
|
28
|
+
* @param {object} [cues] - How the torrent reads subtitle cues: `warm`,
|
|
29
|
+
* `tracksOf`, `declaredTracksOf`, `forget`. Absent, this answers from the
|
|
30
|
+
* containers alone, which is what a proxy with no torrent behind it can do.
|
|
31
|
+
*/
|
|
32
|
+
constructor(containerOrchestrator, cues = {}) {
|
|
33
|
+
this.containers = containerOrchestrator;
|
|
34
|
+
this.cues = cues ?? {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tracks for menu — text tracks via domain, enriched with ContainerTrack flags.
|
|
39
|
+
* Falls back to container tracks when domain has no plan yet.
|
|
40
|
+
* @param {object} torrent
|
|
41
|
+
* @param {number} fileIndex
|
|
42
|
+
* @param {string} sourceKey
|
|
43
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} [readRange]
|
|
44
|
+
* @param {number} [fileSize]
|
|
45
|
+
* @returns {Promise<import("./tracks/index.js").ContainerTrack[]>}
|
|
46
|
+
*/
|
|
47
|
+
async getTracks(torrent, fileIndex, sourceKey, readRange, fileSize) {
|
|
48
|
+
try {
|
|
49
|
+
const domain = await this.cues.tracksOf?.(torrent, fileIndex, sourceKey);
|
|
50
|
+
if (Array.isArray(domain) && domain.length > 0) return domain;
|
|
51
|
+
} catch {}
|
|
52
|
+
if (readRange && Number.isFinite(fileSize)) {
|
|
53
|
+
try {
|
|
54
|
+
const tracks = await this.containers.getTracks({ sourceKey, fileIndex, readRange, fileSize, label: torrent?.files?.[fileIndex]?.name ?? "" });
|
|
55
|
+
return tracks.filter((t) => t.type === "subtitle");
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Declared subtitle tracks in container order (including image tracks) — for declaredIndex alignment.
|
|
63
|
+
*/
|
|
64
|
+
async getDeclaredTracks(torrent, fileIndex, sourceKey) {
|
|
65
|
+
try {
|
|
66
|
+
return (await this.cues.declaredTracksOf?.(torrent, fileIndex, sourceKey)) ?? [];
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The cues a file already holds for one track.
|
|
74
|
+
*
|
|
75
|
+
* The PULL path: a browser asking `/api/subtitles` rather than being pushed
|
|
76
|
+
* to. It runs the same walk as the push, on this thread — it used to be sent
|
|
77
|
+
* to the torrent thread as a command, and when that command went the guard
|
|
78
|
+
* here answered an EMPTY document instead of failing, which is
|
|
79
|
+
* indistinguishable from a file that genuinely holds no cues. That is the one
|
|
80
|
+
* failure this whole path is written to avoid, so an absent walk says so.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} torrent
|
|
83
|
+
* @param {number} fileIndex
|
|
84
|
+
* @param {string} sourceKey
|
|
85
|
+
* @param {number} trackNumber
|
|
86
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
87
|
+
*/
|
|
88
|
+
async getCues(torrent, fileIndex, sourceKey, trackNumber) {
|
|
89
|
+
const empty = () => ({ cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
|
|
90
|
+
if (typeof this.cues.held !== "function") {
|
|
91
|
+
logger.warn(
|
|
92
|
+
"subtitle-orchestrator: no walk was supplied, so no cue can be read — " +
|
|
93
|
+
"an empty document here would be indistinguishable from a file with none"
|
|
94
|
+
);
|
|
95
|
+
return empty();
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const answer = await this.cues.held(torrent, fileIndex, sourceKey, trackNumber);
|
|
99
|
+
return answer ?? empty();
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
|
|
102
|
+
return empty();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Warm all subtitle tracks of a file — called periodically and on verified pieces.
|
|
108
|
+
* Returns per-track fresh cues for push.
|
|
109
|
+
*/
|
|
110
|
+
async warm(torrent, fileIndex, sourceKey) {
|
|
111
|
+
try {
|
|
112
|
+
return (await this.cues.warm?.(torrent, fileIndex, sourceKey)) ?? [];
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A subtitle FILE as WebVTT, and the language its words are in.
|
|
120
|
+
*
|
|
121
|
+
* The language is read from the CONVERTED document, not from the file. The
|
|
122
|
+
* conversion has already dropped everything that is not the words — and on
|
|
123
|
+
* an ASS file that is half of it, in Latin letters, which is what made a
|
|
124
|
+
* Russian track answer `en` (field 2026-09-01,
|
|
125
|
+
* `research/subtitle-language-ass-markup-2026-09-01.md`).
|
|
126
|
+
*
|
|
127
|
+
* @param {Buffer} bytes
|
|
128
|
+
* @param {string} extension - With its dot, lower case.
|
|
129
|
+
* @returns {{ vtt: string, language: string | null } | null} Null for a
|
|
130
|
+
* format this cannot convert.
|
|
131
|
+
*/
|
|
132
|
+
fileAsVtt(bytes, extension) {
|
|
133
|
+
const text = SubtitleFileContainer.decodeBytes(bytes);
|
|
134
|
+
const vtt = SubtitleFileContainer.toVtt(text, extension);
|
|
135
|
+
if (!vtt) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
return { vtt, language: TextSubtitleTrack.detectLanguageFromVtt(vtt) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Cues held for an embedded track as WebVTT, and the language of the track.
|
|
143
|
+
*
|
|
144
|
+
* Two things this reads, and each of them was wrong before 2.68.1. It reads
|
|
145
|
+
* the cues through `finalizeCues`, so what reaches the detector is the words
|
|
146
|
+
* and not ASS's `{\…}` override groups, which are Latin on a Russian track.
|
|
147
|
+
* And it reads EVERY cue held so far, not the subset being sent: a
|
|
148
|
+
* re-subscription after a reconnect asks only for what the page missed,
|
|
149
|
+
* which can be three lines, and three lines are not a sample of a language.
|
|
150
|
+
*
|
|
151
|
+
* @param {object[]} sending - The cues this answer carries.
|
|
152
|
+
* @param {object[]} held - Every cue held for the track so far.
|
|
153
|
+
* @param {string} codecId
|
|
154
|
+
* @returns {{ vtt: string, language: string | null }}
|
|
155
|
+
*/
|
|
156
|
+
cuesAsVtt(sending, held, codecId) {
|
|
157
|
+
return {
|
|
158
|
+
vtt: TextSubtitleTrack.cuesToVtt(sending, codecId),
|
|
159
|
+
language: detectLanguage(TextSubtitleTrack.finalizeCues(held, codecId).map((cue) => cue.text).join("\n"))
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
forget(sourceKey, fileIndex) {
|
|
164
|
+
this.cues.forget?.(sourceKey, fileIndex);
|
|
165
|
+
this.containers.forget(sourceKey, fileIndex);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -56,6 +56,7 @@ import { AudioTrack } from "./tracks/AudioTrack.js";
|
|
|
56
56
|
* @property {boolean} isVisualImpaired
|
|
57
57
|
* @property {boolean} isEnabled
|
|
58
58
|
* @property {number | null} channels
|
|
59
|
+
* @property {number | null} bitrateKbps - The rate the file states for this track; an average, not a bound.
|
|
59
60
|
* @property {string} fileName - For a sidecar: its own file name. "" otherwise.
|
|
60
61
|
* @property {string[]} folders - For a sidecar: the folders above it, relative
|
|
61
62
|
* to the torrent root. What the browser reads a language and a releaser from.
|
|
@@ -73,7 +74,7 @@ import { AudioTrack } from "./tracks/AudioTrack.js";
|
|
|
73
74
|
* @param {object} params
|
|
74
75
|
* @param {object[]} params.embedded - Merged banner+container tracks of the video.
|
|
75
76
|
* @param {number} params.videoFileIndex
|
|
76
|
-
* @param {Array<{ file: import("
|
|
77
|
+
* @param {Array<{ file: import("../torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
|
|
77
78
|
* Each sidecar file with the audio tracks IT holds. A file whose container
|
|
78
79
|
* could not be read contributes one track, which is what a bare elementary
|
|
79
80
|
* stream is.
|
|
@@ -105,6 +106,11 @@ export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
|
|
|
105
106
|
isVisualImpaired: track?.isVisualImpaired === true,
|
|
106
107
|
isEnabled: track?.isEnabled !== false,
|
|
107
108
|
channels: Number.isFinite(track?.channels) ? track.channels : null,
|
|
109
|
+
// The rate the file STATES for this track, in kbit/s — an average, not a
|
|
110
|
+
// bound — or null where nothing states one (a sidecar read from its
|
|
111
|
+
// container table, a Matroska stream without statistics tags). What a
|
|
112
|
+
// viewer's link is asked to carry for a copied soundtrack is this.
|
|
113
|
+
bitrateKbps: Number.isFinite(track?.bitrateKbps) && track.bitrateKbps > 0 ? track.bitrateKbps : null,
|
|
108
114
|
fileName: kind === "sidecar" ? (file?.name ?? "") : "",
|
|
109
115
|
folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
|
|
110
116
|
// What the file's own path says, for a track that ships as its own file:
|