@torrent-tv/proxy 2.87.8 → 2.87.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +4 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +129 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +361 -361
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2162 -2162
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1083 -1083
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +266 -266
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +1 -1
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1146 -1146
- package/services/server/ViewerRequests.js +522 -522
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +983 -983
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +657 -657
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +35 -35
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +161 -161
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/utils/torrent-source-key.js +38 -38
|
@@ -1,455 +1,455 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Which files of a torrent belong to one video file — as its sound, its
|
|
3
|
-
* subtitles or its pictures.
|
|
4
|
-
*
|
|
5
|
-
* Releases often ship a dub or a subtitle set as SEPARATE FILES beside the
|
|
6
|
-
* picture — `Rus Sound/<name>.mka`, `Sub/[group]/<name>.ass` — and a viewer who
|
|
7
|
-
* cannot reach them is watching the release without half of what it carries.
|
|
8
|
-
* This module answers only which file goes with which; what is inside a file is
|
|
9
|
-
* the container layer's business (`services/media/container/`), and what a viewer is
|
|
10
|
-
* shown is composed in the browser, where the locale is known.
|
|
11
|
-
*
|
|
12
|
-
* **Why it lives in `torrent/`.** A container knows only itself, and a track
|
|
13
|
-
* knows only its container; "the file next to this one" is a notion that exists
|
|
14
|
-
* only where there is a LIST of files, which is the torrent. So this is the
|
|
15
|
-
* torrent's own statement about itself, in the layer that holds them, and it is
|
|
16
|
-
* the reason the folder exists.
|
|
17
|
-
*
|
|
18
|
-
* Nothing here reads bytes, waits on the swarm or knows about ffmpeg. It is a
|
|
19
|
-
* function of the torrent's own list of names, which is why it can be tested
|
|
20
|
-
* outright.
|
|
21
|
-
*
|
|
22
|
-
* A note on the axes this fits into. A file beside the video is NOT a third kind
|
|
23
|
-
* of track: `<name>.mka` is a Matroska container holding an audio track, and
|
|
24
|
-
* `MatroskaContainer` reads it exactly as it reads the picture's own tracks. So
|
|
25
|
-
* "external" is not a type — it is only the answer to WHERE a track lives, and
|
|
26
|
-
* that answer belongs here rather than in `tracks/`, which describes what a
|
|
27
|
-
* container declares and must stay free of torrent knowledge.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { nameFollows, sidecarNaming } from "./naming.js";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Containers and elementary streams that can carry a soundtrack on their own.
|
|
34
|
-
*
|
|
35
|
-
* Two groups, and the difference matters to the caller: `.mka` and `.m4a` have a
|
|
36
|
-
* track table that `ContainerFactory` can read, so their language, title and
|
|
37
|
-
* flags are available; the rest are raw elementary streams that carry exactly
|
|
38
|
-
* one track and declare nothing about it.
|
|
39
|
-
*/
|
|
40
|
-
export const AUDIO_SIDECAR_EXTENSIONS = new Set([
|
|
41
|
-
".mka",
|
|
42
|
-
".m4a",
|
|
43
|
-
".aac",
|
|
44
|
-
".ac3",
|
|
45
|
-
".eac3",
|
|
46
|
-
".dts",
|
|
47
|
-
".dtshd",
|
|
48
|
-
".flac",
|
|
49
|
-
".mp3",
|
|
50
|
-
".mp2",
|
|
51
|
-
".ogg",
|
|
52
|
-
".oga",
|
|
53
|
-
".opus",
|
|
54
|
-
".wav",
|
|
55
|
-
".thd",
|
|
56
|
-
".mlp",
|
|
57
|
-
".wma"
|
|
58
|
-
]);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Subtitle files. Image-based ones (`.sup`, `.sub`, `.idx`) are listed because
|
|
62
|
-
* they exist and must be RECOGNISED — a file we cannot show is still not a
|
|
63
|
-
* soundtrack — but this module does not decide what is offerable; that is the
|
|
64
|
-
* subtitle track's own `isTextBased()`.
|
|
65
|
-
*/
|
|
66
|
-
export const SUBTITLE_SIDECAR_EXTENSIONS = new Set([
|
|
67
|
-
".srt",
|
|
68
|
-
".ass",
|
|
69
|
-
".ssa",
|
|
70
|
-
".vtt",
|
|
71
|
-
".webvtt",
|
|
72
|
-
".sub",
|
|
73
|
-
".sup",
|
|
74
|
-
".idx",
|
|
75
|
-
".ttml",
|
|
76
|
-
".smi",
|
|
77
|
-
".txt"
|
|
78
|
-
]);
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Still images shipped beside a video: a contact sheet of frames, a cover, a
|
|
82
|
-
* poster.
|
|
83
|
-
*
|
|
84
|
-
* Recognised for two things a viewer can see. A pack of a hundred videos is
|
|
85
|
-
* unusable as a list of release names, and many such packs ship one contact
|
|
86
|
-
* sheet per video — so the sheets can BE the picker. And a film with a cover
|
|
87
|
-
* beside it has something to show while its first frame is being made, which is
|
|
88
|
-
* otherwise a black rectangle.
|
|
89
|
-
*
|
|
90
|
-
* They are served by the byte-range route like anything else in the torrent;
|
|
91
|
-
* nothing here decodes them.
|
|
92
|
-
*/
|
|
93
|
-
export const IMAGE_SIDECAR_EXTENSIONS = new Set([
|
|
94
|
-
".jpg",
|
|
95
|
-
".jpeg",
|
|
96
|
-
".png",
|
|
97
|
-
".webp",
|
|
98
|
-
".gif",
|
|
99
|
-
".bmp",
|
|
100
|
-
".avif"
|
|
101
|
-
]);
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Containers that carry a picture.
|
|
105
|
-
*
|
|
106
|
-
* Here to COUNT them, not to decide what plays: whether a file is playable is
|
|
107
|
-
* the browser's answer, made from what its own media stack accepts, and this
|
|
108
|
-
* proxy must not acquire a second opinion about it. The count is needed for one
|
|
109
|
-
* decision — whether a torrent holds exactly one picture, in which case a
|
|
110
|
-
* soundtrack beside it has nothing else it could belong to.
|
|
111
|
-
*/
|
|
112
|
-
export const VIDEO_FILE_EXTENSIONS = new Set([
|
|
113
|
-
".mp4",
|
|
114
|
-
".mkv",
|
|
115
|
-
".webm",
|
|
116
|
-
".mov",
|
|
117
|
-
".m4v",
|
|
118
|
-
".avi",
|
|
119
|
-
".mpg",
|
|
120
|
-
".mpeg",
|
|
121
|
-
".ts",
|
|
122
|
-
".m2ts",
|
|
123
|
-
".mts",
|
|
124
|
-
".wmv",
|
|
125
|
-
".asf",
|
|
126
|
-
".flv",
|
|
127
|
-
".f4v",
|
|
128
|
-
".ogv",
|
|
129
|
-
".ogm",
|
|
130
|
-
".3gp",
|
|
131
|
-
".3g2",
|
|
132
|
-
".divx",
|
|
133
|
-
".vob",
|
|
134
|
-
".m2v",
|
|
135
|
-
".m2p",
|
|
136
|
-
".mxf",
|
|
137
|
-
".rm",
|
|
138
|
-
".rmvb",
|
|
139
|
-
// A Video CD's MPEG-1 stream. It was offered by the browser and not counted
|
|
140
|
-
// here, which is the divergence that made this one list rather than two
|
|
141
|
-
// (measured 2026-09-12, and the only extension the two disagreed on). The
|
|
142
|
-
// union is the safe way to settle it: a file that is offered and turns out to
|
|
143
|
-
// be unplayable is one probe, while a file that is a film and is never
|
|
144
|
-
// offered cannot be reached at all.
|
|
145
|
-
".dat"
|
|
146
|
-
]);
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* How many files of a torrent carry a picture.
|
|
150
|
-
*
|
|
151
|
-
* @param {Array<{ path?: string, name?: string }>} files
|
|
152
|
-
* @returns {number}
|
|
153
|
-
*/
|
|
154
|
-
export function countVideoFiles(files) {
|
|
155
|
-
let count = 0;
|
|
156
|
-
for (const file of Array.isArray(files) ? files : []) {
|
|
157
|
-
const name = typeof file?.name === "string" && file.name.length > 0
|
|
158
|
-
? file.name
|
|
159
|
-
: String(file?.path ?? "");
|
|
160
|
-
if (VIDEO_FILE_EXTENSIONS.has(extensionOf(name))) {
|
|
161
|
-
count += 1;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return count;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Subtitle formats that are text, and therefore small by construction — a whole
|
|
169
|
-
* episode of dialogue is tens of kilobytes.
|
|
170
|
-
*
|
|
171
|
-
* The distinction is used to decide how much of such a file to fetch ahead of
|
|
172
|
-
* the viewer: a text file is fetched WHOLE, because it is smaller than the
|
|
173
|
-
* torrent's own piece and reading its edges would cost the same pieces as
|
|
174
|
-
* reading all of it. An image-based one (`.sup`, `.sub`+`.idx`) is a picture per
|
|
175
|
-
* cue and runs to tens of megabytes, so it gets the same edges treatment as
|
|
176
|
-
* anything else.
|
|
177
|
-
*/
|
|
178
|
-
export const TEXT_SUBTITLE_SIDECAR_EXTENSIONS = new Set([
|
|
179
|
-
".srt",
|
|
180
|
-
".ass",
|
|
181
|
-
".ssa",
|
|
182
|
-
".vtt",
|
|
183
|
-
".webvtt",
|
|
184
|
-
".ttml",
|
|
185
|
-
".smi",
|
|
186
|
-
".txt"
|
|
187
|
-
]);
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Extensions whose track table a `Container` subclass can read. Everything else
|
|
191
|
-
* in {@link AUDIO_SIDECAR_EXTENSIONS} is a bare stream: one track, no metadata.
|
|
192
|
-
*/
|
|
193
|
-
const CONTAINER_BACKED_AUDIO = new Set([".mka", ".m4a", ".mp4", ".mkv", ".webm"]);
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* The extension of a name, lowercased, including the dot. Empty when there is
|
|
197
|
-
* none.
|
|
198
|
-
*
|
|
199
|
-
* @param {string} name
|
|
200
|
-
* @returns {string}
|
|
201
|
-
*/
|
|
202
|
-
export function extensionOf(name) {
|
|
203
|
-
const text = typeof name === "string" ? name : "";
|
|
204
|
-
const dot = text.lastIndexOf(".");
|
|
205
|
-
// A leading dot is a hidden file, not an extension.
|
|
206
|
-
return dot > 0 ? text.slice(dot).toLowerCase() : "";
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* A name without its extension.
|
|
211
|
-
*
|
|
212
|
-
* @param {string} name
|
|
213
|
-
* @returns {string}
|
|
214
|
-
*/
|
|
215
|
-
export function baseNameOf(name) {
|
|
216
|
-
const text = typeof name === "string" ? name : "";
|
|
217
|
-
const dot = text.lastIndexOf(".");
|
|
218
|
-
return dot > 0 ? text.slice(0, dot) : text;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Whether a sidecar file of this extension has a readable track table.
|
|
223
|
-
*
|
|
224
|
-
* @param {string} extension - Lowercased, with the dot.
|
|
225
|
-
* @returns {boolean}
|
|
226
|
-
*/
|
|
227
|
-
export function declaresItsOwnTracks(extension) {
|
|
228
|
-
return CONTAINER_BACKED_AUDIO.has(extension);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Split a torrent file's path into the folders above it and its own name, with
|
|
233
|
-
* the torrent's own name removed from the front.
|
|
234
|
-
*
|
|
235
|
-
* WebTorrent prefixes every path in a multi-file torrent with the torrent name;
|
|
236
|
-
* the browser strips it again to show a playlist. Doing it once, here, means the
|
|
237
|
-
* folders travel to the browser already relative to the torrent root, so there
|
|
238
|
-
* is one stripping rule in the system rather than two that can disagree.
|
|
239
|
-
*
|
|
240
|
-
* @param {string} path - `file.path` as WebTorrent reports it.
|
|
241
|
-
* @param {string} torrentName
|
|
242
|
-
* @returns {{ folders: string[], name: string }}
|
|
243
|
-
*/
|
|
244
|
-
export function splitTorrentPath(path, torrentName) {
|
|
245
|
-
const text = typeof path === "string" ? path.replace(/\\/g, "/") : "";
|
|
246
|
-
const prefix = typeof torrentName === "string" && torrentName.length > 0 ? `${torrentName}/` : "";
|
|
247
|
-
const relative = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text;
|
|
248
|
-
const segments = relative.split("/").filter((segment) => segment.length > 0);
|
|
249
|
-
const name = segments.length > 0 ? segments[segments.length - 1] : "";
|
|
250
|
-
return { folders: segments.slice(0, -1), name };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Bracketed groups of a name, in order: `[HorribleSubs] X [1080p]` → both.
|
|
255
|
-
*
|
|
256
|
-
* @param {string} text
|
|
257
|
-
* @returns {string[]}
|
|
258
|
-
*/
|
|
259
|
-
export function bracketTokensOf(text) {
|
|
260
|
-
const source = typeof text === "string" ? text : "";
|
|
261
|
-
const tokens = [];
|
|
262
|
-
for (const match of source.matchAll(/\[([^\]]+)\]/g)) {
|
|
263
|
-
const token = match[1].trim();
|
|
264
|
-
if (token.length > 0) {
|
|
265
|
-
tokens.push(token);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return tokens;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Bracketed groups that look like a release hash — `[78EFD746]`. Anime releases
|
|
273
|
-
* carry the CRC of the file, and two files sharing one are certainly a pair.
|
|
274
|
-
*
|
|
275
|
-
* @param {string} text
|
|
276
|
-
* @returns {string[]}
|
|
277
|
-
*/
|
|
278
|
-
function hashTokensOf(text) {
|
|
279
|
-
return bracketTokensOf(text)
|
|
280
|
-
.filter((token) => /^[0-9a-f]{4,10}$/i.test(token))
|
|
281
|
-
.map((token) => token.toLowerCase());
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Whether a sidecar and a video are the same release of the same episode.
|
|
286
|
-
*
|
|
287
|
-
* Three rules, all taken from what releases actually do, and none of them
|
|
288
|
-
* involving the folder — a dub lives in a folder of its own by construction, so
|
|
289
|
-
* requiring the folders to match would reject every case this exists for:
|
|
290
|
-
*
|
|
291
|
-
* 1. the base names are equal, which is the common shape (`X.mkv` / `X.mka`);
|
|
292
|
-
* 2. the sidecar's name CONTINUES the video's at a token boundary, which is what
|
|
293
|
-
* every surveyed player reads: `<video base>.<language>[.<flags>].<ext>`, the
|
|
294
|
-
* shape Plex, Jellyfin, Kodi, Bazarr and OpenSubtitles all produce;
|
|
295
|
-
* 3. they share a release hash, which anime releases carry.
|
|
296
|
-
*
|
|
297
|
-
* Rule 2 was missing here and present in the browser, as a plain "begins with",
|
|
298
|
-
* and the two were compared nowhere. Measured 2026-09-04 over the 115 torrents
|
|
299
|
-
* in `Dropbox/trn`: of 1249 video files the two answers agreed on 1239 and
|
|
300
|
-
* differed on 10, every one of the ten a `<base>.<language>.ass` name that the
|
|
301
|
-
* browser paired and this side did not. The consequence reached the viewer,
|
|
302
|
-
* because the proxy warms what IT paired while the browser offers what IT
|
|
303
|
-
* paired: a track offered but never warmed waits for its first piece off the
|
|
304
|
-
* swarm, 27.7 s in the field measurement of 2026-08-31.
|
|
305
|
-
*
|
|
306
|
-
* The boundary is what makes rule 2 safe, and a plain "begins with" is not the
|
|
307
|
-
* same rule: without it `Film.20.rus.srt` pairs with `Film.2.mkv` — measured,
|
|
308
|
-
* and the grammar then reports the leftover `0` as the track's title, which is
|
|
309
|
-
* the tell that the remainder is a fragment of another film's name.
|
|
310
|
-
*
|
|
311
|
-
* @param {string} sidecarName
|
|
312
|
-
* @param {string} videoName
|
|
313
|
-
* @returns {boolean}
|
|
314
|
-
*/
|
|
315
|
-
export function namesPair(sidecarName, videoName) {
|
|
316
|
-
if (sameRelease(sidecarName, videoName)) {
|
|
317
|
-
return true;
|
|
318
|
-
}
|
|
319
|
-
return nameFollows(baseNameOf(sidecarName), baseNameOf(videoName));
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* The same question for an IMAGE, which has one shape of its own.
|
|
324
|
-
*
|
|
325
|
-
* A contact sheet is commonly named by the video's WHOLE name with the image
|
|
326
|
-
* extension appended — `Movie.mp4.jpg` beside `Movie.mp4` — verified on a pack
|
|
327
|
-
* of 106 videos with 106 sheets. That is not the base-name rule: the sheet's
|
|
328
|
-
* base name is `Movie.mp4` and the video's is `Movie`.
|
|
329
|
-
*
|
|
330
|
-
* @param {string} imageName
|
|
331
|
-
* @param {string} videoName
|
|
332
|
-
* @returns {boolean}
|
|
333
|
-
*/
|
|
334
|
-
export function imageNamesPair(imageName, videoName) {
|
|
335
|
-
if (sameRelease(imageName, videoName)) {
|
|
336
|
-
return true;
|
|
337
|
-
}
|
|
338
|
-
const imageBase = baseNameOf(imageName).toLowerCase();
|
|
339
|
-
return imageBase.length > 0 && imageBase === String(videoName ?? "").toLowerCase();
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* @param {string} sidecarName
|
|
344
|
-
* @param {string} videoName
|
|
345
|
-
* @returns {boolean}
|
|
346
|
-
*/
|
|
347
|
-
function sameRelease(sidecarName, videoName) {
|
|
348
|
-
const sidecarBase = baseNameOf(sidecarName).toLowerCase();
|
|
349
|
-
const videoBase = baseNameOf(videoName).toLowerCase();
|
|
350
|
-
if (sidecarBase.length === 0 || videoBase.length === 0) {
|
|
351
|
-
return false;
|
|
352
|
-
}
|
|
353
|
-
if (sidecarBase === videoBase) {
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
|
-
const videoHashes = new Set(hashTokensOf(videoBase));
|
|
357
|
-
if (videoHashes.size > 0) {
|
|
358
|
-
for (const token of hashTokensOf(sidecarBase)) {
|
|
359
|
-
if (videoHashes.has(token)) {
|
|
360
|
-
return true;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return false;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* @typedef {object} SidecarFile
|
|
369
|
-
* @property {number} fileIndex - Index in the torrent.
|
|
370
|
-
* @property {string} name - File name, extension included.
|
|
371
|
-
* @property {string[]} folders - Folders above it, relative to the torrent root.
|
|
372
|
-
* @property {string} extension - Lowercased, with the dot.
|
|
373
|
-
* @property {number} length - Bytes.
|
|
374
|
-
* @property {boolean} declaresTracks - Whether its own track table can be read.
|
|
375
|
-
*/
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* The audio and subtitle files of a torrent that belong to one video file.
|
|
379
|
-
*
|
|
380
|
-
* The pairing is by name (see {@link namesPair}), with ONE relaxation: a torrent
|
|
381
|
-
* holding a single video file has nothing else its sidecars could belong to, so
|
|
382
|
-
* there every sidecar is taken. That covers the very common
|
|
383
|
-
* `Film.mkv` + `Rus.mka` shape, where the two names have nothing in common. It
|
|
384
|
-
* is deliberately not extended to a torrent with several videos, where a wrong
|
|
385
|
-
* pairing would put the sound of one episode over the picture of another.
|
|
386
|
-
*
|
|
387
|
-
* @param {object} params
|
|
388
|
-
* @param {Array<{ path?: string, name?: string, length?: number }>} params.files
|
|
389
|
-
* @param {number} params.videoIndex
|
|
390
|
-
* @param {string} [params.torrentName]
|
|
391
|
-
* @param {number} [params.videoCount] - How many playable video files the
|
|
392
|
-
* torrent holds. When 1, the relaxation above applies. Counted by the caller,
|
|
393
|
-
* which is the side that knows what counts as playable.
|
|
394
|
-
* @returns {{ audio: SidecarFile[], subtitles: SidecarFile[], images: SidecarFile[] }}
|
|
395
|
-
* Images are the third group because a contact sheet or a cover is paired by
|
|
396
|
-
* the same rule and found in the same one pass. They are matched more
|
|
397
|
-
* strictly than sound and subtitles: the one-video relaxation does NOT apply
|
|
398
|
-
* to them, since a torrent's stray screenshot is not this film's cover, and
|
|
399
|
-
* showing the wrong picture as a poster is a visible mistake where a missing
|
|
400
|
-
* one is merely nothing.
|
|
401
|
-
*/
|
|
402
|
-
export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCount = 0 }) {
|
|
403
|
-
const list = Array.isArray(files) ? files : [];
|
|
404
|
-
const video = list[videoIndex];
|
|
405
|
-
if (!video) {
|
|
406
|
-
return { audio: [], subtitles: [], images: [] };
|
|
407
|
-
}
|
|
408
|
-
const videoPath = splitTorrentPath(video.path ?? video.name ?? "", torrentName);
|
|
409
|
-
const onlyVideo = videoCount === 1;
|
|
410
|
-
/** @type {SidecarFile[]} */
|
|
411
|
-
const audio = [];
|
|
412
|
-
/** @type {SidecarFile[]} */
|
|
413
|
-
const subtitles = [];
|
|
414
|
-
/** @type {SidecarFile[]} */
|
|
415
|
-
const images = [];
|
|
416
|
-
|
|
417
|
-
for (const [fileIndex, file] of list.entries()) {
|
|
418
|
-
if (fileIndex === videoIndex) {
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
const { folders, name } = splitTorrentPath(file?.path ?? file?.name ?? "", torrentName);
|
|
422
|
-
const extension = extensionOf(name);
|
|
423
|
-
const isAudio = AUDIO_SIDECAR_EXTENSIONS.has(extension);
|
|
424
|
-
const isSubtitle = !isAudio && SUBTITLE_SIDECAR_EXTENSIONS.has(extension);
|
|
425
|
-
const isImage = !isAudio && !isSubtitle && IMAGE_SIDECAR_EXTENSIONS.has(extension);
|
|
426
|
-
if (!isAudio && !isSubtitle && !isImage) {
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
if (isImage ? !imageNamesPair(name, videoPath.name) : !onlyVideo && !namesPair(name, videoPath.name)) {
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
const entry = {
|
|
433
|
-
fileIndex,
|
|
434
|
-
name,
|
|
435
|
-
folders,
|
|
436
|
-
extension,
|
|
437
|
-
length: Number.isFinite(file?.length) ? file.length : 0,
|
|
438
|
-
declaresTracks: declaresItsOwnTracks(extension),
|
|
439
|
-
// What this file's own path says about the track in it: its language, the
|
|
440
|
-
// flags a releaser wrote, and who made it. Read HERE, by the same grammar
|
|
441
|
-
// that decided the pairing, so the two answers cannot disagree — which is
|
|
442
|
-
// what they did while the browser read the name and this side did not.
|
|
443
|
-
naming: sidecarNaming({ folders, fileName: name, videoName: videoPath.name })
|
|
444
|
-
};
|
|
445
|
-
if (isAudio) {
|
|
446
|
-
audio.push(entry);
|
|
447
|
-
} else if (isSubtitle) {
|
|
448
|
-
subtitles.push(entry);
|
|
449
|
-
} else {
|
|
450
|
-
images.push(entry);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
return { audio, subtitles, images };
|
|
455
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Which files of a torrent belong to one video file — as its sound, its
|
|
3
|
+
* subtitles or its pictures.
|
|
4
|
+
*
|
|
5
|
+
* Releases often ship a dub or a subtitle set as SEPARATE FILES beside the
|
|
6
|
+
* picture — `Rus Sound/<name>.mka`, `Sub/[group]/<name>.ass` — and a viewer who
|
|
7
|
+
* cannot reach them is watching the release without half of what it carries.
|
|
8
|
+
* This module answers only which file goes with which; what is inside a file is
|
|
9
|
+
* the container layer's business (`services/media/container/`), and what a viewer is
|
|
10
|
+
* shown is composed in the browser, where the locale is known.
|
|
11
|
+
*
|
|
12
|
+
* **Why it lives in `torrent/`.** A container knows only itself, and a track
|
|
13
|
+
* knows only its container; "the file next to this one" is a notion that exists
|
|
14
|
+
* only where there is a LIST of files, which is the torrent. So this is the
|
|
15
|
+
* torrent's own statement about itself, in the layer that holds them, and it is
|
|
16
|
+
* the reason the folder exists.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here reads bytes, waits on the swarm or knows about ffmpeg. It is a
|
|
19
|
+
* function of the torrent's own list of names, which is why it can be tested
|
|
20
|
+
* outright.
|
|
21
|
+
*
|
|
22
|
+
* A note on the axes this fits into. A file beside the video is NOT a third kind
|
|
23
|
+
* of track: `<name>.mka` is a Matroska container holding an audio track, and
|
|
24
|
+
* `MatroskaContainer` reads it exactly as it reads the picture's own tracks. So
|
|
25
|
+
* "external" is not a type — it is only the answer to WHERE a track lives, and
|
|
26
|
+
* that answer belongs here rather than in `tracks/`, which describes what a
|
|
27
|
+
* container declares and must stay free of torrent knowledge.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { nameFollows, sidecarNaming } from "./naming.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Containers and elementary streams that can carry a soundtrack on their own.
|
|
34
|
+
*
|
|
35
|
+
* Two groups, and the difference matters to the caller: `.mka` and `.m4a` have a
|
|
36
|
+
* track table that `ContainerFactory` can read, so their language, title and
|
|
37
|
+
* flags are available; the rest are raw elementary streams that carry exactly
|
|
38
|
+
* one track and declare nothing about it.
|
|
39
|
+
*/
|
|
40
|
+
export const AUDIO_SIDECAR_EXTENSIONS = new Set([
|
|
41
|
+
".mka",
|
|
42
|
+
".m4a",
|
|
43
|
+
".aac",
|
|
44
|
+
".ac3",
|
|
45
|
+
".eac3",
|
|
46
|
+
".dts",
|
|
47
|
+
".dtshd",
|
|
48
|
+
".flac",
|
|
49
|
+
".mp3",
|
|
50
|
+
".mp2",
|
|
51
|
+
".ogg",
|
|
52
|
+
".oga",
|
|
53
|
+
".opus",
|
|
54
|
+
".wav",
|
|
55
|
+
".thd",
|
|
56
|
+
".mlp",
|
|
57
|
+
".wma"
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Subtitle files. Image-based ones (`.sup`, `.sub`, `.idx`) are listed because
|
|
62
|
+
* they exist and must be RECOGNISED — a file we cannot show is still not a
|
|
63
|
+
* soundtrack — but this module does not decide what is offerable; that is the
|
|
64
|
+
* subtitle track's own `isTextBased()`.
|
|
65
|
+
*/
|
|
66
|
+
export const SUBTITLE_SIDECAR_EXTENSIONS = new Set([
|
|
67
|
+
".srt",
|
|
68
|
+
".ass",
|
|
69
|
+
".ssa",
|
|
70
|
+
".vtt",
|
|
71
|
+
".webvtt",
|
|
72
|
+
".sub",
|
|
73
|
+
".sup",
|
|
74
|
+
".idx",
|
|
75
|
+
".ttml",
|
|
76
|
+
".smi",
|
|
77
|
+
".txt"
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Still images shipped beside a video: a contact sheet of frames, a cover, a
|
|
82
|
+
* poster.
|
|
83
|
+
*
|
|
84
|
+
* Recognised for two things a viewer can see. A pack of a hundred videos is
|
|
85
|
+
* unusable as a list of release names, and many such packs ship one contact
|
|
86
|
+
* sheet per video — so the sheets can BE the picker. And a film with a cover
|
|
87
|
+
* beside it has something to show while its first frame is being made, which is
|
|
88
|
+
* otherwise a black rectangle.
|
|
89
|
+
*
|
|
90
|
+
* They are served by the byte-range route like anything else in the torrent;
|
|
91
|
+
* nothing here decodes them.
|
|
92
|
+
*/
|
|
93
|
+
export const IMAGE_SIDECAR_EXTENSIONS = new Set([
|
|
94
|
+
".jpg",
|
|
95
|
+
".jpeg",
|
|
96
|
+
".png",
|
|
97
|
+
".webp",
|
|
98
|
+
".gif",
|
|
99
|
+
".bmp",
|
|
100
|
+
".avif"
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Containers that carry a picture.
|
|
105
|
+
*
|
|
106
|
+
* Here to COUNT them, not to decide what plays: whether a file is playable is
|
|
107
|
+
* the browser's answer, made from what its own media stack accepts, and this
|
|
108
|
+
* proxy must not acquire a second opinion about it. The count is needed for one
|
|
109
|
+
* decision — whether a torrent holds exactly one picture, in which case a
|
|
110
|
+
* soundtrack beside it has nothing else it could belong to.
|
|
111
|
+
*/
|
|
112
|
+
export const VIDEO_FILE_EXTENSIONS = new Set([
|
|
113
|
+
".mp4",
|
|
114
|
+
".mkv",
|
|
115
|
+
".webm",
|
|
116
|
+
".mov",
|
|
117
|
+
".m4v",
|
|
118
|
+
".avi",
|
|
119
|
+
".mpg",
|
|
120
|
+
".mpeg",
|
|
121
|
+
".ts",
|
|
122
|
+
".m2ts",
|
|
123
|
+
".mts",
|
|
124
|
+
".wmv",
|
|
125
|
+
".asf",
|
|
126
|
+
".flv",
|
|
127
|
+
".f4v",
|
|
128
|
+
".ogv",
|
|
129
|
+
".ogm",
|
|
130
|
+
".3gp",
|
|
131
|
+
".3g2",
|
|
132
|
+
".divx",
|
|
133
|
+
".vob",
|
|
134
|
+
".m2v",
|
|
135
|
+
".m2p",
|
|
136
|
+
".mxf",
|
|
137
|
+
".rm",
|
|
138
|
+
".rmvb",
|
|
139
|
+
// A Video CD's MPEG-1 stream. It was offered by the browser and not counted
|
|
140
|
+
// here, which is the divergence that made this one list rather than two
|
|
141
|
+
// (measured 2026-09-12, and the only extension the two disagreed on). The
|
|
142
|
+
// union is the safe way to settle it: a file that is offered and turns out to
|
|
143
|
+
// be unplayable is one probe, while a file that is a film and is never
|
|
144
|
+
// offered cannot be reached at all.
|
|
145
|
+
".dat"
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How many files of a torrent carry a picture.
|
|
150
|
+
*
|
|
151
|
+
* @param {Array<{ path?: string, name?: string }>} files
|
|
152
|
+
* @returns {number}
|
|
153
|
+
*/
|
|
154
|
+
export function countVideoFiles(files) {
|
|
155
|
+
let count = 0;
|
|
156
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
157
|
+
const name = typeof file?.name === "string" && file.name.length > 0
|
|
158
|
+
? file.name
|
|
159
|
+
: String(file?.path ?? "");
|
|
160
|
+
if (VIDEO_FILE_EXTENSIONS.has(extensionOf(name))) {
|
|
161
|
+
count += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return count;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Subtitle formats that are text, and therefore small by construction — a whole
|
|
169
|
+
* episode of dialogue is tens of kilobytes.
|
|
170
|
+
*
|
|
171
|
+
* The distinction is used to decide how much of such a file to fetch ahead of
|
|
172
|
+
* the viewer: a text file is fetched WHOLE, because it is smaller than the
|
|
173
|
+
* torrent's own piece and reading its edges would cost the same pieces as
|
|
174
|
+
* reading all of it. An image-based one (`.sup`, `.sub`+`.idx`) is a picture per
|
|
175
|
+
* cue and runs to tens of megabytes, so it gets the same edges treatment as
|
|
176
|
+
* anything else.
|
|
177
|
+
*/
|
|
178
|
+
export const TEXT_SUBTITLE_SIDECAR_EXTENSIONS = new Set([
|
|
179
|
+
".srt",
|
|
180
|
+
".ass",
|
|
181
|
+
".ssa",
|
|
182
|
+
".vtt",
|
|
183
|
+
".webvtt",
|
|
184
|
+
".ttml",
|
|
185
|
+
".smi",
|
|
186
|
+
".txt"
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extensions whose track table a `Container` subclass can read. Everything else
|
|
191
|
+
* in {@link AUDIO_SIDECAR_EXTENSIONS} is a bare stream: one track, no metadata.
|
|
192
|
+
*/
|
|
193
|
+
const CONTAINER_BACKED_AUDIO = new Set([".mka", ".m4a", ".mp4", ".mkv", ".webm"]);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The extension of a name, lowercased, including the dot. Empty when there is
|
|
197
|
+
* none.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} name
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
export function extensionOf(name) {
|
|
203
|
+
const text = typeof name === "string" ? name : "";
|
|
204
|
+
const dot = text.lastIndexOf(".");
|
|
205
|
+
// A leading dot is a hidden file, not an extension.
|
|
206
|
+
return dot > 0 ? text.slice(dot).toLowerCase() : "";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A name without its extension.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} name
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function baseNameOf(name) {
|
|
216
|
+
const text = typeof name === "string" ? name : "";
|
|
217
|
+
const dot = text.lastIndexOf(".");
|
|
218
|
+
return dot > 0 ? text.slice(0, dot) : text;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Whether a sidecar file of this extension has a readable track table.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} extension - Lowercased, with the dot.
|
|
225
|
+
* @returns {boolean}
|
|
226
|
+
*/
|
|
227
|
+
export function declaresItsOwnTracks(extension) {
|
|
228
|
+
return CONTAINER_BACKED_AUDIO.has(extension);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Split a torrent file's path into the folders above it and its own name, with
|
|
233
|
+
* the torrent's own name removed from the front.
|
|
234
|
+
*
|
|
235
|
+
* WebTorrent prefixes every path in a multi-file torrent with the torrent name;
|
|
236
|
+
* the browser strips it again to show a playlist. Doing it once, here, means the
|
|
237
|
+
* folders travel to the browser already relative to the torrent root, so there
|
|
238
|
+
* is one stripping rule in the system rather than two that can disagree.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} path - `file.path` as WebTorrent reports it.
|
|
241
|
+
* @param {string} torrentName
|
|
242
|
+
* @returns {{ folders: string[], name: string }}
|
|
243
|
+
*/
|
|
244
|
+
export function splitTorrentPath(path, torrentName) {
|
|
245
|
+
const text = typeof path === "string" ? path.replace(/\\/g, "/") : "";
|
|
246
|
+
const prefix = typeof torrentName === "string" && torrentName.length > 0 ? `${torrentName}/` : "";
|
|
247
|
+
const relative = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text;
|
|
248
|
+
const segments = relative.split("/").filter((segment) => segment.length > 0);
|
|
249
|
+
const name = segments.length > 0 ? segments[segments.length - 1] : "";
|
|
250
|
+
return { folders: segments.slice(0, -1), name };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Bracketed groups of a name, in order: `[HorribleSubs] X [1080p]` → both.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} text
|
|
257
|
+
* @returns {string[]}
|
|
258
|
+
*/
|
|
259
|
+
export function bracketTokensOf(text) {
|
|
260
|
+
const source = typeof text === "string" ? text : "";
|
|
261
|
+
const tokens = [];
|
|
262
|
+
for (const match of source.matchAll(/\[([^\]]+)\]/g)) {
|
|
263
|
+
const token = match[1].trim();
|
|
264
|
+
if (token.length > 0) {
|
|
265
|
+
tokens.push(token);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return tokens;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Bracketed groups that look like a release hash — `[78EFD746]`. Anime releases
|
|
273
|
+
* carry the CRC of the file, and two files sharing one are certainly a pair.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} text
|
|
276
|
+
* @returns {string[]}
|
|
277
|
+
*/
|
|
278
|
+
function hashTokensOf(text) {
|
|
279
|
+
return bracketTokensOf(text)
|
|
280
|
+
.filter((token) => /^[0-9a-f]{4,10}$/i.test(token))
|
|
281
|
+
.map((token) => token.toLowerCase());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether a sidecar and a video are the same release of the same episode.
|
|
286
|
+
*
|
|
287
|
+
* Three rules, all taken from what releases actually do, and none of them
|
|
288
|
+
* involving the folder — a dub lives in a folder of its own by construction, so
|
|
289
|
+
* requiring the folders to match would reject every case this exists for:
|
|
290
|
+
*
|
|
291
|
+
* 1. the base names are equal, which is the common shape (`X.mkv` / `X.mka`);
|
|
292
|
+
* 2. the sidecar's name CONTINUES the video's at a token boundary, which is what
|
|
293
|
+
* every surveyed player reads: `<video base>.<language>[.<flags>].<ext>`, the
|
|
294
|
+
* shape Plex, Jellyfin, Kodi, Bazarr and OpenSubtitles all produce;
|
|
295
|
+
* 3. they share a release hash, which anime releases carry.
|
|
296
|
+
*
|
|
297
|
+
* Rule 2 was missing here and present in the browser, as a plain "begins with",
|
|
298
|
+
* and the two were compared nowhere. Measured 2026-09-04 over the 115 torrents
|
|
299
|
+
* in `Dropbox/trn`: of 1249 video files the two answers agreed on 1239 and
|
|
300
|
+
* differed on 10, every one of the ten a `<base>.<language>.ass` name that the
|
|
301
|
+
* browser paired and this side did not. The consequence reached the viewer,
|
|
302
|
+
* because the proxy warms what IT paired while the browser offers what IT
|
|
303
|
+
* paired: a track offered but never warmed waits for its first piece off the
|
|
304
|
+
* swarm, 27.7 s in the field measurement of 2026-08-31.
|
|
305
|
+
*
|
|
306
|
+
* The boundary is what makes rule 2 safe, and a plain "begins with" is not the
|
|
307
|
+
* same rule: without it `Film.20.rus.srt` pairs with `Film.2.mkv` — measured,
|
|
308
|
+
* and the grammar then reports the leftover `0` as the track's title, which is
|
|
309
|
+
* the tell that the remainder is a fragment of another film's name.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} sidecarName
|
|
312
|
+
* @param {string} videoName
|
|
313
|
+
* @returns {boolean}
|
|
314
|
+
*/
|
|
315
|
+
export function namesPair(sidecarName, videoName) {
|
|
316
|
+
if (sameRelease(sidecarName, videoName)) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
return nameFollows(baseNameOf(sidecarName), baseNameOf(videoName));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The same question for an IMAGE, which has one shape of its own.
|
|
324
|
+
*
|
|
325
|
+
* A contact sheet is commonly named by the video's WHOLE name with the image
|
|
326
|
+
* extension appended — `Movie.mp4.jpg` beside `Movie.mp4` — verified on a pack
|
|
327
|
+
* of 106 videos with 106 sheets. That is not the base-name rule: the sheet's
|
|
328
|
+
* base name is `Movie.mp4` and the video's is `Movie`.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} imageName
|
|
331
|
+
* @param {string} videoName
|
|
332
|
+
* @returns {boolean}
|
|
333
|
+
*/
|
|
334
|
+
export function imageNamesPair(imageName, videoName) {
|
|
335
|
+
if (sameRelease(imageName, videoName)) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
const imageBase = baseNameOf(imageName).toLowerCase();
|
|
339
|
+
return imageBase.length > 0 && imageBase === String(videoName ?? "").toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {string} sidecarName
|
|
344
|
+
* @param {string} videoName
|
|
345
|
+
* @returns {boolean}
|
|
346
|
+
*/
|
|
347
|
+
function sameRelease(sidecarName, videoName) {
|
|
348
|
+
const sidecarBase = baseNameOf(sidecarName).toLowerCase();
|
|
349
|
+
const videoBase = baseNameOf(videoName).toLowerCase();
|
|
350
|
+
if (sidecarBase.length === 0 || videoBase.length === 0) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
if (sidecarBase === videoBase) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
const videoHashes = new Set(hashTokensOf(videoBase));
|
|
357
|
+
if (videoHashes.size > 0) {
|
|
358
|
+
for (const token of hashTokensOf(sidecarBase)) {
|
|
359
|
+
if (videoHashes.has(token)) {
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* @typedef {object} SidecarFile
|
|
369
|
+
* @property {number} fileIndex - Index in the torrent.
|
|
370
|
+
* @property {string} name - File name, extension included.
|
|
371
|
+
* @property {string[]} folders - Folders above it, relative to the torrent root.
|
|
372
|
+
* @property {string} extension - Lowercased, with the dot.
|
|
373
|
+
* @property {number} length - Bytes.
|
|
374
|
+
* @property {boolean} declaresTracks - Whether its own track table can be read.
|
|
375
|
+
*/
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The audio and subtitle files of a torrent that belong to one video file.
|
|
379
|
+
*
|
|
380
|
+
* The pairing is by name (see {@link namesPair}), with ONE relaxation: a torrent
|
|
381
|
+
* holding a single video file has nothing else its sidecars could belong to, so
|
|
382
|
+
* there every sidecar is taken. That covers the very common
|
|
383
|
+
* `Film.mkv` + `Rus.mka` shape, where the two names have nothing in common. It
|
|
384
|
+
* is deliberately not extended to a torrent with several videos, where a wrong
|
|
385
|
+
* pairing would put the sound of one episode over the picture of another.
|
|
386
|
+
*
|
|
387
|
+
* @param {object} params
|
|
388
|
+
* @param {Array<{ path?: string, name?: string, length?: number }>} params.files
|
|
389
|
+
* @param {number} params.videoIndex
|
|
390
|
+
* @param {string} [params.torrentName]
|
|
391
|
+
* @param {number} [params.videoCount] - How many playable video files the
|
|
392
|
+
* torrent holds. When 1, the relaxation above applies. Counted by the caller,
|
|
393
|
+
* which is the side that knows what counts as playable.
|
|
394
|
+
* @returns {{ audio: SidecarFile[], subtitles: SidecarFile[], images: SidecarFile[] }}
|
|
395
|
+
* Images are the third group because a contact sheet or a cover is paired by
|
|
396
|
+
* the same rule and found in the same one pass. They are matched more
|
|
397
|
+
* strictly than sound and subtitles: the one-video relaxation does NOT apply
|
|
398
|
+
* to them, since a torrent's stray screenshot is not this film's cover, and
|
|
399
|
+
* showing the wrong picture as a poster is a visible mistake where a missing
|
|
400
|
+
* one is merely nothing.
|
|
401
|
+
*/
|
|
402
|
+
export function matchSidecarFiles({ files, videoIndex, torrentName = "", videoCount = 0 }) {
|
|
403
|
+
const list = Array.isArray(files) ? files : [];
|
|
404
|
+
const video = list[videoIndex];
|
|
405
|
+
if (!video) {
|
|
406
|
+
return { audio: [], subtitles: [], images: [] };
|
|
407
|
+
}
|
|
408
|
+
const videoPath = splitTorrentPath(video.path ?? video.name ?? "", torrentName);
|
|
409
|
+
const onlyVideo = videoCount === 1;
|
|
410
|
+
/** @type {SidecarFile[]} */
|
|
411
|
+
const audio = [];
|
|
412
|
+
/** @type {SidecarFile[]} */
|
|
413
|
+
const subtitles = [];
|
|
414
|
+
/** @type {SidecarFile[]} */
|
|
415
|
+
const images = [];
|
|
416
|
+
|
|
417
|
+
for (const [fileIndex, file] of list.entries()) {
|
|
418
|
+
if (fileIndex === videoIndex) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const { folders, name } = splitTorrentPath(file?.path ?? file?.name ?? "", torrentName);
|
|
422
|
+
const extension = extensionOf(name);
|
|
423
|
+
const isAudio = AUDIO_SIDECAR_EXTENSIONS.has(extension);
|
|
424
|
+
const isSubtitle = !isAudio && SUBTITLE_SIDECAR_EXTENSIONS.has(extension);
|
|
425
|
+
const isImage = !isAudio && !isSubtitle && IMAGE_SIDECAR_EXTENSIONS.has(extension);
|
|
426
|
+
if (!isAudio && !isSubtitle && !isImage) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (isImage ? !imageNamesPair(name, videoPath.name) : !onlyVideo && !namesPair(name, videoPath.name)) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const entry = {
|
|
433
|
+
fileIndex,
|
|
434
|
+
name,
|
|
435
|
+
folders,
|
|
436
|
+
extension,
|
|
437
|
+
length: Number.isFinite(file?.length) ? file.length : 0,
|
|
438
|
+
declaresTracks: declaresItsOwnTracks(extension),
|
|
439
|
+
// What this file's own path says about the track in it: its language, the
|
|
440
|
+
// flags a releaser wrote, and who made it. Read HERE, by the same grammar
|
|
441
|
+
// that decided the pairing, so the two answers cannot disagree — which is
|
|
442
|
+
// what they did while the browser read the name and this side did not.
|
|
443
|
+
naming: sidecarNaming({ folders, fileName: name, videoName: videoPath.name })
|
|
444
|
+
};
|
|
445
|
+
if (isAudio) {
|
|
446
|
+
audio.push(entry);
|
|
447
|
+
} else if (isSubtitle) {
|
|
448
|
+
subtitles.push(entry);
|
|
449
|
+
} else {
|
|
450
|
+
images.push(entry);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { audio, subtitles, images };
|
|
455
|
+
}
|