@torrent-tv/proxy 2.87.20 → 2.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +8 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/browser-proxy-contract.md +133 -133
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +50 -50
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/audio-tracks/post.js +29 -29
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +106 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/sources/warm/post.js +217 -217
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +138 -138
- package/routes/api/transcode-sessions/post.js +173 -173
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +380 -380
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/server.js +725 -725
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +376 -376
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +813 -813
- package/services/encode/EncodeRuns.js +1235 -1235
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +758 -758
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2184 -2184
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1141 -1141
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +315 -315
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +754 -754
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +456 -456
- package/services/encode/segment-formats/index.js +85 -85
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +515 -515
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +245 -245
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/playback-planner.js +931 -931
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1150 -1150
- package/services/server/ViewerRequests.js +760 -760
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +40 -40
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/playback-readiness.js +696 -696
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +992 -992
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +539 -539
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1931 -1908
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1240 -1240
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +382 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/episode-naming.js +182 -0
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-pool.js +2675 -2675
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/client.js +765 -765
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/pool-adapter.js +371 -371
- package/services/torrent/worker/protocol.js +184 -184
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/torrent/worker/worker.js +1129 -1129
- package/services/transport/data-channel-handler.js +1369 -1369
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +691 -691
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -71
- package/test/asset-paths.test.js +28 -28
- package/test/audio-inventory.test.js +212 -212
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/data-channel-viewer-lifecycle.test.js +92 -92
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +43 -43
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +162 -162
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/episode-naming.test.js +131 -0
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-presentation-clock.test.js +147 -147
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +401 -401
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +595 -595
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +403 -365
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/playback-readiness.test.js +453 -453
- package/test/playback-start-regressions.test.js +99 -99
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +12 -2
- package/test/source-registry.test.js +18 -18
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +209 -209
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
- package/utils/torrent-source-key.js +38 -38
|
@@ -1,102 +1,102 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One demand register and one swarm selection per torrent, found from the
|
|
3
|
-
* torrent itself.
|
|
4
|
-
*
|
|
5
|
-
* The same shape the piece store already uses — `pieceStoreOf(torrent)`
|
|
6
|
-
* reaches the store without it being threaded through every call — and for the
|
|
7
|
-
* same reason: the reader, the pool and the background fill all need the same
|
|
8
|
-
* instance, and passing it through six layers of arguments would make the
|
|
9
|
-
* argument lists bigger than the thing they carry.
|
|
10
|
-
*
|
|
11
|
-
* Kept in a live set as well as a weak map, because one question cannot be
|
|
12
|
-
* answered per torrent: whether ANYTHING anywhere is still missing something
|
|
13
|
-
* urgent. The link and the machine are shared between torrents, so a viewer
|
|
14
|
-
* starving on one film must stop the speculative fetching on the other. Asked
|
|
15
|
-
* per torrent, that question has the wrong answer.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { DemandRegister } from "../demand/DemandRegister.js";
|
|
19
|
-
import { SwarmSelection } from "./SwarmSelection.js";
|
|
20
|
-
|
|
21
|
-
/** @type {WeakMap<object, { register: DemandRegister, selection: SwarmSelection }>} */
|
|
22
|
-
const byTorrent = new WeakMap();
|
|
23
|
-
/** @type {Set<{ register: DemandRegister, selection: SwarmSelection }>} */
|
|
24
|
-
const live = new Set();
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The register and selection for a torrent, made on first use.
|
|
28
|
-
*
|
|
29
|
-
* @param {object} torrent
|
|
30
|
-
* @returns {{ register: DemandRegister, selection: SwarmSelection }}
|
|
31
|
-
*/
|
|
32
|
-
export function demandFor(torrent) {
|
|
33
|
-
const held = byTorrent.get(torrent);
|
|
34
|
-
if (held) {
|
|
35
|
-
return held;
|
|
36
|
-
}
|
|
37
|
-
const register = new DemandRegister();
|
|
38
|
-
const entry = { register, selection: new SwarmSelection({ torrent, register }) };
|
|
39
|
-
byTorrent.set(torrent, entry);
|
|
40
|
-
live.add(entry);
|
|
41
|
-
return entry;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Whether this torrent is still short of anything anybody asked for.
|
|
46
|
-
*
|
|
47
|
-
* Read by the upload policy: while a reader is missing bytes, a little upload
|
|
48
|
-
* buys the reciprocity that gets them; once nothing declared is missing it buys
|
|
49
|
-
* nothing at all, and on 2026-09-11 the proxy went on offering 512 KB/s of a
|
|
50
|
-
* fully downloaded file to 596 peers for forty-eight minutes, reading a 4 MB
|
|
51
|
-
* piece off the disk for every 16 KB it sent.
|
|
52
|
-
*
|
|
53
|
-
* False for a torrent nothing has been stated about, which is the same answer
|
|
54
|
-
* and the right one: nobody is waiting for it.
|
|
55
|
-
*
|
|
56
|
-
* @param {object} torrent
|
|
57
|
-
* @returns {boolean}
|
|
58
|
-
*/
|
|
59
|
-
export function hasUnmetDemand(torrent) {
|
|
60
|
-
const held = byTorrent.get(torrent);
|
|
61
|
-
return held ? held.selection.hasUrgentMissing() : false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Give up everything stated for a torrent that is going.
|
|
66
|
-
*
|
|
67
|
-
* @param {object} torrent
|
|
68
|
-
* @returns {void}
|
|
69
|
-
*/
|
|
70
|
-
export function forgetTorrent(torrent) {
|
|
71
|
-
const held = byTorrent.get(torrent);
|
|
72
|
-
if (!held) {
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
held.selection.releaseAll();
|
|
76
|
-
held.register.clear();
|
|
77
|
-
byTorrent.delete(torrent);
|
|
78
|
-
live.delete(held);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Bring every torrent's download set into line with what is stated.
|
|
83
|
-
*
|
|
84
|
-
* The cross-torrent rule lives here and not in a selection, because it is not a
|
|
85
|
-
* per-torrent question: two films on one proxy share the link, so filling the
|
|
86
|
-
* tail of one while a viewer of the other has a still picture spends the same
|
|
87
|
-
* bandwidth twice over. The answer is worked out once and given to all.
|
|
88
|
-
*
|
|
89
|
-
* @returns {{ torrents: number, speculativeAllowed: boolean, stated: number, withdrawn: number }}
|
|
90
|
-
*/
|
|
91
|
-
export function reconcileAll() {
|
|
92
|
-
const entries = [...live];
|
|
93
|
-
const speculativeAllowed = !entries.some((entry) => entry.selection.hasUrgentMissing());
|
|
94
|
-
let stated = 0;
|
|
95
|
-
let withdrawn = 0;
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
const result = entry.selection.reconcile({ speculativeAllowed });
|
|
98
|
-
stated += result.stated;
|
|
99
|
-
withdrawn += result.withdrawn;
|
|
100
|
-
}
|
|
101
|
-
return { torrents: entries.length, speculativeAllowed, stated, withdrawn };
|
|
102
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file One demand register and one swarm selection per torrent, found from the
|
|
3
|
+
* torrent itself.
|
|
4
|
+
*
|
|
5
|
+
* The same shape the piece store already uses — `pieceStoreOf(torrent)`
|
|
6
|
+
* reaches the store without it being threaded through every call — and for the
|
|
7
|
+
* same reason: the reader, the pool and the background fill all need the same
|
|
8
|
+
* instance, and passing it through six layers of arguments would make the
|
|
9
|
+
* argument lists bigger than the thing they carry.
|
|
10
|
+
*
|
|
11
|
+
* Kept in a live set as well as a weak map, because one question cannot be
|
|
12
|
+
* answered per torrent: whether ANYTHING anywhere is still missing something
|
|
13
|
+
* urgent. The link and the machine are shared between torrents, so a viewer
|
|
14
|
+
* starving on one film must stop the speculative fetching on the other. Asked
|
|
15
|
+
* per torrent, that question has the wrong answer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DemandRegister } from "../demand/DemandRegister.js";
|
|
19
|
+
import { SwarmSelection } from "./SwarmSelection.js";
|
|
20
|
+
|
|
21
|
+
/** @type {WeakMap<object, { register: DemandRegister, selection: SwarmSelection }>} */
|
|
22
|
+
const byTorrent = new WeakMap();
|
|
23
|
+
/** @type {Set<{ register: DemandRegister, selection: SwarmSelection }>} */
|
|
24
|
+
const live = new Set();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The register and selection for a torrent, made on first use.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} torrent
|
|
30
|
+
* @returns {{ register: DemandRegister, selection: SwarmSelection }}
|
|
31
|
+
*/
|
|
32
|
+
export function demandFor(torrent) {
|
|
33
|
+
const held = byTorrent.get(torrent);
|
|
34
|
+
if (held) {
|
|
35
|
+
return held;
|
|
36
|
+
}
|
|
37
|
+
const register = new DemandRegister();
|
|
38
|
+
const entry = { register, selection: new SwarmSelection({ torrent, register }) };
|
|
39
|
+
byTorrent.set(torrent, entry);
|
|
40
|
+
live.add(entry);
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether this torrent is still short of anything anybody asked for.
|
|
46
|
+
*
|
|
47
|
+
* Read by the upload policy: while a reader is missing bytes, a little upload
|
|
48
|
+
* buys the reciprocity that gets them; once nothing declared is missing it buys
|
|
49
|
+
* nothing at all, and on 2026-09-11 the proxy went on offering 512 KB/s of a
|
|
50
|
+
* fully downloaded file to 596 peers for forty-eight minutes, reading a 4 MB
|
|
51
|
+
* piece off the disk for every 16 KB it sent.
|
|
52
|
+
*
|
|
53
|
+
* False for a torrent nothing has been stated about, which is the same answer
|
|
54
|
+
* and the right one: nobody is waiting for it.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} torrent
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function hasUnmetDemand(torrent) {
|
|
60
|
+
const held = byTorrent.get(torrent);
|
|
61
|
+
return held ? held.selection.hasUrgentMissing() : false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Give up everything stated for a torrent that is going.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} torrent
|
|
68
|
+
* @returns {void}
|
|
69
|
+
*/
|
|
70
|
+
export function forgetTorrent(torrent) {
|
|
71
|
+
const held = byTorrent.get(torrent);
|
|
72
|
+
if (!held) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
held.selection.releaseAll();
|
|
76
|
+
held.register.clear();
|
|
77
|
+
byTorrent.delete(torrent);
|
|
78
|
+
live.delete(held);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bring every torrent's download set into line with what is stated.
|
|
83
|
+
*
|
|
84
|
+
* The cross-torrent rule lives here and not in a selection, because it is not a
|
|
85
|
+
* per-torrent question: two films on one proxy share the link, so filling the
|
|
86
|
+
* tail of one while a viewer of the other has a still picture spends the same
|
|
87
|
+
* bandwidth twice over. The answer is worked out once and given to all.
|
|
88
|
+
*
|
|
89
|
+
* @returns {{ torrents: number, speculativeAllowed: boolean, stated: number, withdrawn: number }}
|
|
90
|
+
*/
|
|
91
|
+
export function reconcileAll() {
|
|
92
|
+
const entries = [...live];
|
|
93
|
+
const speculativeAllowed = !entries.some((entry) => entry.selection.hasUrgentMissing());
|
|
94
|
+
let stated = 0;
|
|
95
|
+
let withdrawn = 0;
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const result = entry.selection.reconcile({ speculativeAllowed });
|
|
98
|
+
stated += result.stated;
|
|
99
|
+
withdrawn += result.withdrawn;
|
|
100
|
+
}
|
|
101
|
+
return { torrents: entries.length, speculativeAllowed, stated, withdrawn };
|
|
102
|
+
}
|
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Withdrawing the claim that this proxy has a piece.
|
|
3
|
-
*
|
|
4
|
-
* **The fact has one owner and it is the store.** The store holds the bytes, so
|
|
5
|
-
* it says what this proxy has. The library keeps a second copy of that fact in
|
|
6
|
-
* its completion bitfield, and until 2026-09-12 nothing reconciled the two: the
|
|
7
|
-
* disk tier drops a piece once every reader is past it — correctly, and that is
|
|
8
|
-
* what bounds the spill — while the bitfield went on saying the piece was
|
|
9
|
-
* verified. A read then concluded the piece was had, asked for it, was told it
|
|
10
|
-
* was absent, and failed. Nor was it ever fetched again, because the library
|
|
11
|
-
* does not download what it believes it already owns.
|
|
12
|
-
*
|
|
13
|
-
* Field 2026-09-12: a film played 80 seconds; the encoder ran on to 725 s, so
|
|
14
|
-
* some 565 spilled pieces fell behind every read head and were dropped,
|
|
15
|
-
* including piece 0; the encoder then lost its input and restarted, which
|
|
16
|
-
* re-opens the input at byte 0; and `/stream` answered `0 of 2363497962 bytes:
|
|
17
|
-
* Piece 0 is verified but absent from the store` to every read for the next 92
|
|
18
|
-
* minutes while the browser retried one segment and the picture stood still.
|
|
19
|
-
*
|
|
20
|
-
* So the eviction's own bargain — "a seek back re-downloads it" — is made true
|
|
21
|
-
* here, and the bitfield becomes a projection of what the store holds.
|
|
22
|
-
*
|
|
23
|
-
* **Why it is a plain function.** It takes the values it needs and holds
|
|
24
|
-
* nothing: no pool, no store, no client. That is what lets it be exercised with
|
|
25
|
-
* an object literal, and it is the rule the layers are checked against — a layer
|
|
26
|
-
* must be usable with plain values alone, with no process, no disk and no clock.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Tell the library it no longer has a piece, so the next read of it waits for a
|
|
31
|
-
* download instead of failing.
|
|
32
|
-
*
|
|
33
|
-
* **What it deliberately does not do.** The library's `_markUnverified` would
|
|
34
|
-
* also re-select the piece; it does not here, because every torrent is added
|
|
35
|
-
* with `deselect: true`, which sets the library's own `_startAsDeselected` and
|
|
36
|
-
* makes it skip that call. The download set has exactly one owner —
|
|
37
|
-
* `SwarmSelection`, from the priority map — and a withdrawn piece is fetched
|
|
38
|
-
* again when a read states it, which is the same statement every other piece
|
|
39
|
-
* waits on.
|
|
40
|
-
*
|
|
41
|
-
* @param {object} what
|
|
42
|
-
* @param {number} what.index - The piece the store can no longer produce.
|
|
43
|
-
* @param {object[]} [what.files] - The store's own files; the torrent that owns
|
|
44
|
-
* them is found through one of them, because the store has no idea what a
|
|
45
|
-
* torrent is.
|
|
46
|
-
* @param {object} [what.torrent] - Given directly instead of through `files`.
|
|
47
|
-
* @param {(line: string) => void} [what.warn] - Said when the library refuses.
|
|
48
|
-
* @returns {"withdrawn" | "nothing-to-withdraw" | "no-torrent" | "refused"}
|
|
49
|
-
*/
|
|
50
|
-
export function withdrawClaim({ index, files, torrent, warn = () => undefined }) {
|
|
51
|
-
if (!Number.isInteger(index) || index < 0) {
|
|
52
|
-
return "nothing-to-withdraw";
|
|
53
|
-
}
|
|
54
|
-
const owner = torrent ?? files?.[0]?._torrent ?? null;
|
|
55
|
-
// A torrent being destroyed is the ordinary case at the end of a session, and
|
|
56
|
-
// it has no claim left to withdraw: the store is going with it.
|
|
57
|
-
if (!owner || owner.destroyed || typeof owner._markUnverified !== "function") {
|
|
58
|
-
return "no-torrent";
|
|
59
|
-
}
|
|
60
|
-
// NOTHING TO WITHDRAW, which is most of the time: the store also drops pieces
|
|
61
|
-
// it never completed, and marking one unverified that the library already
|
|
62
|
-
// knows is missing would re-create the piece and discard whatever blocks are
|
|
63
|
-
// in flight for it.
|
|
64
|
-
if (!owner.bitfield?.get?.(index)) {
|
|
65
|
-
return "nothing-to-withdraw";
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
owner._markUnverified(index);
|
|
69
|
-
} catch (error) {
|
|
70
|
-
// This reaches into the library's own bookkeeping. If a later version
|
|
71
|
-
// changes it, the honest result is a line saying so rather than an eviction
|
|
72
|
-
// that fails.
|
|
73
|
-
warn(
|
|
74
|
-
`could not withdraw the claim on piece ${index} of ${owner.name ?? "?"}: ` +
|
|
75
|
-
`${error?.message ?? error}`
|
|
76
|
-
);
|
|
77
|
-
return "refused";
|
|
78
|
-
}
|
|
79
|
-
return "withdrawn";
|
|
80
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Withdrawing the claim that this proxy has a piece.
|
|
3
|
+
*
|
|
4
|
+
* **The fact has one owner and it is the store.** The store holds the bytes, so
|
|
5
|
+
* it says what this proxy has. The library keeps a second copy of that fact in
|
|
6
|
+
* its completion bitfield, and until 2026-09-12 nothing reconciled the two: the
|
|
7
|
+
* disk tier drops a piece once every reader is past it — correctly, and that is
|
|
8
|
+
* what bounds the spill — while the bitfield went on saying the piece was
|
|
9
|
+
* verified. A read then concluded the piece was had, asked for it, was told it
|
|
10
|
+
* was absent, and failed. Nor was it ever fetched again, because the library
|
|
11
|
+
* does not download what it believes it already owns.
|
|
12
|
+
*
|
|
13
|
+
* Field 2026-09-12: a film played 80 seconds; the encoder ran on to 725 s, so
|
|
14
|
+
* some 565 spilled pieces fell behind every read head and were dropped,
|
|
15
|
+
* including piece 0; the encoder then lost its input and restarted, which
|
|
16
|
+
* re-opens the input at byte 0; and `/stream` answered `0 of 2363497962 bytes:
|
|
17
|
+
* Piece 0 is verified but absent from the store` to every read for the next 92
|
|
18
|
+
* minutes while the browser retried one segment and the picture stood still.
|
|
19
|
+
*
|
|
20
|
+
* So the eviction's own bargain — "a seek back re-downloads it" — is made true
|
|
21
|
+
* here, and the bitfield becomes a projection of what the store holds.
|
|
22
|
+
*
|
|
23
|
+
* **Why it is a plain function.** It takes the values it needs and holds
|
|
24
|
+
* nothing: no pool, no store, no client. That is what lets it be exercised with
|
|
25
|
+
* an object literal, and it is the rule the layers are checked against — a layer
|
|
26
|
+
* must be usable with plain values alone, with no process, no disk and no clock.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tell the library it no longer has a piece, so the next read of it waits for a
|
|
31
|
+
* download instead of failing.
|
|
32
|
+
*
|
|
33
|
+
* **What it deliberately does not do.** The library's `_markUnverified` would
|
|
34
|
+
* also re-select the piece; it does not here, because every torrent is added
|
|
35
|
+
* with `deselect: true`, which sets the library's own `_startAsDeselected` and
|
|
36
|
+
* makes it skip that call. The download set has exactly one owner —
|
|
37
|
+
* `SwarmSelection`, from the priority map — and a withdrawn piece is fetched
|
|
38
|
+
* again when a read states it, which is the same statement every other piece
|
|
39
|
+
* waits on.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} what
|
|
42
|
+
* @param {number} what.index - The piece the store can no longer produce.
|
|
43
|
+
* @param {object[]} [what.files] - The store's own files; the torrent that owns
|
|
44
|
+
* them is found through one of them, because the store has no idea what a
|
|
45
|
+
* torrent is.
|
|
46
|
+
* @param {object} [what.torrent] - Given directly instead of through `files`.
|
|
47
|
+
* @param {(line: string) => void} [what.warn] - Said when the library refuses.
|
|
48
|
+
* @returns {"withdrawn" | "nothing-to-withdraw" | "no-torrent" | "refused"}
|
|
49
|
+
*/
|
|
50
|
+
export function withdrawClaim({ index, files, torrent, warn = () => undefined }) {
|
|
51
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
52
|
+
return "nothing-to-withdraw";
|
|
53
|
+
}
|
|
54
|
+
const owner = torrent ?? files?.[0]?._torrent ?? null;
|
|
55
|
+
// A torrent being destroyed is the ordinary case at the end of a session, and
|
|
56
|
+
// it has no claim left to withdraw: the store is going with it.
|
|
57
|
+
if (!owner || owner.destroyed || typeof owner._markUnverified !== "function") {
|
|
58
|
+
return "no-torrent";
|
|
59
|
+
}
|
|
60
|
+
// NOTHING TO WITHDRAW, which is most of the time: the store also drops pieces
|
|
61
|
+
// it never completed, and marking one unverified that the library already
|
|
62
|
+
// knows is missing would re-create the piece and discard whatever blocks are
|
|
63
|
+
// in flight for it.
|
|
64
|
+
if (!owner.bitfield?.get?.(index)) {
|
|
65
|
+
return "nothing-to-withdraw";
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
owner._markUnverified(index);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// This reaches into the library's own bookkeeping. If a later version
|
|
71
|
+
// changes it, the honest result is a line saying so rather than an eviction
|
|
72
|
+
// that fails.
|
|
73
|
+
warn(
|
|
74
|
+
`could not withdraw the claim on piece ${index} of ${owner.name ?? "?"}: ` +
|
|
75
|
+
`${error?.message ?? error}`
|
|
76
|
+
);
|
|
77
|
+
return "refused";
|
|
78
|
+
}
|
|
79
|
+
return "withdrawn";
|
|
80
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a picture's NAME says about which episode it is.
|
|
3
|
+
*
|
|
4
|
+
* A season pack states its structure in names — `s01e02_Murder.in.the.Mews.avi`,
|
|
5
|
+
* `Reacher.S04E01.1080p.rus.LostFilm.TV.mkv`, `[HorribleSubs] Drifters - 01
|
|
6
|
+
* [1080p].mkv` under a folder, or an episode number under `Season_01/`. This
|
|
7
|
+
* reads that statement and nothing else: which season, which episode numbers,
|
|
8
|
+
* which part of an episode, whether it is a special, and the text on either
|
|
9
|
+
* side of the marker.
|
|
10
|
+
*
|
|
11
|
+
* The numbers are the RELEASE's numbering. They are often the same as a
|
|
12
|
+
* metadata provider's and sometimes not — one release numbers the two parts of
|
|
13
|
+
* a feature-length episode as two episodes, which shifts every number after
|
|
14
|
+
* them — so nothing here claims to know which episode of the show a file is.
|
|
15
|
+
* That is decided later, against the provider's own list, by whoever holds it.
|
|
16
|
+
*
|
|
17
|
+
* A name without a marker returns `null`: absence of a number is absence of
|
|
18
|
+
* information, not evidence that the file is not an episode.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { stripExtension } from "./naming.js";
|
|
22
|
+
|
|
23
|
+
/** Most episodes one file may claim before the claim is read as noise. */
|
|
24
|
+
const MAX_EPISODES_PER_FILE = 8;
|
|
25
|
+
|
|
26
|
+
/** Longest hint returned, in characters; a name longer than this is not a title. */
|
|
27
|
+
const MAX_HINT_LENGTH = 160;
|
|
28
|
+
|
|
29
|
+
/** `S01E02`, `s03.e01`, `S01E01E02`, `S01E01-E02`, `S01E01-02`. */
|
|
30
|
+
const SEASON_EPISODE = /(?<![a-z0-9])s(\d{1,3})[ ._]?e(\d{1,4})((?:-?e\d{1,4}|-\d{1,4})*)(?![0-9])/i;
|
|
31
|
+
|
|
32
|
+
/** `1x02`; four-digit sides are picture sizes (`1920x1080`) and never match. */
|
|
33
|
+
const CROSS = /(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![0-9a-z])/i;
|
|
34
|
+
|
|
35
|
+
/** `E05`, `Ep 5`, `Episode.05`, `Серия 5` — an episode number with no season in it. */
|
|
36
|
+
const EPISODE_ONLY = /(?<![\p{L}0-9])(?:e|ep|episode|серия)[ ._-]?(\d{1,4})(?![0-9])/iu;
|
|
37
|
+
|
|
38
|
+
/** `Show - 01`, `Show - 01v2` — the numbering most anime releases use. */
|
|
39
|
+
const DASH_NUMBER = / - (\d{1,4})(?:v\d)?(?=[ .[(]|$)/;
|
|
40
|
+
|
|
41
|
+
/** A folder that names a season: `Season_01`, `Season 1`, `S01`, `Сезон 1`, `1 сезон`. */
|
|
42
|
+
const SEASON_FOLDER = /^(?:season|сезон|s)[ ._-]*(\d{1,3})$|^(\d{1,3})[ ._-]*(?:season|сезон)$/iu;
|
|
43
|
+
|
|
44
|
+
/** `Part.1`, `Pt 2`, `Часть 1`. */
|
|
45
|
+
const PART = /(?<![\p{L}0-9])(?:part|pt|часть)[ ._-]?(\d{1,2})(?![0-9])/iu;
|
|
46
|
+
|
|
47
|
+
/** `Special`, `Specials`, `SP01`. */
|
|
48
|
+
const SPECIAL = /(?<![\p{L}])(?:specials?|sp\d{1,3})(?![\p{L}])/iu;
|
|
49
|
+
|
|
50
|
+
/** Separators a release puts between words, trimmed off the ends of a hint. */
|
|
51
|
+
const EDGE_SEPARATORS = /^[\s._\-–—]+|[\s._\-–—]+$/g;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* An episode marker read from a name.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {object} EpisodeMarker
|
|
57
|
+
* @property {number | null} season - `null` when neither the name nor a folder states one.
|
|
58
|
+
* @property {number[]} episodes - The release's numbers, one or more.
|
|
59
|
+
* @property {number | null} part - Which part of one episode, when the name says so.
|
|
60
|
+
* @property {boolean} special - Season 0, or a name calling itself a special.
|
|
61
|
+
* @property {string} showHint - The name's text before the marker, as written.
|
|
62
|
+
* @property {string} titleHint - The name's text after the marker, part removed, as written.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Trim a piece of a name to a hint.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function hint(text) {
|
|
72
|
+
return String(text ?? "").replace(EDGE_SEPARATORS, "").slice(0, MAX_HINT_LENGTH);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The numbers after the first episode in `S01E01E02` or `S01E01-03`.
|
|
77
|
+
*
|
|
78
|
+
* A `-` joins a range, a repeated `E` lists another episode.
|
|
79
|
+
*
|
|
80
|
+
* @param {number} first
|
|
81
|
+
* @param {string} rest
|
|
82
|
+
* @returns {number[] | null} `null` when the claim is longer than any file carries.
|
|
83
|
+
*/
|
|
84
|
+
function episodesFrom(first, rest) {
|
|
85
|
+
const episodes = [first];
|
|
86
|
+
for (const token of rest.match(/-?e?\d+/gi) ?? []) {
|
|
87
|
+
const value = Number(token.replace(/[^0-9]/g, ""));
|
|
88
|
+
const last = episodes[episodes.length - 1];
|
|
89
|
+
if (token.startsWith("-") && value > last) {
|
|
90
|
+
for (let next = last + 1; next <= value; next += 1) {
|
|
91
|
+
episodes.push(next);
|
|
92
|
+
if (episodes.length > MAX_EPISODES_PER_FILE) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else if (value !== last) {
|
|
97
|
+
episodes.push(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return episodes.length > MAX_EPISODES_PER_FILE ? null : episodes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The season the deepest season-naming folder states, if any does.
|
|
105
|
+
*
|
|
106
|
+
* @param {string[]} folders
|
|
107
|
+
* @returns {number | null}
|
|
108
|
+
*/
|
|
109
|
+
function seasonFromFolders(folders) {
|
|
110
|
+
for (let index = folders.length - 1; index >= 0; index -= 1) {
|
|
111
|
+
const match = SEASON_FOLDER.exec(String(folders[index] ?? "").trim());
|
|
112
|
+
if (match) {
|
|
113
|
+
return Number(match[1] ?? match[2]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Which episode a picture's name says it is.
|
|
121
|
+
*
|
|
122
|
+
* @param {{ name: string, folders?: string[] }} file - File name and the folders above it.
|
|
123
|
+
* @returns {EpisodeMarker | null}
|
|
124
|
+
*/
|
|
125
|
+
export function readEpisodeMarker({ name, folders = [] }) {
|
|
126
|
+
const stem = stripExtension(String(name ?? ""));
|
|
127
|
+
const folderSeason = seasonFromFolders(Array.isArray(folders) ? folders : []);
|
|
128
|
+
|
|
129
|
+
let season = null;
|
|
130
|
+
let episodes = null;
|
|
131
|
+
let before = "";
|
|
132
|
+
let after = "";
|
|
133
|
+
|
|
134
|
+
const seasonEpisode = SEASON_EPISODE.exec(stem);
|
|
135
|
+
const cross = seasonEpisode ? null : CROSS.exec(stem);
|
|
136
|
+
const episodeOnly = seasonEpisode || cross ? null : EPISODE_ONLY.exec(stem);
|
|
137
|
+
const dash = seasonEpisode || cross || episodeOnly ? null : DASH_NUMBER.exec(stem);
|
|
138
|
+
const match = seasonEpisode ?? cross ?? episodeOnly ?? dash;
|
|
139
|
+
if (!match) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
before = stem.slice(0, match.index);
|
|
143
|
+
after = stem.slice(match.index + match[0].length);
|
|
144
|
+
|
|
145
|
+
if (seasonEpisode) {
|
|
146
|
+
season = Number(seasonEpisode[1]);
|
|
147
|
+
episodes = episodesFrom(Number(seasonEpisode[2]), seasonEpisode[3] ?? "");
|
|
148
|
+
} else if (cross) {
|
|
149
|
+
season = Number(cross[1]);
|
|
150
|
+
episodes = [Number(cross[2])];
|
|
151
|
+
} else {
|
|
152
|
+
// `Show S1 - 01` states its season just before the number; a folder is the
|
|
153
|
+
// other place a release puts it.
|
|
154
|
+
const trailingSeason = /(?<![a-z0-9])s(\d{1,3})[\s._-]*$/i.exec(before);
|
|
155
|
+
season = trailingSeason ? Number(trailingSeason[1]) : folderSeason;
|
|
156
|
+
if (trailingSeason) {
|
|
157
|
+
before = before.slice(0, trailingSeason.index);
|
|
158
|
+
}
|
|
159
|
+
episodes = [Number(match[1])];
|
|
160
|
+
}
|
|
161
|
+
// A year where an episode number would be is a year far more often than an
|
|
162
|
+
// episode: `Film - 2024 [1080p]`, and `WALL-E.2008.mkv` reads as `E` then
|
|
163
|
+
// `2008`. Only the markers without a season can be fooled this way.
|
|
164
|
+
if ((dash || episodeOnly) && /^(?:19|20)\d\d$/.test(match[1])) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (!episodes) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const part = PART.exec(after);
|
|
172
|
+
const titleText = part ? `${after.slice(0, part.index)} ${after.slice(part.index + part[0].length)}` : after;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
season,
|
|
176
|
+
episodes,
|
|
177
|
+
part: part ? Number(part[1]) : null,
|
|
178
|
+
special: season === 0 || SPECIAL.test(stem),
|
|
179
|
+
showHint: hint(before),
|
|
180
|
+
titleHint: hint(titleText)
|
|
181
|
+
};
|
|
182
|
+
}
|