@torrent-tv/proxy 2.88.3 → 2.88.4

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.
Files changed (473) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2259 -2250
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/diagnostics/sctpstate.gdb +31 -31
  19. package/bin/cli.js +684 -684
  20. package/bin/thread-pool.js +30 -30
  21. package/biome.json +411 -411
  22. package/docs/browser-proxy-contract.md +23 -4
  23. package/docs/container-architecture.md +218 -218
  24. package/docs/download-architecture.md +261 -261
  25. package/docs/encode-architecture.md +729 -729
  26. package/docs/encode-run-state.md +96 -96
  27. package/docs/logs.md +97 -97
  28. package/docs/socks5-egress-research.md +114 -114
  29. package/docs/storage-architecture.md +267 -267
  30. package/knip.json +14 -14
  31. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  32. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  33. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  34. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  35. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  36. package/openspec/changes/binary-distribution/proposal.md +53 -53
  37. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  38. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  39. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  40. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  41. package/openspec/changes/cold-start/design.md +98 -98
  42. package/openspec/changes/cold-start/proposal.md +62 -62
  43. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  44. package/openspec/changes/cold-start/tasks.md +48 -48
  45. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  46. package/openspec/changes/disk-cap/proposal.md +37 -37
  47. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  48. package/openspec/changes/disk-cap/tasks.md +19 -19
  49. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  50. package/openspec/changes/proxy-observability/design.md +38 -38
  51. package/openspec/changes/proxy-observability/proposal.md +49 -49
  52. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  53. package/openspec/changes/proxy-observability/tasks.md +21 -21
  54. package/openspec/changes/seek-debounce/design.md +118 -118
  55. package/openspec/changes/seek-debounce/proposal.md +59 -59
  56. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  57. package/openspec/changes/seek-debounce/tasks.md +33 -33
  58. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  59. package/openspec/changes/subtitle-language/proposal.md +50 -50
  60. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  61. package/openspec/changes/subtitle-language/tasks.md +26 -26
  62. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  63. package/openspec/changes/track-selection/design.md +47 -47
  64. package/openspec/changes/track-selection/proposal.md +45 -45
  65. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  66. package/openspec/changes/track-selection/tasks.md +20 -20
  67. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  68. package/openspec/changes/transcode-quality/proposal.md +51 -51
  69. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  70. package/openspec/changes/transcode-quality/tasks.md +63 -63
  71. package/openspec/config.yaml +38 -38
  72. package/package.json +1 -1
  73. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  74. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  75. package/research/double-spawn-2026-09-10.md +171 -171
  76. package/research/handover-2026-09-13.md +209 -209
  77. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  78. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  79. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  80. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  81. package/routes/api/client-logs/post.js +86 -86
  82. package/routes/api/delivery-sink/get.js +72 -72
  83. package/routes/api/link-probe/get.js +51 -51
  84. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  85. package/routes/api/playback-plan/post.js +53 -53
  86. package/routes/api/sources/post.js +36 -36
  87. package/routes/api/sources/stats/get.js +132 -132
  88. package/routes/api/sources/warm/post.js +217 -217
  89. package/routes/api/subtitles/get.js +230 -230
  90. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  91. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  92. package/routes/api/transcode-sessions/post.js +178 -173
  93. package/routes/api/transcode-sessions/progress/get.js +27 -27
  94. package/routes/api/transcode-sessions/release/post.js +41 -41
  95. package/routes/api/transcode-sessions/seek/post.js +63 -63
  96. package/routes/health/get.js +13 -13
  97. package/routes/healthz/get.js +13 -13
  98. package/routes/stream/get.js +380 -380
  99. package/routes/transcode/audio-file/get.js +64 -64
  100. package/routes/transcode/audio-warm/get.js +95 -84
  101. package/routes/transcode/session-file/get.js +52 -52
  102. package/routes/transcode/variant-file/get.js +95 -95
  103. package/routes/transcode/variant-warm/get.js +84 -84
  104. package/scripts/render-run-graph.js +173 -173
  105. package/server.js +725 -725
  106. package/services/encode/CoverageMap.js +428 -428
  107. package/services/encode/CushionReport.js +345 -345
  108. package/services/encode/EncodeAdmission.js +376 -376
  109. package/services/encode/EncodeOrchestrator.js +1217 -1217
  110. package/services/encode/EncodePlan.js +1366 -1366
  111. package/services/encode/EncodeRun.js +813 -813
  112. package/services/encode/EncodeRuns.js +1235 -1235
  113. package/services/encode/Encoder.js +142 -142
  114. package/services/encode/EncoderSelection.js +38 -38
  115. package/services/encode/LocalObservations.js +359 -359
  116. package/services/encode/NvencEncoder.js +50 -50
  117. package/services/encode/OutputOpening.js +783 -758
  118. package/services/encode/OutputTimes.js +579 -579
  119. package/services/encode/QsvEncoder.js +58 -58
  120. package/services/encode/Renditions.js +2282 -2184
  121. package/services/encode/RunProgress.js +84 -84
  122. package/services/encode/SegmentDemand.js +95 -95
  123. package/services/encode/SoftwareEncoder.js +121 -121
  124. package/services/encode/V4l2m2mEncoder.js +53 -53
  125. package/services/encode/VaapiEncoder.js +66 -66
  126. package/services/encode/args.js +418 -416
  127. package/services/encode/available-share.js +89 -89
  128. package/services/encode/calibration.js +361 -361
  129. package/services/encode/contention.js +128 -128
  130. package/services/encode/decode-cost-fit.js +363 -363
  131. package/services/encode/encode-exit.js +114 -114
  132. package/services/encode/encode-run-state.js +551 -551
  133. package/services/encode/encoder-readings.js +45 -45
  134. package/services/encode/fingerprint.js +322 -322
  135. package/services/encode/host-load.js +174 -174
  136. package/services/encode/hwaccel.js +1894 -1894
  137. package/services/encode/index.js +9 -9
  138. package/services/encode/learned-median.js +90 -90
  139. package/services/encode/output/EncodedOutput.js +57 -57
  140. package/services/encode/output/Output.js +131 -131
  141. package/services/encode/output/OutputCatalog.js +414 -414
  142. package/services/encode/output/OutputSpec.js +390 -390
  143. package/services/encode/output/Timeline.js +308 -308
  144. package/services/encode/output/cut-grid.js +107 -107
  145. package/services/encode/output/index.js +20 -20
  146. package/services/encode/output/ladder.js +32 -32
  147. package/services/encode/output/playlists.js +284 -284
  148. package/services/encode/output/rates.js +125 -125
  149. package/services/encode/output-key-format.js +21 -21
  150. package/services/encode/quality/EncodeCost.js +1150 -1141
  151. package/services/encode/quality/HostLoad.js +525 -525
  152. package/services/encode/quality/HostTimings.js +249 -249
  153. package/services/encode/quality/OutputQualityState.js +28 -28
  154. package/services/encode/quality/QualityController.js +935 -935
  155. package/services/encode/quality/QualityOffer.js +416 -416
  156. package/services/encode/quality/drain-threat.js +47 -47
  157. package/services/encode/quality/link-budget.js +349 -254
  158. package/services/encode/quality/output-format.js +319 -315
  159. package/services/encode/quality/serving-output.js +216 -216
  160. package/services/encode/quality/visible-rung.js +57 -57
  161. package/services/encode/run-budget.js +97 -97
  162. package/services/encode/run-command.js +754 -754
  163. package/services/encode/run-costs.js +155 -155
  164. package/services/encode/run-inputs.js +42 -42
  165. package/services/encode/segment-formats/fmp4.js +456 -456
  166. package/services/encode/segment-formats/index.js +85 -85
  167. package/services/encode/segment-formats/init-compat.js +324 -324
  168. package/services/encode/segment-formats/mpegts.js +134 -134
  169. package/services/encode/start-stop-cost.js +178 -178
  170. package/services/encode/throughput.js +97 -97
  171. package/services/encode/torrent-cost.js +88 -88
  172. package/services/identity/installation-id.js +38 -38
  173. package/services/media/ContainerOrchestrator.js +149 -149
  174. package/services/media/KeyframeTables.js +316 -316
  175. package/services/media/SourceFile.js +298 -298
  176. package/services/media/SubtitleCues.js +520 -520
  177. package/services/media/SubtitleOrchestrator.js +167 -167
  178. package/services/media/audio-inventory.js +298 -245
  179. package/services/media/container/AviContainer.js +266 -266
  180. package/services/media/container/Container.js +609 -603
  181. package/services/media/container/ContainerFactory.js +56 -56
  182. package/services/media/container/KeyframeTable.js +272 -272
  183. package/services/media/container/MatroskaContainer.js +1494 -1494
  184. package/services/media/container/Mp4Container.js +1344 -1244
  185. package/services/media/container/ebml-reader.js +159 -159
  186. package/services/media/ffmpeg-banner.js +260 -260
  187. package/services/media/keyframe-probe.js +157 -157
  188. package/services/media/media-info-probe.js +93 -93
  189. package/services/media/playback-planner.js +931 -931
  190. package/services/media/tracks/AudioTrack.js +143 -131
  191. package/services/media/tracks/ContainerTrack.js +75 -75
  192. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  193. package/services/media/tracks/SubtitleTrack.js +38 -38
  194. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  195. package/services/media/tracks/VideoTrack.js +33 -33
  196. package/services/media/tracks/audio-bound.js +233 -0
  197. package/services/media/tracks/index.js +15 -15
  198. package/services/media/tracks/language-detect.js +174 -174
  199. package/services/server/OutputLifecycle.js +418 -418
  200. package/services/server/SegmentServing.js +1150 -1150
  201. package/services/server/ViewerRequests.js +760 -760
  202. package/services/server/WaitLedger.js +142 -142
  203. package/services/server/controllers/PlaybackController.js +40 -40
  204. package/services/server/controllers/SubtitleController.js +109 -109
  205. package/services/server/http-responses.js +60 -60
  206. package/services/server/transcode-session-files.js +325 -325
  207. package/services/server/wire-outputs.js +995 -992
  208. package/services/storage/Diagnostics.js +202 -202
  209. package/services/storage/MachineBudget.js +235 -235
  210. package/services/storage/allowance.js +107 -107
  211. package/services/storage/core-dumps.js +106 -106
  212. package/services/storage/files/CompletedFiles.js +391 -391
  213. package/services/storage/files/piece-from-whole-file.js +118 -118
  214. package/services/storage/free.js +71 -71
  215. package/services/storage/keep.js +48 -48
  216. package/services/storage/machine-memory.js +56 -56
  217. package/services/storage/memory-report.js +575 -575
  218. package/services/storage/piece-store/piece-disk-store.js +539 -539
  219. package/services/storage/piece-store/piece-lru.js +495 -495
  220. package/services/storage/piece-store/shared-piece-store.js +1931 -1931
  221. package/services/storage/returns.js +103 -103
  222. package/services/storage/segment-store/SegmentStore.js +1240 -1240
  223. package/services/storage/wire.js +175 -175
  224. package/services/torrent/demand/DemandRegister.js +202 -202
  225. package/services/torrent/demand/Urgency.js +137 -137
  226. package/services/torrent/demand/Window.js +118 -118
  227. package/services/torrent/demand/index.js +8 -8
  228. package/services/torrent/demand/pieces.js +140 -140
  229. package/services/torrent/download/SwarmSelection.js +386 -386
  230. package/services/torrent/download/registry.js +102 -102
  231. package/services/torrent/download/withdraw-claim.js +80 -80
  232. package/services/torrent/episode-naming.js +182 -182
  233. package/services/torrent/files.js +455 -455
  234. package/services/torrent/naming.js +619 -619
  235. package/services/torrent/piece-store-of.js +29 -29
  236. package/services/torrent/supply-margin.js +262 -262
  237. package/services/torrent/torrent-pool.js +2675 -2675
  238. package/services/torrent/worker/background-fill.js +213 -213
  239. package/services/torrent/worker/channel.js +271 -271
  240. package/services/torrent/worker/client.js +765 -765
  241. package/services/torrent/worker/fastest-wires.js +342 -342
  242. package/services/torrent/worker/handle-state.js +23 -23
  243. package/services/torrent/worker/held-bytes.js +155 -155
  244. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  245. package/services/torrent/worker/piece-reader.js +1078 -1078
  246. package/services/torrent/worker/pool-adapter.js +371 -371
  247. package/services/torrent/worker/protocol.js +184 -184
  248. package/services/torrent/worker/resume-warm.js +107 -107
  249. package/services/torrent/worker/webrtc-shim.js +219 -219
  250. package/services/torrent/worker/worker.js +1129 -1129
  251. package/services/transport/data-channel-handler.js +1369 -1369
  252. package/services/transport/delivery-probe.js +842 -842
  253. package/services/transport/health-collector.js +53 -53
  254. package/services/transport/nat-classifier.js +231 -231
  255. package/services/transport/packet-witness.js +808 -808
  256. package/services/transport/port-mapper.js +235 -235
  257. package/services/transport/registry-api.js +81 -81
  258. package/services/transport/tunnel-client.js +447 -447
  259. package/services/transport/usrsctp-state.js +175 -175
  260. package/services/transport/webrtc-manager.js +484 -484
  261. package/services/viewer/Assignments.js +340 -340
  262. package/services/viewer/PriorityMap.js +390 -390
  263. package/services/viewer/PriorityOrchestrator.js +325 -325
  264. package/services/viewer/Viewer.js +691 -691
  265. package/services/viewer/Viewers.js +368 -368
  266. package/services/viewer/active-output.js +37 -37
  267. package/services/viewer/audio-start.js +46 -46
  268. package/services/viewer/buffer-trend.js +81 -81
  269. package/services/viewer/choices.js +647 -647
  270. package/services/viewer/positions.js +126 -126
  271. package/services/viewer/report-intake.js +46 -46
  272. package/store/source-registry.js +71 -71
  273. package/test/asset-paths.test.js +28 -28
  274. package/test/audio-buffer-continuity.test.js +70 -70
  275. package/test/audio-inventory.test.js +212 -212
  276. package/test/audio-track-bitrate.test.js +40 -40
  277. package/test/auto-quality-step.test.js +707 -707
  278. package/test/available-share.test.js +76 -76
  279. package/test/background-fill.test.js +190 -190
  280. package/test/client-log-file.test.js +110 -110
  281. package/test/completed-files.test.js +115 -115
  282. package/test/concurrent-cost.test.js +148 -148
  283. package/test/container-media-info.test.js +228 -228
  284. package/test/contention.test.js +76 -76
  285. package/test/core-dumps.test.js +57 -57
  286. package/test/coverage-follows-the-disk.test.js +228 -228
  287. package/test/coverage-map.test.js +195 -195
  288. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  289. package/test/cut-times-timeline.test.js +65 -65
  290. package/test/cuts-follow-published-grid.test.js +109 -109
  291. package/test/data-channel-frame.test.js +63 -63
  292. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  293. package/test/dead-torrent-handle.test.js +52 -52
  294. package/test/declared-rates.test.js +116 -116
  295. package/test/declared-tracks.test.js +43 -43
  296. package/test/decode-cost-fit.test.js +202 -202
  297. package/test/decode-cost.test.js +462 -462
  298. package/test/delivery-probe.test.js +393 -393
  299. package/test/demand-register.test.js +206 -206
  300. package/test/dht-bootstrap.test.js +72 -72
  301. package/test/drain-threat.test.js +65 -65
  302. package/test/duplicate-add.test.js +48 -48
  303. package/test/encode-admission.test.js +146 -146
  304. package/test/encode-cost.test.js +283 -283
  305. package/test/encode-exit.test.js +111 -111
  306. package/test/encode-orchestrator.test.js +419 -419
  307. package/test/encode-plan-viewers.test.js +718 -718
  308. package/test/encode-plan.test.js +545 -545
  309. package/test/encode-run-state.test.js +314 -314
  310. package/test/encode-run.test.js +162 -162
  311. package/test/encode-slope.test.js +46 -46
  312. package/test/encoded-output.test.js +39 -39
  313. package/test/encoder-kinds.test.js +122 -122
  314. package/test/encoder-readings.test.js +46 -46
  315. package/test/episode-naming.test.js +131 -131
  316. package/test/every-claimant-is-told-its-share.test.js +152 -152
  317. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  318. package/test/failed-start-breaker.test.js +202 -202
  319. package/test/fastest-wires.test.js +143 -143
  320. package/test/ffmpeg-stream-counts.test.js +78 -78
  321. package/test/file-edges.test.js +191 -191
  322. package/test/fixtures/audio-frame-boundary.json +1 -1
  323. package/test/fmp4-presentation-clock.test.js +147 -147
  324. package/test/fmp4-self-contained.test.js +80 -80
  325. package/test/fmp4-timestamps.test.js +140 -140
  326. package/test/fmp4-track-completeness.test.js +41 -41
  327. package/test/format-keyed-outputs.test.js +78 -78
  328. package/test/health-metrics.test.js +38 -38
  329. package/test/held-request-width.test.js +157 -157
  330. package/test/helpers/encode-run.js +136 -136
  331. package/test/helpers/held-file.js +29 -29
  332. package/test/helpers/manager.js +57 -57
  333. package/test/helpers/output-spec.js +63 -63
  334. package/test/host-load.test.js +68 -68
  335. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  336. package/test/init-compat.test.js +219 -219
  337. package/test/input-lost-quiets-the-plan.test.js +261 -261
  338. package/test/input-unavailable.test.js +48 -48
  339. package/test/installation-id.test.js +39 -39
  340. package/test/keeping-period.test.js +83 -83
  341. package/test/keyframe-index-accuracy.test.js +188 -188
  342. package/test/keyframe-table-budget.test.js +102 -102
  343. package/test/keyframes-belong-to-the-file.test.js +249 -249
  344. package/test/learned-median.test.js +37 -37
  345. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  346. package/test/limit-row-by-area.test.js +60 -60
  347. package/test/link-budget.test.js +112 -111
  348. package/test/link-probe.test.js +56 -56
  349. package/test/local-encoding-data.test.js +273 -273
  350. package/test/logger-repeats.test.js +134 -134
  351. package/test/look-ahead-coverage.test.js +56 -56
  352. package/test/manager-surface.test.js +292 -292
  353. package/test/matroska-cues-track.test.js +192 -192
  354. package/test/memory-budget.test.js +390 -390
  355. package/test/move-cost.test.js +216 -216
  356. package/test/mp4-subtitles.test.js +173 -173
  357. package/test/one-authority.test.js +285 -285
  358. package/test/one-budget.test.js +149 -149
  359. package/test/one-output-one-session.test.js +270 -204
  360. package/test/one-resource-divided.test.js +185 -185
  361. package/test/one-serving-output-per-height.test.js +458 -458
  362. package/test/orchestrator-wired.test.js +206 -206
  363. package/test/output-catalog.test.js +39 -39
  364. package/test/output-shape.test.js +21 -21
  365. package/test/output-spec.test.js +251 -251
  366. package/test/output-speed.test.js +88 -88
  367. package/test/outputs-being-prepared.test.js +40 -40
  368. package/test/packet-witness-ring.test.js +236 -236
  369. package/test/packet-witness.test.js +148 -148
  370. package/test/piece-disk-store.test.js +401 -401
  371. package/test/piece-from-whole-file.test.js +129 -129
  372. package/test/piece-lru.test.js +319 -319
  373. package/test/piece-reader.test.js +162 -162
  374. package/test/piece-selection-offset.test.js +85 -85
  375. package/test/piece-store-eviction.test.js +595 -595
  376. package/test/piece-store-never-refuses.test.js +153 -153
  377. package/test/piece-store-reservations.test.js +403 -403
  378. package/test/piece-store-slow-disk.test.js +159 -159
  379. package/test/piece-tail.test.js +97 -97
  380. package/test/plan-host-timings.test.js +68 -68
  381. package/test/plan-is-steady.test.js +225 -225
  382. package/test/playback-readiness.test.js +453 -453
  383. package/test/playback-start-regressions.test.js +99 -99
  384. package/test/priority-map-download.test.js +174 -174
  385. package/test/priority-map-emptied.test.js +204 -204
  386. package/test/priority-map-per-output.test.js +215 -215
  387. package/test/priority-map.test.js +163 -163
  388. package/test/probe-wedge-certainty.test.js +60 -60
  389. package/test/produced-copy-choice.test.js +213 -213
  390. package/test/published-timeline.test.js +83 -83
  391. package/test/quality-offer.test.js +162 -162
  392. package/test/quality-variants.test.js +1257 -1229
  393. package/test/rate-control-in-the-output.test.js +180 -180
  394. package/test/read-survives-withdrawal.test.js +164 -164
  395. package/test/read-window.test.js +365 -365
  396. package/test/resume-warm.test.js +39 -39
  397. package/test/run-graph-drift.test.js +37 -37
  398. package/test/run-inputs.test.js +80 -80
  399. package/test/run-intervals.test.js +100 -100
  400. package/test/run-position-follows-published-grid.test.js +89 -89
  401. package/test/run-progress.test.js +65 -65
  402. package/test/same-height-switch.test.js +664 -664
  403. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  404. package/test/seek-landing.test.js +109 -109
  405. package/test/seek-target-not-superseded.test.js +195 -195
  406. package/test/segment-closed-on-both-branches.test.js +96 -96
  407. package/test/segment-cut-times.test.js +63 -63
  408. package/test/segment-demand.test.js +80 -80
  409. package/test/segment-hold-supersede.test.js +91 -91
  410. package/test/segment-judged-by-its-own-header.test.js +221 -221
  411. package/test/segment-serve-wiring.test.js +475 -475
  412. package/test/segment-store-eviction.test.js +247 -247
  413. package/test/segment-store.test.js +323 -323
  414. package/test/segment-true-start.test.js +86 -86
  415. package/test/segments-are-shared.test.js +176 -176
  416. package/test/send-chunk-bytes.test.js +86 -86
  417. package/test/serving-existing-output.test.js +105 -102
  418. package/test/serving-output.test.js +102 -102
  419. package/test/shared-piece-store.test.js +310 -310
  420. package/test/sidecar-naming.test.js +142 -142
  421. package/test/soundtrack-rate-bound.test.js +289 -0
  422. package/test/source-file.test.js +133 -133
  423. package/test/source-files-route.test.js +113 -113
  424. package/test/source-registry.test.js +18 -18
  425. package/test/split-a-stretch.test.js +139 -139
  426. package/test/stale-request-after-seek.test.js +158 -158
  427. package/test/startup-readings.test.js +124 -124
  428. package/test/stream-route.test.js +209 -209
  429. package/test/subtitle-cue-framing.test.js +200 -200
  430. package/test/subtitle-cue-source.test.js +102 -102
  431. package/test/subtitle-cue-walk.test.js +370 -370
  432. package/test/subtitle-cursor.test.js +63 -63
  433. package/test/subtitle-defaults.test.js +118 -118
  434. package/test/subtitle-language.test.js +252 -252
  435. package/test/subtitle-track-numbering.test.js +386 -386
  436. package/test/supply-margin.test.js +177 -177
  437. package/test/swarm-follows-readers.test.js +174 -174
  438. package/test/swarm-reach.test.js +130 -130
  439. package/test/swarm-selection.test.js +220 -220
  440. package/test/tail-duplication.test.js +167 -167
  441. package/test/the-channel-has-both-ends.test.js +91 -91
  442. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  443. package/test/thread-pool.test.js +53 -53
  444. package/test/timeline.test.js +84 -84
  445. package/test/torrent-contents.test.js +235 -235
  446. package/test/torrent-cost.test.js +71 -71
  447. package/test/torrent-files.test.js +221 -221
  448. package/test/torrent-naming.test.js +255 -255
  449. package/test/tracks-begin-together.test.js +209 -209
  450. package/test/tunnel-renewal.test.js +167 -167
  451. package/test/two-viewers-one-picture.test.js +504 -407
  452. package/test/upload-hurry.test.js +186 -186
  453. package/test/usrsctp-state.test.js +131 -131
  454. package/test/video-facts.test.js +102 -102
  455. package/test/viewer-assignments.test.js +143 -143
  456. package/test/viewer-outputs.test.js +285 -285
  457. package/test/viewer-presence.test.js +116 -116
  458. package/test/viewer-requests-admission.test.js +89 -89
  459. package/test/viewer-states.test.js +189 -189
  460. package/test/viewer-subtitle-subscription.test.js +96 -96
  461. package/test/viewer.test.js +139 -139
  462. package/test/viewing-generations.test.js +390 -390
  463. package/test/visible-rung.test.js +37 -37
  464. package/test/wait-ledger.test.js +100 -100
  465. package/test/webrtc-shim.test.js +149 -149
  466. package/test/wedge-certainty.test.js +131 -131
  467. package/test/withdraw-piece-claim.test.js +212 -212
  468. package/test/worker-channel.test.js +158 -158
  469. package/utils/client-log-file.js +151 -151
  470. package/utils/logger.js +312 -312
  471. package/utils/parse-range.js +25 -25
  472. package/utils/perf.js +59 -59
  473. package/utils/torrent-source-key.js +38 -38
@@ -1,603 +1,609 @@
1
- /**
2
- * @file Base Container — abstract per RFC 9559 / ISO 14496-12.
3
- *
4
- * A Container knows how to read its own format's track table and index.
5
- * Concrete containers (MatroskaContainer, Mp4Container, AviContainer) implement
6
- * spec-specific parsing. All byte access goes through `readRange(start,end)` so
7
- * the class works over torrent piece windows.
8
- *
9
- * Spec refs:
10
- * - Matroska RFC 9559 §5: EBML, Segment, SeekHead, Tracks, Cues, Clusters
11
- * - MP4 ISO/IEC 14496-12 §8: ftyp, moov, trak, tkhd, mdhd, hdlr, elng, stbl
12
- * - AVI RIFF §: LIST hdrl, idx1
13
- */
14
-
15
- /**
16
- * What one file declares about itself. Every field is either a value the
17
- * container states or `null`, which means the container does not state it —
18
- * a defined absence, not "nobody has looked yet".
19
- *
20
- * @typedef {object} ContainerMediaInfo
21
- * @property {string} format - "matroska" | "mp4" | "avi" | "unknown".
22
- * @property {number | null} durationSeconds
23
- * @property {number | null} startTimeSeconds - Where this file's own timeline
24
- * begins. Two files of one release need not agree on it, and the difference
25
- * is what keeps a soundtrack shipped separately aligned with its picture.
26
- */
27
-
28
- export class Container {
29
- /**
30
- * Two ways of reading, because two readers of one file want different things
31
- * and the file is opened once.
32
- *
33
- * `readRange` fetches what is missing: the head and the track table are
34
- * kilobytes, they are needed before anything can be offered, and the codec
35
- * probe has already pulled the head of every file that plays. `readHeld`
36
- * reads only what is already downloaded and asks the swarm for nothing, which
37
- * is what the cue walk needs — turning subtitles on must not pull bytes the
38
- * viewer is not waiting for. `isHeld` says whether a range can be read that
39
- * way at all, so the walk can leave a cluster for next time instead of
40
- * blocking on it.
41
- *
42
- * They are given per container rather than per call because a container is
43
- * cached per file and both readers want the same parsed head. Where the
44
- * caller supplies only `readRange`, the held reader is that one and every
45
- * range counts as held — which is the right answer for a local file, and for
46
- * a torrent it is the caller's job to say otherwise.
47
- *
48
- * The torrent is NOT passed in and must not be: `readHeld` and `isHeld` are
49
- * the only two facts about it this layer needs, and reducing it to two
50
- * functions is what keeps the container ignorant of where its bytes live.
51
- *
52
- * @param {object} params
53
- * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
54
- * @param {number} params.fileSize
55
- * @param {string} [params.label]
56
- * @param {(start:number,end:number)=>Promise<Buffer|null>} [params.readHeld]
57
- * @param {(start:number,end:number)=>boolean} [params.isHeld]
58
- */
59
- constructor({ readRange, fileSize, label = "", readHeld, isHeld }) {
60
- this.readRange = readRange;
61
- this.fileSize = fileSize;
62
- this.label = label;
63
- this.readHeld = typeof readHeld === "function" ? readHeld : readRange;
64
- this.isHeld = typeof isHeld === "function" ? isHeld : () => true;
65
- }
66
-
67
-
68
- /**
69
- * Whether one of ffmpeg's banner streams and one of this container's tracks
70
- * can be the same track.
71
- *
72
- * @param {{ language?: string, title?: string }} banner
73
- * @param {{ language?: string, name?: string }} container
74
- * @returns {boolean}
75
- */
76
- static pairingHolds(banner, container) {
77
- return pairingHolds(banner, container);
78
- }
79
-
80
- /**
81
- * ffmpeg's description of a file's subtitle streams, corrected by what the
82
- * container itself declares.
83
- *
84
- * The banner cannot express the difference between "no track is marked" and
85
- * "every track is marked", because Matroska's `FlagDefault` defaults to 1 and
86
- * ffmpeg has already applied that default by the time it prints. The
87
- * container can be asked, and this is where the two readings are lined up —
88
- * by position, checked pair by pair rather than assumed.
89
- *
90
- * @param {object[]} bannerTracks
91
- * @param {object[]} declared - This container's own subtitle tracks, in its order.
92
- * @returns {object[]}
93
- */
94
- /**
95
- * The picture's facts, from the two readings that state them.
96
- *
97
- * Audio and subtitles have had this since the flags were first read from the
98
- * file; video never did. Every figure the encode is planned from — the size,
99
- * the frame rate, whether it is HDR, how many bits a sample carries — was
100
- * taken from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
101
- * declares was read and then used for nothing but a line in the log.
102
- *
103
- * Which reading wins is decided per field by what each one IS, and the rule
104
- * is the one `readMediaInfo` already states: a fact the container DECLARES is
105
- * read from the container; a fact only the media has is measured from the
106
- * media.
107
- *
108
- * - the coded size and the frame rate are the BANNER's. Both are declared by
109
- * the container as well, but what the encoder receives is what the decoder
110
- * produced, and the ladder and the scale filter have to be sized to that. A
111
- * container that declares something else is mis-declaring, and using its
112
- * numbers would size the encode to a picture that never arrives;
113
- * - the bit depth and the HDR signalling are the CONTAINER's where it states
114
- * them. They are not properties of the decoded frames at all — they are the
115
- * file saying how its samples are to be read — and ffmpeg prints them only
116
- * as a side effect of naming a pixel format. HDR is not compared for
117
- * disagreement: both sides give a boolean, and a boolean cannot say "I did
118
- * not look";
119
- * - the display size is the container's alone; the banner has no such field.
120
- *
121
- * Where only one side states a field, that side answers whatever the rule
122
- * would have preferred. Where both state it and they DISAGREE, the
123
- * disagreement is reported: it is a fact about the file, and until now
124
- * nothing could see it.
125
- *
126
- * @param {{ width?: number|null, height?: number|null, fps?: number|null, isHdr?: boolean, bitDepth?: number|null }} banner
127
- * @param {object | null} declared - The container's own `VideoTrack`.
128
- * @returns {{ width: number|null, height: number|null, fps: number|null, isHdr: boolean, bitDepth: number|null, displayWidth: number|null, displayHeight: number|null, disagreements: string[] }}
129
- */
130
- static mergeVideoFacts(banner, declared) {
131
- const number = (value) => (Number.isFinite(value) && value > 0 ? Number(value) : null);
132
- const fromBanner = {
133
- width: number(banner?.width),
134
- height: number(banner?.height),
135
- fps: number(banner?.fps),
136
- isHdr: banner?.isHdr === true,
137
- bitDepth: number(banner?.bitDepth)
138
- };
139
- if (!declared) {
140
- return { ...fromBanner, displayWidth: null, displayHeight: null, disagreements: [] };
141
- }
142
- const fromContainer = {
143
- width: number(declared.width),
144
- height: number(declared.height),
145
- fps: number(declared.fps),
146
- isHdr: declared.isHdr === true,
147
- bitDepth: number(declared.bitDepth)
148
- };
149
- const disagreements = [];
150
- const note = (field, mine, theirs) => {
151
- if (mine !== null && theirs !== null && mine !== theirs) {
152
- disagreements.push(`${field} ${theirs} in the container against ${mine} in the probe`);
153
- }
154
- };
155
- note("width", fromBanner.width, fromContainer.width);
156
- note("height", fromBanner.height, fromContainer.height);
157
- if (fromBanner.bitDepth !== null && fromContainer.bitDepth !== null && fromBanner.bitDepth !== fromContainer.bitDepth) {
158
- disagreements.push(
159
- `bit depth ${fromContainer.bitDepth} in the container against ${fromBanner.bitDepth} in the probe`
160
- );
161
- }
162
- // HDR is deliberately NOT compared. Both sides give it as a boolean, and a
163
- // boolean cannot say "I did not look": a container with no Colour element
164
- // and one that states SDR are the same `false`, as are a probe that printed
165
- // no colour metadata and one that printed BT.709. Reporting that as a
166
- // disagreement would report it on almost every file. Either side saying yes
167
- // is taken as yes, which is the safe direction — the cost of tone mapping a
168
- // picture that did not need it is smaller than showing a washed-out one.
169
- return {
170
- width: fromBanner.width ?? fromContainer.width,
171
- height: fromBanner.height ?? fromContainer.height,
172
- fps: fromBanner.fps ?? fromContainer.fps,
173
- // The container declares these; the probe only reflects them.
174
- bitDepth: fromContainer.bitDepth ?? fromBanner.bitDepth,
175
- isHdr: fromContainer.isHdr || fromBanner.isHdr,
176
- displayWidth: number(declared.displayWidth),
177
- displayHeight: number(declared.displayHeight),
178
- disagreements
179
- };
180
- }
181
-
182
- /**
183
- * Line ffmpeg's banner up with what the container declares, and say whether the
184
- * two are describing the same thing in the same order.
185
- *
186
- * ffmpeg numbers a file's streams `0:a:N` / `0:s:N` over every stream of that
187
- * kind, in the order the container declares them; the container reading is a
188
- * list in that same order. So position is the correspondence — but a position
189
- * match that is merely assumed is worth nothing, and it is CHECKED: each pair
190
- * has to agree on language or on title. One pair that agrees on neither, or a
191
- * length that differs, means the two readings are not about the same thing, and
192
- * then the container reading is not used AT ALL. A wrong flag is worse than a
193
- * missing one, because `0:a:N` is what the encoder is given.
194
- *
195
- * One alignment for every media kind, because it is one rule. It was written
196
- * twice — once for subtitles, once for audio — down to a `pairingHolds` that
197
- * was byte-for-byte the same function under two names.
198
- *
199
- * @param {object[]} bannerTracks
200
- * @param {object[]} declared
201
- * @param {string} noun - "subtitle" or "audio"; only for the reason text.
202
- * @returns {{ aligned: boolean, reason: string, banner: object[], container: object[] }}
203
- */
204
- static alignWithBanner(bannerTracks, declared, noun) {
205
- const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
206
- const container = Array.isArray(declared) ? declared : [];
207
- if (banner.length === 0) {
208
- return { aligned: false, reason: `the probe found no ${noun} stream`, banner, container };
209
- }
210
- if (container.length === 0) {
211
- return { aligned: false, reason: `the container declares no ${noun} track`, banner, container };
212
- }
213
- if (container.length !== banner.length) {
214
- return {
215
- aligned: false,
216
- reason: `the container declares ${container.length} ${noun} tracks and the probe found ${banner.length}`,
217
- banner,
218
- container
219
- };
220
- }
221
- for (const [order, track] of banner.entries()) {
222
- if (!Container.pairingHolds(track, container[order])) {
223
- return {
224
- aligned: false,
225
- reason:
226
- `${noun} ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
227
- `in the probe and "${normalise(container[order]?.name) || "-"}"/` +
228
- `${normalise(container[order]?.language) || "-"} in the container`,
229
- banner,
230
- container
231
- };
232
- }
233
- }
234
- return { aligned: true, reason: "", banner, container };
235
- }
236
-
237
- /**
238
- * What the banner says, with the flags only the container knows added — or the
239
- * banner alone where the two could not be lined up.
240
- *
241
- * `take` says which of the container's fields this kind of track wants, and is
242
- * the only part that differs between them.
243
- *
244
- * @param {object[]} bannerTracks
245
- * @param {object[]} declared
246
- * @param {string} noun
247
- * @param {(containerTrack: object, bannerTrack: object) => object} take
248
- * @param {object} absent - The same fields as `take` returns, for a track whose
249
- * container reading could not be used. Not "the container says no" — the
250
- * container has not been heard from.
251
- * @returns {{ tracks: object[], aligned: boolean, reason: string }}
252
- */
253
- static mergeDeclaredFlags(bannerTracks, declared, noun, take, absent) {
254
- const { aligned, reason, banner, container } = Container.alignWithBanner(bannerTracks, declared, noun);
255
- if (!aligned) {
256
- return {
257
- tracks: banner.map((track) => ({ ...track, ...absent })),
258
- aligned,
259
- reason
260
- };
261
- }
262
- return {
263
- tracks: banner.map((track, order) => ({ ...track, ...take(container[order], track) })),
264
- aligned: true,
265
- reason: ""
266
- };
267
- }
268
-
269
- static mergeSubtitleFlags(bannerTracks, declared) {
270
- return Container.mergeDeclaredFlags(
271
- bannerTracks,
272
- declared,
273
- "subtitle",
274
- (track) => ({
275
- // Matroska applies `eng` when Language is absent, and ffmpeg reports
276
- // that default as if the file had stated it. Preserve only an explicit
277
- // container value; otherwise let cue-text detection decide later.
278
- language: track.declaresLanguage === true
279
- ? (track.languageBcp47 || track.language || "")
280
- : "",
281
- declaresLanguage: track.declaresLanguage === true,
282
- isDefault: track.isDefault === true,
283
- declaresDefault: track.declaresDefault === true,
284
- // Read from the file rather than guessed from the track's name. Both
285
- // are stated by the container itself (RFC 9559 §5.1.4.1) and neither
286
- // reaches ffmpeg's `-i` banner, which is where every other field here
287
- // comes from.
288
- isForced: track.isForced === true,
289
- isHearingImpaired: track.isHearingImpaired === true,
290
- // FlagEnabled, so the browser can leave an unusable track out of the
291
- // menu. It stays in the list and keeps its number: ffmpeg creates a
292
- // stream for it either way.
293
- isEnabled: track.isEnabled !== false,
294
- // The RFC 5646 tag where the file writes one, kept BESIDE the code
295
- // rather than replacing it: this list is aligned against ffmpeg's
296
- // banner, which prints the three-letter form.
297
- languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : ""
298
- }),
299
- {
300
- declaresDefault: false,
301
- isForced: false,
302
- isHearingImpaired: false,
303
- isEnabled: true,
304
- languageBcp47: ""
305
- }
306
- );
307
- }
308
-
309
- /**
310
- * The same for audio, with the flags RFC 9559 §5.1.4.1 defines for it.
311
- *
312
- * `FlagOriginal`, `FlagCommentary` and `FlagVisualImpaired` do not appear in
313
- * the banner at all, so without this the audio menu cannot tell a director's
314
- * commentary from the film.
315
- *
316
- * @param {object[]} bannerTracks
317
- * @param {object[]} declared
318
- * @returns {{ tracks: object[], aligned: boolean, reason: string }}
319
- */
320
- static mergeAudioFlags(bannerTracks, declared) {
321
- return Container.mergeDeclaredFlags(
322
- bannerTracks,
323
- declared,
324
- "audio",
325
- (track, banner) => ({
326
- isOriginal: track.isOriginal === true,
327
- isCommentary: track.isCommentary === true,
328
- isVisualImpaired: track.isVisualImpaired === true,
329
- isEnabled: track.isEnabled !== false,
330
- isDefault: track.isDefault === true,
331
- declaresDefault: track.declaresDefault === true,
332
- languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : "",
333
- channels: Number.isFinite(track.channels) ? track.channels : null,
334
- title:
335
- typeof banner?.title === "string" && banner.title.length > 0
336
- ? banner.title
337
- : (typeof track.name === "string" ? track.name : "")
338
- }),
339
- {
340
- declaresDefault: false,
341
- isOriginal: false,
342
- isCommentary: false,
343
- isVisualImpaired: false,
344
- isEnabled: true,
345
- languageBcp47: "",
346
- channels: null
347
- }
348
- );
349
- }
350
-
351
-
352
- /** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
353
- get formatName() {
354
- return "unknown";
355
- }
356
-
357
- /** Whether `head` (first bytes) looks like this container. */
358
- static detect(_head) {
359
- return false;
360
- }
361
-
362
- /**
363
- * All tracks declared by the container, in container order.
364
- * Includes disabled tracks (isEnabled=false) to preserve declaredIndex alignment with ffmpeg.
365
- * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
366
- */
367
- async readTracks() {
368
- throw new Error("readTracks not implemented");
369
- }
370
-
371
-
372
- /**
373
- * This container's subtitle tracks and where their cues are, in ONE shape
374
- * whichever container answers.
375
- *
376
- * `tracks` carries a `clusterPositions` list for a container that stores cues
377
- * in clusters and a `samples` list for one that states each cue's own byte
378
- * range; a caller reads neither, and asks {@link Container#readHeldCues}
379
- * instead. `declared` is what the container says about its subtitle tracks in
380
- * its own order, and empty means the container said nothing — a real answer,
381
- * not a missing one.
382
- *
383
- * @returns {Promise<{ tracks: object[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
384
- * Null where this container declares no subtitles at all.
385
- */
386
- async readSubtitlePlan() {
387
- return null;
388
- }
389
-
390
- /**
391
- * The cues this container can read RIGHT NOW for one track, without fetching.
392
- *
393
- * Every container answers this, and each reads what its own specification
394
- * says: Matroska walks the clusters its Cues table names, an MP4 reads the
395
- * samples its table states. The caller therefore chooses a container once —
396
- * from the bytes — and never again. It used to choose twice, once by file
397
- * extension for the container and once by whether a track carried a sample
398
- * list for the reading, and two choices that must agree and are made from
399
- * different evidence are a disagreement waiting to happen.
400
- *
401
- * `progress` is what has already been read, kept by the caller because it
402
- * belongs to the file rather than to one pass: `walked` holds cluster
403
- * positions, `harvested` holds sample offsets per track. Each container adds
404
- * to the one it uses.
405
- *
406
- * @param {object} _plan - This file's subtitle plan.
407
- * @param {object} _track - The track asked about.
408
- * @param {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
409
- * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
410
- * `found` is track number to the cues found in THIS pass — Matroska fills
411
- * every track from one walk, so it is a map and not a list.
412
- */
413
- async readHeldCues(_plan, _track, _progress) {
414
- return { found: new Map(), covered: 0, indexed: 0 };
415
- }
416
-
417
- /**
418
- * Keyframe times for the video track, ascending seconds. Null when index
419
- * absent (MPEG-TS, fragmented MP4, truncated).
420
- *
421
- * **It reads, and does not remember.** It used to keep the answer, and that
422
- * made the table the one fact in this proxy stored in two places: here, and
423
- * in the file's `KeyframeTable` on the main thread. Traced 2026-09-15, this
424
- * copy had no reader: nothing ever asked a container twice for its keyframes.
425
- * The parse itself has since moved to the thread the sessions are on, so
426
- * there is no longer a second thread for a second copy to sit in either.
427
- *
428
- * The remembering lives where the fact is USED, which is also the only place
429
- * that can do the rest of the job: bound how long a viewer waits for it
430
- * while letting the read go on, join a second asker to a read already
431
- * running, and keep the answer reachable by a session created before it
432
- * landed. A container cannot do any of those — it does not know there is a
433
- * viewer — so a copy here was never the whole answer, only a second one.
434
- *
435
- * @returns {Promise<{times:number[],tolerance:number}|null>}
436
- */
437
- async readKeyframeIndex() {
438
- return this.parseKeyframeIndex();
439
- }
440
-
441
- /**
442
- * How THIS format states where its keyframes are. Overridden by every
443
- * container that has such a table; the default is the honest answer for one
444
- * that does not.
445
- *
446
- * @returns {Promise<{times:number[],tolerance:number}|null>}
447
- */
448
- async parseKeyframeIndex() {
449
- return null;
450
- }
451
-
452
- /**
453
- * What this file DECLARES about itself as a whole, as distinct from what its
454
- * individual tracks declare.
455
- *
456
- * The rule this method exists to hold: a fact the container declares is read
457
- * from the container; a fact only the media itself has is measured from the
458
- * media. Both halves used to be asked of ffmpeg, so the same header was read
459
- * twice — measured 2026-09-03, this layer read one `.mka` header in 8 ms while
460
- * a second ffmpeg read the same header over HTTP for 8121 ms, in the same
461
- * second, on the same file.
462
- *
463
- * `null` is not "unknown, ask someone else". It means the container does not
464
- * declare the field, which is a final answer about the container and the point
465
- * at which a caller may go to the media — see `docs/container-architecture.md`.
466
- *
467
- * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
468
- */
469
- async readMediaInfo() {
470
- if (!this.mediaInfo) {
471
- this.mediaInfo = {
472
- format: this.formatName,
473
- durationSeconds: null,
474
- startTimeSeconds: null
475
- };
476
- }
477
- return this.mediaInfo;
478
- }
479
-
480
- /**
481
- * Subtitle-specific: where cues live (Matroska cluster positions or MP4 sample ranges).
482
- * Returned via track objects' clusterPositions/samples, so base has no extra method — tracks carry it.
483
- */
484
-
485
- /**
486
- * The TEXT FIELD of one subtitle cue, taken out of this container's framing.
487
- *
488
- * How a cue's bytes are wrapped is stated by the container's own
489
- * specification, so each subclass answers for itself: Matroska reorders an ASS
490
- * dialogue row, drops its two timing fields and prepends a read order
491
- * (`matroska.org/technical/subtitles.html`); an MP4 prefixes a `tx3g` sample
492
- * with its length (ISO/IEC 14496-12 §12.6); a subtitle FILE states its own
493
- * field order in `[Events]`. None of that is a fact about the subtitle format,
494
- * and the format's own markup — `{\pos(…)}`, `\N` — is not a fact about the
495
- * container. The second half is `TextSubtitleTrack`; this is the
496
- * first, and the two are applied in that order.
497
- *
498
- * Static because de-framing reads no instance state: a caller that has bytes
499
- * and knows the format needs no container built over the whole file. The
500
- * instance form below exists so a caller that DOES hold a container gets the
501
- * right answer without naming the subclass.
502
- *
503
- * @param {Buffer} _payload - The cue's bytes as the container stores them.
504
- * @param {string} _codecId - CodecID / sample entry type / file extension.
505
- * @returns {string} The text field, markup still in place.
506
- */
507
- static cueTextOf(_payload, _codecId) {
508
- throw new Error("cueTextOf not implemented");
509
- }
510
-
511
- /**
512
- * @param {Buffer} payload
513
- * @param {string} codecId
514
- * @returns {string}
515
- */
516
- cueTextOf(payload, codecId) {
517
- return /** @type {typeof Container} */ (this.constructor).cueTextOf(payload, codecId);
518
- }
519
- }
520
-
521
- // ---------------------------------------------------------------------------
522
- // Lining ffmpeg's banner up with what a container declares. Here because the
523
- // correction is about what a CONTAINER states and the banner cannot.
524
- // ---------------------------------------------------------------------------
525
- /**
526
- * Which subtitle track the FILE says to show, read from the file rather than
527
- * from ffmpeg's description of it.
528
- *
529
- * Why this exists. The browser decides which subtitle track to turn on from
530
- * `isDefault`, and until now that came from ffmpeg's `-i` banner, which prints
531
- * `(default)`. In Matroska `FlagDefault` DEFAULTS TO 1 and ffmpeg has already
532
- * applied that default by the time it prints — so a file whose muxer wrote the
533
- * flag on no track arrives looking exactly like one that wrote it on every
534
- * track: everything marked. The banner cannot tell the two apart, and the
535
- * difference is the whole question, because one of them means "show this one"
536
- * and the other means "the file has no opinion".
537
- *
538
- * The container itself can be asked, and the EBML reader already walks the
539
- * Tracks element for subtitle extraction. What it now also records is whether
540
- * the element was WRITTEN, which is the fact the banner destroys.
541
- *
542
- * The awkward part is lining the two readings up. ffmpeg numbers its subtitle
543
- * streams `0:s:0`, `0:s:1`, … over EVERY subtitle stream, picture-based ones
544
- * included, in the order the container declares them; the container reading is
545
- * a list in that same order. So position is the correspondence — but a position
546
- * match that is merely assumed is worth nothing, so it is CHECKED: each pair
547
- * has to agree on language or on title. One pair that agrees on neither, or a
548
- * length that differs, means the two readings are not describing the same
549
- * thing in the same order, and then the container reading is not used at all.
550
- */
551
-
552
- /**
553
- * Language codes that carry no information, and so cannot confirm a pairing.
554
- *
555
- * ffmpeg prints `und` for a stream with no language; Matroska's own default
556
- * for `Language` is `eng`, which is why an absent element cannot be read as a
557
- * statement either — but `eng` is also a real answer, so it is not listed here
558
- * and is compared like any other.
559
- */
560
- const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
561
-
562
- /**
563
- * @param {unknown} value
564
- * @returns {string}
565
- */
566
- function normalise(value) {
567
- return typeof value === "string" ? value.trim().toLowerCase() : "";
568
- }
569
-
570
- /**
571
- * Whether one banner stream and one container track can be the same track.
572
- *
573
- * Agreement on either the language or the name is enough; both being empty is
574
- * not agreement, because two tracks that say nothing about themselves say
575
- * nothing about their pairing either.
576
- *
577
- * @param {{ language?: string, title?: string }} banner
578
- * @param {{ language?: string, name?: string }} container
579
- * @returns {boolean}
580
- */
581
- function pairingHolds(banner, container) {
582
- const bannerLanguage = normalise(banner?.language);
583
- const containerLanguage = normalise(container?.language);
584
- if (
585
- !EMPTY_LANGUAGES.has(bannerLanguage) &&
586
- !EMPTY_LANGUAGES.has(containerLanguage) &&
587
- bannerLanguage === containerLanguage
588
- ) {
589
- return true;
590
- }
591
- const bannerTitle = normalise(banner?.title);
592
- const containerName = normalise(container?.name);
593
- if (bannerTitle.length > 0 && bannerTitle === containerName) {
594
- return true;
595
- }
596
- // Nothing to compare on either side. Not a disagreement — a file may name
597
- // neither — so it does not break the alignment; it simply adds no support.
598
- return (
599
- (EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
600
- (bannerTitle.length === 0 || containerName.length === 0)
601
- );
602
- }
603
-
1
+ /**
2
+ * @file Base Container — abstract per RFC 9559 / ISO 14496-12.
3
+ *
4
+ * A Container knows how to read its own format's track table and index.
5
+ * Concrete containers (MatroskaContainer, Mp4Container, AviContainer) implement
6
+ * spec-specific parsing. All byte access goes through `readRange(start,end)` so
7
+ * the class works over torrent piece windows.
8
+ *
9
+ * Spec refs:
10
+ * - Matroska RFC 9559 §5: EBML, Segment, SeekHead, Tracks, Cues, Clusters
11
+ * - MP4 ISO/IEC 14496-12 §8: ftyp, moov, trak, tkhd, mdhd, hdlr, elng, stbl
12
+ * - AVI RIFF §: LIST hdrl, idx1
13
+ */
14
+
15
+ /**
16
+ * What one file declares about itself. Every field is either a value the
17
+ * container states or `null`, which means the container does not state it —
18
+ * a defined absence, not "nobody has looked yet".
19
+ *
20
+ * @typedef {object} ContainerMediaInfo
21
+ * @property {string} format - "matroska" | "mp4" | "avi" | "unknown".
22
+ * @property {number | null} durationSeconds
23
+ * @property {number | null} startTimeSeconds - Where this file's own timeline
24
+ * begins. Two files of one release need not agree on it, and the difference
25
+ * is what keeps a soundtrack shipped separately aligned with its picture.
26
+ */
27
+
28
+ export class Container {
29
+ /**
30
+ * Two ways of reading, because two readers of one file want different things
31
+ * and the file is opened once.
32
+ *
33
+ * `readRange` fetches what is missing: the head and the track table are
34
+ * kilobytes, they are needed before anything can be offered, and the codec
35
+ * probe has already pulled the head of every file that plays. `readHeld`
36
+ * reads only what is already downloaded and asks the swarm for nothing, which
37
+ * is what the cue walk needs — turning subtitles on must not pull bytes the
38
+ * viewer is not waiting for. `isHeld` says whether a range can be read that
39
+ * way at all, so the walk can leave a cluster for next time instead of
40
+ * blocking on it.
41
+ *
42
+ * They are given per container rather than per call because a container is
43
+ * cached per file and both readers want the same parsed head. Where the
44
+ * caller supplies only `readRange`, the held reader is that one and every
45
+ * range counts as held — which is the right answer for a local file, and for
46
+ * a torrent it is the caller's job to say otherwise.
47
+ *
48
+ * The torrent is NOT passed in and must not be: `readHeld` and `isHeld` are
49
+ * the only two facts about it this layer needs, and reducing it to two
50
+ * functions is what keeps the container ignorant of where its bytes live.
51
+ *
52
+ * @param {object} params
53
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
54
+ * @param {number} params.fileSize
55
+ * @param {string} [params.label]
56
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} [params.readHeld]
57
+ * @param {(start:number,end:number)=>boolean} [params.isHeld]
58
+ */
59
+ constructor({ readRange, fileSize, label = "", readHeld, isHeld }) {
60
+ this.readRange = readRange;
61
+ this.fileSize = fileSize;
62
+ this.label = label;
63
+ this.readHeld = typeof readHeld === "function" ? readHeld : readRange;
64
+ this.isHeld = typeof isHeld === "function" ? isHeld : () => true;
65
+ }
66
+
67
+
68
+ /**
69
+ * Whether one of ffmpeg's banner streams and one of this container's tracks
70
+ * can be the same track.
71
+ *
72
+ * @param {{ language?: string, title?: string }} banner
73
+ * @param {{ language?: string, name?: string }} container
74
+ * @returns {boolean}
75
+ */
76
+ static pairingHolds(banner, container) {
77
+ return pairingHolds(banner, container);
78
+ }
79
+
80
+ /**
81
+ * ffmpeg's description of a file's subtitle streams, corrected by what the
82
+ * container itself declares.
83
+ *
84
+ * The banner cannot express the difference between "no track is marked" and
85
+ * "every track is marked", because Matroska's `FlagDefault` defaults to 1 and
86
+ * ffmpeg has already applied that default by the time it prints. The
87
+ * container can be asked, and this is where the two readings are lined up —
88
+ * by position, checked pair by pair rather than assumed.
89
+ *
90
+ * @param {object[]} bannerTracks
91
+ * @param {object[]} declared - This container's own subtitle tracks, in its order.
92
+ * @returns {object[]}
93
+ */
94
+ /**
95
+ * The picture's facts, from the two readings that state them.
96
+ *
97
+ * Audio and subtitles have had this since the flags were first read from the
98
+ * file; video never did. Every figure the encode is planned from — the size,
99
+ * the frame rate, whether it is HDR, how many bits a sample carries — was
100
+ * taken from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
101
+ * declares was read and then used for nothing but a line in the log.
102
+ *
103
+ * Which reading wins is decided per field by what each one IS, and the rule
104
+ * is the one `readMediaInfo` already states: a fact the container DECLARES is
105
+ * read from the container; a fact only the media has is measured from the
106
+ * media.
107
+ *
108
+ * - the coded size and the frame rate are the BANNER's. Both are declared by
109
+ * the container as well, but what the encoder receives is what the decoder
110
+ * produced, and the ladder and the scale filter have to be sized to that. A
111
+ * container that declares something else is mis-declaring, and using its
112
+ * numbers would size the encode to a picture that never arrives;
113
+ * - the bit depth and the HDR signalling are the CONTAINER's where it states
114
+ * them. They are not properties of the decoded frames at all — they are the
115
+ * file saying how its samples are to be read — and ffmpeg prints them only
116
+ * as a side effect of naming a pixel format. HDR is not compared for
117
+ * disagreement: both sides give a boolean, and a boolean cannot say "I did
118
+ * not look";
119
+ * - the display size is the container's alone; the banner has no such field.
120
+ *
121
+ * Where only one side states a field, that side answers whatever the rule
122
+ * would have preferred. Where both state it and they DISAGREE, the
123
+ * disagreement is reported: it is a fact about the file, and until now
124
+ * nothing could see it.
125
+ *
126
+ * @param {{ width?: number|null, height?: number|null, fps?: number|null, isHdr?: boolean, bitDepth?: number|null }} banner
127
+ * @param {object | null} declared - The container's own `VideoTrack`.
128
+ * @returns {{ width: number|null, height: number|null, fps: number|null, isHdr: boolean, bitDepth: number|null, displayWidth: number|null, displayHeight: number|null, disagreements: string[] }}
129
+ */
130
+ static mergeVideoFacts(banner, declared) {
131
+ const number = (value) => (Number.isFinite(value) && value > 0 ? Number(value) : null);
132
+ const fromBanner = {
133
+ width: number(banner?.width),
134
+ height: number(banner?.height),
135
+ fps: number(banner?.fps),
136
+ isHdr: banner?.isHdr === true,
137
+ bitDepth: number(banner?.bitDepth)
138
+ };
139
+ if (!declared) {
140
+ return { ...fromBanner, displayWidth: null, displayHeight: null, disagreements: [] };
141
+ }
142
+ const fromContainer = {
143
+ width: number(declared.width),
144
+ height: number(declared.height),
145
+ fps: number(declared.fps),
146
+ isHdr: declared.isHdr === true,
147
+ bitDepth: number(declared.bitDepth)
148
+ };
149
+ const disagreements = [];
150
+ const note = (field, mine, theirs) => {
151
+ if (mine !== null && theirs !== null && mine !== theirs) {
152
+ disagreements.push(`${field} ${theirs} in the container against ${mine} in the probe`);
153
+ }
154
+ };
155
+ note("width", fromBanner.width, fromContainer.width);
156
+ note("height", fromBanner.height, fromContainer.height);
157
+ if (fromBanner.bitDepth !== null && fromContainer.bitDepth !== null && fromBanner.bitDepth !== fromContainer.bitDepth) {
158
+ disagreements.push(
159
+ `bit depth ${fromContainer.bitDepth} in the container against ${fromBanner.bitDepth} in the probe`
160
+ );
161
+ }
162
+ // HDR is deliberately NOT compared. Both sides give it as a boolean, and a
163
+ // boolean cannot say "I did not look": a container with no Colour element
164
+ // and one that states SDR are the same `false`, as are a probe that printed
165
+ // no colour metadata and one that printed BT.709. Reporting that as a
166
+ // disagreement would report it on almost every file. Either side saying yes
167
+ // is taken as yes, which is the safe direction — the cost of tone mapping a
168
+ // picture that did not need it is smaller than showing a washed-out one.
169
+ return {
170
+ width: fromBanner.width ?? fromContainer.width,
171
+ height: fromBanner.height ?? fromContainer.height,
172
+ fps: fromBanner.fps ?? fromContainer.fps,
173
+ // The container declares these; the probe only reflects them.
174
+ bitDepth: fromContainer.bitDepth ?? fromBanner.bitDepth,
175
+ isHdr: fromContainer.isHdr || fromBanner.isHdr,
176
+ displayWidth: number(declared.displayWidth),
177
+ displayHeight: number(declared.displayHeight),
178
+ disagreements
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Line ffmpeg's banner up with what the container declares, and say whether the
184
+ * two are describing the same thing in the same order.
185
+ *
186
+ * ffmpeg numbers a file's streams `0:a:N` / `0:s:N` over every stream of that
187
+ * kind, in the order the container declares them; the container reading is a
188
+ * list in that same order. So position is the correspondence — but a position
189
+ * match that is merely assumed is worth nothing, and it is CHECKED: each pair
190
+ * has to agree on language or on title. One pair that agrees on neither, or a
191
+ * length that differs, means the two readings are not about the same thing, and
192
+ * then the container reading is not used AT ALL. A wrong flag is worse than a
193
+ * missing one, because `0:a:N` is what the encoder is given.
194
+ *
195
+ * One alignment for every media kind, because it is one rule. It was written
196
+ * twice — once for subtitles, once for audio — down to a `pairingHolds` that
197
+ * was byte-for-byte the same function under two names.
198
+ *
199
+ * @param {object[]} bannerTracks
200
+ * @param {object[]} declared
201
+ * @param {string} noun - "subtitle" or "audio"; only for the reason text.
202
+ * @returns {{ aligned: boolean, reason: string, banner: object[], container: object[] }}
203
+ */
204
+ static alignWithBanner(bannerTracks, declared, noun) {
205
+ const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
206
+ const container = Array.isArray(declared) ? declared : [];
207
+ if (banner.length === 0) {
208
+ return { aligned: false, reason: `the probe found no ${noun} stream`, banner, container };
209
+ }
210
+ if (container.length === 0) {
211
+ return { aligned: false, reason: `the container declares no ${noun} track`, banner, container };
212
+ }
213
+ if (container.length !== banner.length) {
214
+ return {
215
+ aligned: false,
216
+ reason: `the container declares ${container.length} ${noun} tracks and the probe found ${banner.length}`,
217
+ banner,
218
+ container
219
+ };
220
+ }
221
+ for (const [order, track] of banner.entries()) {
222
+ if (!Container.pairingHolds(track, container[order])) {
223
+ return {
224
+ aligned: false,
225
+ reason:
226
+ `${noun} ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
227
+ `in the probe and "${normalise(container[order]?.name) || "-"}"/` +
228
+ `${normalise(container[order]?.language) || "-"} in the container`,
229
+ banner,
230
+ container
231
+ };
232
+ }
233
+ }
234
+ return { aligned: true, reason: "", banner, container };
235
+ }
236
+
237
+ /**
238
+ * What the banner says, with the flags only the container knows added — or the
239
+ * banner alone where the two could not be lined up.
240
+ *
241
+ * `take` says which of the container's fields this kind of track wants, and is
242
+ * the only part that differs between them.
243
+ *
244
+ * @param {object[]} bannerTracks
245
+ * @param {object[]} declared
246
+ * @param {string} noun
247
+ * @param {(containerTrack: object, bannerTrack: object) => object} take
248
+ * @param {object} absent - The same fields as `take` returns, for a track whose
249
+ * container reading could not be used. Not "the container says no" — the
250
+ * container has not been heard from.
251
+ * @returns {{ tracks: object[], aligned: boolean, reason: string }}
252
+ */
253
+ static mergeDeclaredFlags(bannerTracks, declared, noun, take, absent) {
254
+ const { aligned, reason, banner, container } = Container.alignWithBanner(bannerTracks, declared, noun);
255
+ if (!aligned) {
256
+ return {
257
+ tracks: banner.map((track) => ({ ...track, ...absent })),
258
+ aligned,
259
+ reason
260
+ };
261
+ }
262
+ return {
263
+ tracks: banner.map((track, order) => ({ ...track, ...take(container[order], track) })),
264
+ aligned: true,
265
+ reason: ""
266
+ };
267
+ }
268
+
269
+ static mergeSubtitleFlags(bannerTracks, declared) {
270
+ return Container.mergeDeclaredFlags(
271
+ bannerTracks,
272
+ declared,
273
+ "subtitle",
274
+ (track) => ({
275
+ // Matroska applies `eng` when Language is absent, and ffmpeg reports
276
+ // that default as if the file had stated it. Preserve only an explicit
277
+ // container value; otherwise let cue-text detection decide later.
278
+ language: track.declaresLanguage === true
279
+ ? (track.languageBcp47 || track.language || "")
280
+ : "",
281
+ declaresLanguage: track.declaresLanguage === true,
282
+ isDefault: track.isDefault === true,
283
+ declaresDefault: track.declaresDefault === true,
284
+ // Read from the file rather than guessed from the track's name. Both
285
+ // are stated by the container itself (RFC 9559 §5.1.4.1) and neither
286
+ // reaches ffmpeg's `-i` banner, which is where every other field here
287
+ // comes from.
288
+ isForced: track.isForced === true,
289
+ isHearingImpaired: track.isHearingImpaired === true,
290
+ // FlagEnabled, so the browser can leave an unusable track out of the
291
+ // menu. It stays in the list and keeps its number: ffmpeg creates a
292
+ // stream for it either way.
293
+ isEnabled: track.isEnabled !== false,
294
+ // The RFC 5646 tag where the file writes one, kept BESIDE the code
295
+ // rather than replacing it: this list is aligned against ffmpeg's
296
+ // banner, which prints the three-letter form.
297
+ languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : ""
298
+ }),
299
+ {
300
+ declaresDefault: false,
301
+ isForced: false,
302
+ isHearingImpaired: false,
303
+ isEnabled: true,
304
+ languageBcp47: ""
305
+ }
306
+ );
307
+ }
308
+
309
+ /**
310
+ * The same for audio, with the flags RFC 9559 §5.1.4.1 defines for it.
311
+ *
312
+ * `FlagOriginal`, `FlagCommentary` and `FlagVisualImpaired` do not appear in
313
+ * the banner at all, so without this the audio menu cannot tell a director's
314
+ * commentary from the film.
315
+ *
316
+ * @param {object[]} bannerTracks
317
+ * @param {object[]} declared
318
+ * @returns {{ tracks: object[], aligned: boolean, reason: string }}
319
+ */
320
+ static mergeAudioFlags(bannerTracks, declared) {
321
+ return Container.mergeDeclaredFlags(
322
+ bannerTracks,
323
+ declared,
324
+ "audio",
325
+ (track, banner) => ({
326
+ isOriginal: track.isOriginal === true,
327
+ isCommentary: track.isCommentary === true,
328
+ isVisualImpaired: track.isVisualImpaired === true,
329
+ isEnabled: track.isEnabled !== false,
330
+ isDefault: track.isDefault === true,
331
+ declaresDefault: track.declaresDefault === true,
332
+ languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : "",
333
+ channels: Number.isFinite(track.channels) ? track.channels : null,
334
+ // The codec's configuration and the most it allows the track to
335
+ // carry, both as the container states them (`tracks/audio-bound.js`).
336
+ codecParameters: track.codecParameters ?? null,
337
+ peakKbps: Number.isFinite(track.peakKbps) && track.peakKbps > 0 ? track.peakKbps : null,
338
+ title:
339
+ typeof banner?.title === "string" && banner.title.length > 0
340
+ ? banner.title
341
+ : (typeof track.name === "string" ? track.name : "")
342
+ }),
343
+ {
344
+ declaresDefault: false,
345
+ isOriginal: false,
346
+ isCommentary: false,
347
+ isVisualImpaired: false,
348
+ isEnabled: true,
349
+ languageBcp47: "",
350
+ channels: null,
351
+ codecParameters: null,
352
+ peakKbps: null
353
+ }
354
+ );
355
+ }
356
+
357
+
358
+ /** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
359
+ get formatName() {
360
+ return "unknown";
361
+ }
362
+
363
+ /** Whether `head` (first bytes) looks like this container. */
364
+ static detect(_head) {
365
+ return false;
366
+ }
367
+
368
+ /**
369
+ * All tracks declared by the container, in container order.
370
+ * Includes disabled tracks (isEnabled=false) to preserve declaredIndex alignment with ffmpeg.
371
+ * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
372
+ */
373
+ async readTracks() {
374
+ throw new Error("readTracks not implemented");
375
+ }
376
+
377
+
378
+ /**
379
+ * This container's subtitle tracks and where their cues are, in ONE shape
380
+ * whichever container answers.
381
+ *
382
+ * `tracks` carries a `clusterPositions` list for a container that stores cues
383
+ * in clusters and a `samples` list for one that states each cue's own byte
384
+ * range; a caller reads neither, and asks {@link Container#readHeldCues}
385
+ * instead. `declared` is what the container says about its subtitle tracks in
386
+ * its own order, and empty means the container said nothing — a real answer,
387
+ * not a missing one.
388
+ *
389
+ * @returns {Promise<{ tracks: object[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
390
+ * Null where this container declares no subtitles at all.
391
+ */
392
+ async readSubtitlePlan() {
393
+ return null;
394
+ }
395
+
396
+ /**
397
+ * The cues this container can read RIGHT NOW for one track, without fetching.
398
+ *
399
+ * Every container answers this, and each reads what its own specification
400
+ * says: Matroska walks the clusters its Cues table names, an MP4 reads the
401
+ * samples its table states. The caller therefore chooses a container once —
402
+ * from the bytes — and never again. It used to choose twice, once by file
403
+ * extension for the container and once by whether a track carried a sample
404
+ * list for the reading, and two choices that must agree and are made from
405
+ * different evidence are a disagreement waiting to happen.
406
+ *
407
+ * `progress` is what has already been read, kept by the caller because it
408
+ * belongs to the file rather than to one pass: `walked` holds cluster
409
+ * positions, `harvested` holds sample offsets per track. Each container adds
410
+ * to the one it uses.
411
+ *
412
+ * @param {object} _plan - This file's subtitle plan.
413
+ * @param {object} _track - The track asked about.
414
+ * @param {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
415
+ * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
416
+ * `found` is track number to the cues found in THIS pass — Matroska fills
417
+ * every track from one walk, so it is a map and not a list.
418
+ */
419
+ async readHeldCues(_plan, _track, _progress) {
420
+ return { found: new Map(), covered: 0, indexed: 0 };
421
+ }
422
+
423
+ /**
424
+ * Keyframe times for the video track, ascending seconds. Null when index
425
+ * absent (MPEG-TS, fragmented MP4, truncated).
426
+ *
427
+ * **It reads, and does not remember.** It used to keep the answer, and that
428
+ * made the table the one fact in this proxy stored in two places: here, and
429
+ * in the file's `KeyframeTable` on the main thread. Traced 2026-09-15, this
430
+ * copy had no reader: nothing ever asked a container twice for its keyframes.
431
+ * The parse itself has since moved to the thread the sessions are on, so
432
+ * there is no longer a second thread for a second copy to sit in either.
433
+ *
434
+ * The remembering lives where the fact is USED, which is also the only place
435
+ * that can do the rest of the job: bound how long a viewer waits for it
436
+ * while letting the read go on, join a second asker to a read already
437
+ * running, and keep the answer reachable by a session created before it
438
+ * landed. A container cannot do any of those — it does not know there is a
439
+ * viewer — so a copy here was never the whole answer, only a second one.
440
+ *
441
+ * @returns {Promise<{times:number[],tolerance:number}|null>}
442
+ */
443
+ async readKeyframeIndex() {
444
+ return this.parseKeyframeIndex();
445
+ }
446
+
447
+ /**
448
+ * How THIS format states where its keyframes are. Overridden by every
449
+ * container that has such a table; the default is the honest answer for one
450
+ * that does not.
451
+ *
452
+ * @returns {Promise<{times:number[],tolerance:number}|null>}
453
+ */
454
+ async parseKeyframeIndex() {
455
+ return null;
456
+ }
457
+
458
+ /**
459
+ * What this file DECLARES about itself as a whole, as distinct from what its
460
+ * individual tracks declare.
461
+ *
462
+ * The rule this method exists to hold: a fact the container declares is read
463
+ * from the container; a fact only the media itself has is measured from the
464
+ * media. Both halves used to be asked of ffmpeg, so the same header was read
465
+ * twice — measured 2026-09-03, this layer read one `.mka` header in 8 ms while
466
+ * a second ffmpeg read the same header over HTTP for 8121 ms, in the same
467
+ * second, on the same file.
468
+ *
469
+ * `null` is not "unknown, ask someone else". It means the container does not
470
+ * declare the field, which is a final answer about the container and the point
471
+ * at which a caller may go to the media — see `docs/container-architecture.md`.
472
+ *
473
+ * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
474
+ */
475
+ async readMediaInfo() {
476
+ if (!this.mediaInfo) {
477
+ this.mediaInfo = {
478
+ format: this.formatName,
479
+ durationSeconds: null,
480
+ startTimeSeconds: null
481
+ };
482
+ }
483
+ return this.mediaInfo;
484
+ }
485
+
486
+ /**
487
+ * Subtitle-specific: where cues live (Matroska cluster positions or MP4 sample ranges).
488
+ * Returned via track objects' clusterPositions/samples, so base has no extra method — tracks carry it.
489
+ */
490
+
491
+ /**
492
+ * The TEXT FIELD of one subtitle cue, taken out of this container's framing.
493
+ *
494
+ * How a cue's bytes are wrapped is stated by the container's own
495
+ * specification, so each subclass answers for itself: Matroska reorders an ASS
496
+ * dialogue row, drops its two timing fields and prepends a read order
497
+ * (`matroska.org/technical/subtitles.html`); an MP4 prefixes a `tx3g` sample
498
+ * with its length (ISO/IEC 14496-12 §12.6); a subtitle FILE states its own
499
+ * field order in `[Events]`. None of that is a fact about the subtitle format,
500
+ * and the format's own markup — `{\pos(…)}`, `\N` — is not a fact about the
501
+ * container. The second half is `TextSubtitleTrack`; this is the
502
+ * first, and the two are applied in that order.
503
+ *
504
+ * Static because de-framing reads no instance state: a caller that has bytes
505
+ * and knows the format needs no container built over the whole file. The
506
+ * instance form below exists so a caller that DOES hold a container gets the
507
+ * right answer without naming the subclass.
508
+ *
509
+ * @param {Buffer} _payload - The cue's bytes as the container stores them.
510
+ * @param {string} _codecId - CodecID / sample entry type / file extension.
511
+ * @returns {string} The text field, markup still in place.
512
+ */
513
+ static cueTextOf(_payload, _codecId) {
514
+ throw new Error("cueTextOf not implemented");
515
+ }
516
+
517
+ /**
518
+ * @param {Buffer} payload
519
+ * @param {string} codecId
520
+ * @returns {string}
521
+ */
522
+ cueTextOf(payload, codecId) {
523
+ return /** @type {typeof Container} */ (this.constructor).cueTextOf(payload, codecId);
524
+ }
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Lining ffmpeg's banner up with what a container declares. Here because the
529
+ // correction is about what a CONTAINER states and the banner cannot.
530
+ // ---------------------------------------------------------------------------
531
+ /**
532
+ * Which subtitle track the FILE says to show, read from the file rather than
533
+ * from ffmpeg's description of it.
534
+ *
535
+ * Why this exists. The browser decides which subtitle track to turn on from
536
+ * `isDefault`, and until now that came from ffmpeg's `-i` banner, which prints
537
+ * `(default)`. In Matroska `FlagDefault` DEFAULTS TO 1 and ffmpeg has already
538
+ * applied that default by the time it prints — so a file whose muxer wrote the
539
+ * flag on no track arrives looking exactly like one that wrote it on every
540
+ * track: everything marked. The banner cannot tell the two apart, and the
541
+ * difference is the whole question, because one of them means "show this one"
542
+ * and the other means "the file has no opinion".
543
+ *
544
+ * The container itself can be asked, and the EBML reader already walks the
545
+ * Tracks element for subtitle extraction. What it now also records is whether
546
+ * the element was WRITTEN, which is the fact the banner destroys.
547
+ *
548
+ * The awkward part is lining the two readings up. ffmpeg numbers its subtitle
549
+ * streams `0:s:0`, `0:s:1`, … over EVERY subtitle stream, picture-based ones
550
+ * included, in the order the container declares them; the container reading is
551
+ * a list in that same order. So position is the correspondence — but a position
552
+ * match that is merely assumed is worth nothing, so it is CHECKED: each pair
553
+ * has to agree on language or on title. One pair that agrees on neither, or a
554
+ * length that differs, means the two readings are not describing the same
555
+ * thing in the same order, and then the container reading is not used at all.
556
+ */
557
+
558
+ /**
559
+ * Language codes that carry no information, and so cannot confirm a pairing.
560
+ *
561
+ * ffmpeg prints `und` for a stream with no language; Matroska's own default
562
+ * for `Language` is `eng`, which is why an absent element cannot be read as a
563
+ * statement either — but `eng` is also a real answer, so it is not listed here
564
+ * and is compared like any other.
565
+ */
566
+ const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
567
+
568
+ /**
569
+ * @param {unknown} value
570
+ * @returns {string}
571
+ */
572
+ function normalise(value) {
573
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
574
+ }
575
+
576
+ /**
577
+ * Whether one banner stream and one container track can be the same track.
578
+ *
579
+ * Agreement on either the language or the name is enough; both being empty is
580
+ * not agreement, because two tracks that say nothing about themselves say
581
+ * nothing about their pairing either.
582
+ *
583
+ * @param {{ language?: string, title?: string }} banner
584
+ * @param {{ language?: string, name?: string }} container
585
+ * @returns {boolean}
586
+ */
587
+ function pairingHolds(banner, container) {
588
+ const bannerLanguage = normalise(banner?.language);
589
+ const containerLanguage = normalise(container?.language);
590
+ if (
591
+ !EMPTY_LANGUAGES.has(bannerLanguage) &&
592
+ !EMPTY_LANGUAGES.has(containerLanguage) &&
593
+ bannerLanguage === containerLanguage
594
+ ) {
595
+ return true;
596
+ }
597
+ const bannerTitle = normalise(banner?.title);
598
+ const containerName = normalise(container?.name);
599
+ if (bannerTitle.length > 0 && bannerTitle === containerName) {
600
+ return true;
601
+ }
602
+ // Nothing to compare on either side. Not a disagreement — a file may name
603
+ // neither — so it does not break the alignment; it simply adds no support.
604
+ return (
605
+ (EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
606
+ (bannerTitle.length === 0 || containerName.length === 0)
607
+ );
608
+ }
609
+