@torrent-tv/proxy 2.86.0 → 2.87.1

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 (385) hide show
  1. package/CHANGELOG.md +2142 -1915
  2. package/CLAUDE.md +322 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/browser-proxy-contract.md +94 -0
  7. package/docs/container-architecture.md +218 -213
  8. package/docs/download-architecture.md +7 -7
  9. package/docs/encode-architecture.md +287 -11
  10. package/docs/encode-run-state.md +2 -2
  11. package/docs/logs.md +97 -97
  12. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  13. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  14. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  15. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  18. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  19. package/openspec/changes/cold-start/design.md +1 -1
  20. package/openspec/changes/disk-cap/proposal.md +1 -1
  21. package/openspec/changes/proxy-observability/proposal.md +2 -2
  22. package/openspec/changes/track-selection/proposal.md +1 -1
  23. package/openspec/changes/transcode-quality/proposal.md +1 -1
  24. package/package.json +2 -2
  25. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  26. package/research/double-spawn-2026-09-10.md +171 -171
  27. package/routes/api/playback-plan/post.js +2 -2
  28. package/routes/api/sources/files/get.js +1 -1
  29. package/routes/api/sources/stats/get.js +1 -1
  30. package/routes/api/sources/warm/post.js +10 -3
  31. package/routes/api/subtitles/get.js +230 -230
  32. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  33. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  34. package/routes/api/transcode-sessions/post.js +160 -139
  35. package/routes/api/transcode-sessions/progress/get.js +3 -3
  36. package/routes/api/transcode-sessions/release/post.js +3 -3
  37. package/routes/api/transcode-sessions/seek/post.js +12 -3
  38. package/routes/stream/get.js +2 -2
  39. package/routes/transcode/audio-file/get.js +15 -6
  40. package/routes/transcode/audio-warm/get.js +9 -6
  41. package/routes/transcode/session-file/get.js +52 -268
  42. package/routes/transcode/variant-file/get.js +46 -7
  43. package/routes/transcode/variant-warm/get.js +19 -6
  44. package/scripts/render-run-graph.js +1 -1
  45. package/server.js +720 -465
  46. package/services/encode/CoverageMap.js +428 -428
  47. package/services/encode/CushionReport.js +345 -0
  48. package/services/encode/EncodeAdmission.js +332 -0
  49. package/services/{orchestrators → encode}/EncodeOrchestrator.js +307 -29
  50. package/services/encode/EncodeRun.js +16 -9
  51. package/services/encode/EncodeRuns.js +1229 -0
  52. package/services/encode/Encoder.js +18 -2
  53. package/services/encode/EncoderSelection.js +38 -0
  54. package/services/encode/LocalObservations.js +359 -0
  55. package/services/encode/NvencEncoder.js +5 -0
  56. package/services/encode/OutputOpening.js +755 -0
  57. package/services/encode/OutputTimes.js +579 -0
  58. package/services/encode/Renditions.js +2162 -0
  59. package/services/encode/RunProgress.js +84 -0
  60. package/services/encode/SoftwareEncoder.js +12 -7
  61. package/services/encode/args.js +402 -186
  62. package/services/encode/calibration.js +361 -0
  63. package/services/encode/contention.js +128 -128
  64. package/services/encode/encode-run-state.js +16 -20
  65. package/services/encode/fingerprint.js +322 -0
  66. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  67. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  68. package/services/encode/output/EncodedOutput.js +57 -0
  69. package/services/encode/output/Output.js +131 -0
  70. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  71. package/services/encode/output/OutputSpec.js +390 -0
  72. package/services/{output → encode/output}/Timeline.js +12 -37
  73. package/services/encode/output/index.js +20 -0
  74. package/services/encode/output/ladder.js +32 -0
  75. package/services/{output → encode/output}/playlists.js +3 -0
  76. package/services/encode/output-key-format.js +21 -0
  77. package/services/encode/quality/EncodeCost.js +1083 -0
  78. package/services/encode/quality/HostLoad.js +525 -0
  79. package/services/encode/quality/HostTimings.js +249 -0
  80. package/services/encode/quality/OutputQualityState.js +28 -0
  81. package/services/encode/quality/QualityController.js +935 -0
  82. package/services/encode/quality/QualityOffer.js +416 -0
  83. package/services/encode/quality/drain-threat.js +47 -0
  84. package/services/encode/quality/link-budget.js +254 -0
  85. package/services/encode/quality/output-format.js +266 -0
  86. package/services/encode/quality/serving-output.js +216 -0
  87. package/services/encode/quality/visible-rung.js +57 -0
  88. package/services/encode/run-command.js +23 -20
  89. package/services/encode/run-inputs.js +42 -0
  90. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  91. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  92. package/services/encode/segment-formats/init-compat.js +324 -0
  93. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  94. package/services/encode/throughput.js +97 -0
  95. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  96. package/services/media/KeyframeTables.js +316 -0
  97. package/services/{source → media}/SourceFile.js +7 -55
  98. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  99. package/services/media/SubtitleOrchestrator.js +167 -0
  100. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  101. package/services/{container → media/container}/AviContainer.js +266 -266
  102. package/services/{container → media/container}/Container.js +596 -601
  103. package/services/media/container/ContainerFactory.js +56 -0
  104. package/services/media/container/KeyframeTable.js +272 -0
  105. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  106. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  107. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  108. package/services/media/keyframe-probe.js +157 -0
  109. package/services/media/media-info-probe.js +93 -0
  110. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  111. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  112. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  113. package/services/server/OutputLifecycle.js +418 -0
  114. package/services/server/SegmentServing.js +1146 -0
  115. package/services/server/ViewerRequests.js +522 -0
  116. package/services/server/controllers/PlaybackController.js +36 -0
  117. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  118. package/services/server/http-responses.js +60 -0
  119. package/services/server/transcode-session-files.js +325 -0
  120. package/services/server/wire-outputs.js +983 -0
  121. package/services/storage/Diagnostics.js +202 -0
  122. package/services/storage/MachineBudget.js +235 -0
  123. package/services/{piece-store → storage}/allowance.js +107 -107
  124. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  125. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  126. package/services/{disk → storage}/free.js +33 -0
  127. package/services/storage/machine-memory.js +56 -0
  128. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  129. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  130. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  131. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  132. package/services/storage/wire.js +175 -0
  133. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  134. package/services/{download → torrent/download}/registry.js +2 -2
  135. package/services/torrent/files.js +1 -1
  136. package/services/torrent/piece-store-of.js +29 -0
  137. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  138. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  139. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  140. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  141. package/services/torrent/worker/held-bytes.js +155 -0
  142. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  143. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  144. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  145. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  146. package/services/torrent/worker/resume-warm.js +107 -0
  147. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  148. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  149. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  150. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  151. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  152. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  153. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  154. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  155. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  156. package/services/viewer/Assignments.js +340 -0
  157. package/services/{priority → viewer}/PriorityMap.js +390 -390
  158. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  159. package/services/viewer/Viewer.js +657 -483
  160. package/services/viewer/Viewers.js +368 -257
  161. package/services/viewer/active-output.js +37 -54
  162. package/services/viewer/audio-start.js +4 -3
  163. package/services/viewer/buffer-trend.js +81 -0
  164. package/services/viewer/choices.js +647 -0
  165. package/services/viewer/positions.js +126 -120
  166. package/services/viewer/report-intake.js +46 -45
  167. package/test/asset-paths.test.js +28 -0
  168. package/test/audio-inventory.test.js +3 -3
  169. package/test/audio-track-bitrate.test.js +40 -0
  170. package/test/auto-quality-step.test.js +707 -515
  171. package/test/available-share.test.js +1 -1
  172. package/test/background-fill.test.js +1 -1
  173. package/test/client-log-file.test.js +110 -110
  174. package/test/completed-files.test.js +1 -1
  175. package/test/concurrent-cost.test.js +13 -3
  176. package/test/container-media-info.test.js +3 -3
  177. package/test/core-dumps.test.js +1 -1
  178. package/test/coverage-follows-the-disk.test.js +46 -9
  179. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  180. package/test/cut-times-timeline.test.js +2 -2
  181. package/test/cuts-follow-published-grid.test.js +9 -8
  182. package/test/data-channel-frame.test.js +1 -1
  183. package/test/dead-torrent-handle.test.js +1 -1
  184. package/test/declared-rates.test.js +2 -2
  185. package/test/declared-tracks.test.js +2 -2
  186. package/test/decode-cost-fit.test.js +1 -1
  187. package/test/decode-cost.test.js +34 -25
  188. package/test/delivery-probe.test.js +1 -1
  189. package/test/demand-register.test.js +4 -4
  190. package/test/dht-bootstrap.test.js +1 -1
  191. package/test/drain-threat.test.js +65 -0
  192. package/test/duplicate-add.test.js +1 -1
  193. package/test/encode-admission.test.js +146 -0
  194. package/test/encode-cost.test.js +199 -10
  195. package/test/encode-orchestrator.test.js +39 -1
  196. package/test/encode-plan-viewers.test.js +2 -2
  197. package/test/encode-run.test.js +2 -2
  198. package/test/encode-slope.test.js +1 -1
  199. package/test/encoded-output.test.js +39 -0
  200. package/test/encoder-readings.test.js +1 -1
  201. package/test/every-claimant-is-told-its-share.test.js +152 -0
  202. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  203. package/test/failed-start-breaker.test.js +81 -30
  204. package/test/fastest-wires.test.js +1 -1
  205. package/test/ffmpeg-stream-counts.test.js +1 -1
  206. package/test/file-edges.test.js +3 -3
  207. package/test/fmp4-self-contained.test.js +1 -1
  208. package/test/fmp4-timestamps.test.js +1 -1
  209. package/test/fmp4-track-completeness.test.js +1 -1
  210. package/test/format-keyed-outputs.test.js +78 -0
  211. package/test/health-metrics.test.js +3 -2
  212. package/test/held-request-width.test.js +22 -23
  213. package/test/helpers/encode-run.js +136 -136
  214. package/test/helpers/held-file.js +29 -0
  215. package/test/helpers/manager.js +57 -0
  216. package/test/helpers/output-spec.js +63 -0
  217. package/test/host-load.test.js +1 -1
  218. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  219. package/test/init-compat.test.js +219 -0
  220. package/test/input-lost-quiets-the-plan.test.js +1 -1
  221. package/test/input-unavailable.test.js +1 -1
  222. package/test/keeping-period.test.js +4 -4
  223. package/test/keyframe-index-accuracy.test.js +97 -45
  224. package/test/keyframe-table-budget.test.js +28 -33
  225. package/test/keyframes-belong-to-the-file.test.js +174 -57
  226. package/test/learned-median.test.js +1 -1
  227. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  228. package/test/limit-row-by-area.test.js +60 -0
  229. package/test/link-budget.test.js +111 -0
  230. package/test/link-probe.test.js +56 -56
  231. package/test/local-encoding-data.test.js +273 -0
  232. package/test/look-ahead-coverage.test.js +1 -1
  233. package/test/manager-surface.test.js +294 -0
  234. package/test/matroska-blocks.test.js +0 -0
  235. package/test/matroska-cues-track.test.js +1 -1
  236. package/test/memory-budget.test.js +2 -2
  237. package/test/mp4-composition-times.test.js +0 -0
  238. package/test/mp4-subtitles.test.js +1 -1
  239. package/test/one-authority.test.js +45 -41
  240. package/test/one-budget.test.js +149 -0
  241. package/test/one-output-one-session.test.js +204 -0
  242. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  243. package/test/one-serving-output-per-height.test.js +458 -0
  244. package/test/orchestrator-wired.test.js +46 -45
  245. package/test/output-catalog.test.js +39 -0
  246. package/test/output-shape.test.js +4 -51
  247. package/test/output-spec.test.js +251 -157
  248. package/test/output-speed.test.js +17 -15
  249. package/test/outputs-being-prepared.test.js +40 -0
  250. package/test/packet-witness-ring.test.js +2 -2
  251. package/test/packet-witness.test.js +1 -1
  252. package/test/piece-disk-store.test.js +2 -2
  253. package/test/piece-from-whole-file.test.js +1 -1
  254. package/test/piece-lru.test.js +1 -1
  255. package/test/piece-reader.test.js +2 -2
  256. package/test/piece-store-eviction.test.js +1 -1
  257. package/test/piece-store-never-refuses.test.js +1 -1
  258. package/test/piece-store-reservations.test.js +1 -1
  259. package/test/piece-store-slow-disk.test.js +1 -1
  260. package/test/piece-tail.test.js +1 -1
  261. package/test/plan-host-timings.test.js +1 -1
  262. package/test/plan-is-steady.test.js +225 -225
  263. package/test/priority-map-download.test.js +4 -4
  264. package/test/priority-map-emptied.test.js +204 -205
  265. package/test/priority-map-per-output.test.js +215 -209
  266. package/test/priority-map.test.js +1 -1
  267. package/test/probe-wedge-certainty.test.js +1 -1
  268. package/test/produced-copy-choice.test.js +15 -60
  269. package/test/quality-offer.test.js +162 -0
  270. package/test/quality-variants.test.js +1229 -1167
  271. package/test/rate-control-in-the-output.test.js +180 -0
  272. package/test/read-survives-withdrawal.test.js +2 -2
  273. package/test/read-window.test.js +10 -3
  274. package/test/resume-warm.test.js +1 -1
  275. package/test/run-inputs.test.js +80 -0
  276. package/test/run-position-follows-published-grid.test.js +13 -11
  277. package/test/run-progress.test.js +65 -0
  278. package/test/same-height-switch.test.js +664 -0
  279. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  280. package/test/seek-landing.test.js +13 -13
  281. package/test/seek-target-not-superseded.test.js +28 -29
  282. package/test/segment-closed-on-both-branches.test.js +96 -0
  283. package/test/segment-cut-times.test.js +1 -1
  284. package/test/segment-hold-supersede.test.js +28 -35
  285. package/test/segment-judged-by-its-own-header.test.js +221 -0
  286. package/test/segment-serve-wiring.test.js +130 -39
  287. package/test/segment-store-eviction.test.js +22 -7
  288. package/test/segment-store.test.js +98 -14
  289. package/test/segment-true-start.test.js +1 -1
  290. package/test/segments-are-shared.test.js +17 -16
  291. package/test/send-chunk-bytes.test.js +1 -1
  292. package/test/serving-existing-output.test.js +102 -0
  293. package/test/serving-output.test.js +102 -0
  294. package/test/shared-piece-store.test.js +1 -1
  295. package/test/source-file.test.js +1 -1
  296. package/test/stale-request-after-seek.test.js +17 -21
  297. package/test/startup-readings.test.js +10 -5
  298. package/test/subtitle-cue-framing.test.js +5 -5
  299. package/test/subtitle-cue-source.test.js +36 -38
  300. package/test/subtitle-cue-walk.test.js +11 -10
  301. package/test/subtitle-defaults.test.js +1 -1
  302. package/test/subtitle-language.test.js +4 -4
  303. package/test/subtitle-track-numbering.test.js +5 -4
  304. package/test/supply-margin.test.js +1 -1
  305. package/test/swarm-follows-readers.test.js +3 -3
  306. package/test/swarm-reach.test.js +1 -1
  307. package/test/swarm-selection.test.js +3 -3
  308. package/test/tail-duplication.test.js +1 -1
  309. package/test/the-channel-has-both-ends.test.js +91 -0
  310. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  311. package/test/thread-pool.test.js +3 -3
  312. package/test/timeline.test.js +1 -12
  313. package/test/torrent-cost.test.js +1 -1
  314. package/test/tracks-begin-together.test.js +38 -24
  315. package/test/tunnel-renewal.test.js +1 -1
  316. package/test/two-viewers-one-picture.test.js +120 -87
  317. package/test/upload-hurry.test.js +3 -3
  318. package/test/usrsctp-state.test.js +1 -1
  319. package/test/video-facts.test.js +1 -1
  320. package/test/viewer-assignments.test.js +143 -0
  321. package/test/viewer-outputs.test.js +60 -52
  322. package/test/viewer-presence.test.js +22 -25
  323. package/test/viewer-requests-admission.test.js +89 -0
  324. package/test/viewer-states.test.js +189 -202
  325. package/test/viewer-subtitle-subscription.test.js +1 -1
  326. package/test/viewer.test.js +139 -136
  327. package/test/viewing-generations.test.js +390 -0
  328. package/test/visible-rung.test.js +37 -0
  329. package/test/wait-ledger.test.js +1 -1
  330. package/test/webrtc-shim.test.js +3 -3
  331. package/test/wedge-certainty.test.js +2 -2
  332. package/test/withdraw-piece-claim.test.js +2 -2
  333. package/test/worker-channel.test.js +1 -31
  334. package/utils/client-log-file.js +151 -151
  335. package/scripts/read-container-index.mjs +0 -108
  336. package/services/container/ContainerFactory.js +0 -122
  337. package/services/controllers/PlaybackController.js +0 -33
  338. package/services/disk/DiskSpace.js +0 -150
  339. package/services/disk/wire.js +0 -61
  340. package/services/hls-session-manager.js +0 -9608
  341. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  342. package/services/output/Output.js +0 -94
  343. package/services/output/OutputSpec.js +0 -202
  344. package/services/output/index.js +0 -1
  345. package/services/output/ladder.js +0 -26
  346. package/services/produced-index.js +0 -222
  347. package/services/quality/EncodeCost.js +0 -555
  348. package/services/torrent-worker/container-tracks.js +0 -414
  349. package/services/viewer/link-readings.js +0 -56
  350. package/test/produced-index.test.js +0 -142
  351. /package/{services → bin}/thread-pool.js +0 -0
  352. /package/services/{available-share.js → encode/available-share.js} +0 -0
  353. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  354. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  355. /package/services/{host-load.js → encode/host-load.js} +0 -0
  356. /package/services/{output → encode/output}/cut-grid.js +0 -0
  357. /package/services/{output → encode/output}/rates.js +0 -0
  358. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  359. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  360. /package/services/{container → media/container}/ebml-reader.js +0 -0
  361. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  362. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  365. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  366. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  367. /package/services/{tracks → media/tracks}/index.js +0 -0
  368. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  369. /package/services/{disk → storage}/keep.js +0 -0
  370. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  371. /package/services/{disk → storage}/returns.js +0 -0
  372. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  373. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  374. /package/services/{demand → torrent/demand}/Window.js +0 -0
  375. /package/services/{demand → torrent/demand}/index.js +0 -0
  376. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  377. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  378. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  379. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  380. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  381. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  382. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  383. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  384. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  385. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -1,111 +1,76 @@
1
1
  /**
2
- * @file Subtitle cues gathered from the clusters a viewer has already brought
3
- * in, never from clusters they have not.
2
+ * @file Reading the subtitle cues a file already holds, and the plan of its
3
+ * subtitle tracks.
4
4
  *
5
- * The rule this file exists to keep (stated by the user 2026-08-20): subtitles
6
- * arrive the way the picture does, or they are not offered. So nothing here
7
- * requests a byte. It looks at what the torrent already holds, reads the
8
- * clusters inside it, and returns what it found; the region the viewer is
9
- * watching is downloaded before they reach it, so its cues are ready before
10
- * they are needed. A region nobody has watched has no cues, and that is
11
- * correct — there is nobody to show them to.
5
+ * It walks ONLY what is downloaded: switching subtitles on must never pull
6
+ * bytes the viewer is not waiting for. The head and the Cues table are the one
7
+ * exception and are fetched — they are kilobytes, they are needed before
8
+ * anything can be offered, and the codec probe has already pulled the head for
9
+ * every file that plays.
12
10
  *
13
- * Why not ffmpeg: measured 2026-08-19, extracting one subtitle track of
14
- * `Minions.and.Monsters.1080p.mkv` took **752 seconds** and pulled the download
15
- * from 2.7 % to 81 % of a 6.5 GB film, because a subtitle stream is sparse and
16
- * the demuxer walks the container to the end whatever range is asked of it.
17
- * Reading the clusters costs nothing extra at all.
11
+ * **The torrent is not here, and that is the point.** This layer is handed a
12
+ * `HeldFile`: a name, a length, which ranges are downloaded whole, and how to
13
+ * read one of them without fetching. Piece length, file offsets and the
14
+ * bitfield are the torrent's words and stay in the torrent's thread
15
+ * (`torrent/worker/held-bytes.js`). Until 2026-09-15 this whole file lived
16
+ * there, which put a container parse — the media layer's work — in the thread
17
+ * that owns the swarm, and made every answer something to carry back across a
18
+ * channel.
19
+ *
20
+ * What is genuinely this file's own, and travels with it: the found-order
21
+ * cursor a browser follows, the per-file state, and one walk of a file at a
22
+ * time.
18
23
  */
19
24
 
20
- import { ContainerFactory } from "../container/ContainerFactory.js";
21
- import { TextSubtitleTrack } from "../tracks/TextSubtitleTrack.js";
22
- import { detectLanguage } from "../tracks/language-detect.js";
25
+ import { ContainerFactory } from "./container/ContainerFactory.js";
26
+ import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
27
+ import { detectLanguage } from "./tracks/language-detect.js";
23
28
  import { logger } from "../../utils/logger.js";
24
29
 
25
- /** How long a read of already-held bytes may take before it is given up. */
26
- const READ_ABANDON_MS = 30_000;
27
30
 
28
- /** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
29
- const byFile = new Map();
31
+ /**
32
+ * One file of one torrent, as much of it as this layer is allowed to know.
33
+ *
34
+ * Four plain values and two functions: what it is called, how long it is, which
35
+ * of it is downloaded whole, and how to read a range of that without asking the
36
+ * swarm for anything. No torrent, no bitfield, no piece length — those are the
37
+ * torrent's words, and turning them into these is its job.
38
+ *
39
+ * @typedef {object} HeldFile
40
+ * @property {string} sourceKey
41
+ * @property {number} fileIndex
42
+ * @property {string} name
43
+ * @property {number} length
44
+ * @property {() => Promise<Array<[number, number]>>} heldRanges - Ascending,
45
+ * non-overlapping, inclusive offsets within the file.
46
+ * @property {(start: number, end: number) => Promise<Buffer | null>} readHeld -
47
+ * Null where those bytes are not there, which is never fetched.
48
+ */
30
49
 
31
50
  /**
32
- * Whether every piece covering a byte range is already downloaded.
51
+ * Whether a range falls entirely inside one of the held runs.
33
52
  *
34
- * @param {object} torrent
35
- * @param {object} file
36
- * @param {number} start - Offset within the FILE.
53
+ * @param {Array<[number, number]> | null} ranges
54
+ * @param {number} start
37
55
  * @param {number} end - Inclusive.
38
56
  * @returns {boolean}
39
57
  */
40
- function rangeIsHeld(torrent, file, start, end) {
41
- const pieceLength = Number(torrent?.pieceLength);
42
- const offset = Number(file?.offset) || 0;
43
- if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
58
+ function rangeHolds(ranges, start, end) {
59
+ if (!Array.isArray(ranges) || !(end >= start)) {
44
60
  return false;
45
61
  }
46
- const first = Math.floor((offset + start) / pieceLength);
47
- const last = Math.floor((offset + end) / pieceLength);
48
- for (let index = first; index <= last; index += 1) {
49
- if (!torrent.bitfield.get(index)) {
50
- return false;
62
+ for (const [from, to] of ranges) {
63
+ if (start >= from && end <= to) {
64
+ return true;
51
65
  }
52
66
  }
53
- return true;
67
+ return false;
54
68
  }
55
69
 
56
- /**
57
- * Read a byte range of a file straight from the store, without asking the swarm
58
- * for anything.
59
- *
60
- * @param {object} file
61
- * @param {number} start
62
- * @param {number} end - Inclusive.
63
- * @returns {Promise<Buffer | null>}
64
- */
65
- function readHeld(file, start, end) {
66
- return new Promise((resolve) => {
67
- const chunks = [];
68
- let stream;
69
- try {
70
- stream = file.createReadStream({ start, end });
71
- } catch {
72
- resolve(null);
73
- return;
74
- }
75
- let settled = false;
76
- /** @type {ReturnType<typeof setTimeout> | null} */
77
- let abandon = null;
78
- const settle = (value) => {
79
- if (settled) {
80
- return;
81
- }
82
- settled = true;
83
- if (abandon !== null) {
84
- clearTimeout(abandon);
85
- }
86
- if (value === null) {
87
- stream.destroy?.();
88
- }
89
- resolve(value);
90
- };
91
- // A read of bytes the torrent already holds either answers or it does not.
92
- // This is not a measurement of anything and no figure is derived from it:
93
- // it is the point past which such a read is presumed lost, so that one
94
- // stream which never ends cannot hold this file's walk — and with it the
95
- // browser's own request for its subtitles — for the rest of the session.
96
- abandon = setTimeout(() => {
97
- logger.info(
98
- `subtitles: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
99
- `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
100
- );
101
- settle(null);
102
- }, READ_ABANDON_MS);
103
- abandon.unref?.();
104
- stream.on("data", (chunk) => chunks.push(chunk));
105
- stream.on("end", () => settle(Buffer.concat(chunks)));
106
- stream.on("error", () => settle(null));
107
- });
108
- }
70
+ /** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
71
+ const byFile = new Map();
72
+
73
+
109
74
 
110
75
  /**
111
76
  * A container over one file of a torrent, told how to read it.
@@ -123,28 +88,31 @@ function readHeld(file, start, end) {
123
88
  * different evidence that have to agree.
124
89
  *
125
90
  * @param {object} state - This file's state; the container is kept on it.
126
- * @param {object} torrent
127
- * @param {object} file
128
- * @returns {Promise<import("../container/Container.js").Container | null>}
91
+ * @param {import("./SubtitleCues.js").HeldFile} source
92
+ * @returns {Promise<import("./container/Container.js").Container | null>}
129
93
  */
130
- async function containerOver(state, torrent, file) {
94
+ async function containerOver(state, source) {
131
95
  if (state.container !== null) {
132
96
  return state.container;
133
97
  }
134
- const held = async (start, end) => readHeld(file, start, Math.min(end, file.length - 1));
98
+ const last = Number(source.length) - 1;
99
+ const held = async (start, end) => source.readHeld(start, Math.min(end, last));
135
100
  const params = {
136
101
  readRange: held,
137
102
  readHeld: held,
138
- isHeld: (start, end) => rangeIsHeld(torrent, file, start, Math.min(end, file.length - 1)),
139
- fileSize: file.length,
140
- label: String(file.name ?? "")
103
+ // Answered from the list of ranges this file has downloaded WHOLE, taken
104
+ // once per pass rather than asked per cluster: a pass asks about every
105
+ // cluster of the file, and the torrent that knows is on the other thread.
106
+ isHeld: (start, end) => rangeHolds(state.held, start, Math.min(end, last)),
107
+ fileSize: source.length,
108
+ label: String(source.name ?? "")
141
109
  };
142
110
  const sniffed = await ContainerFactory.create(params);
143
111
  if (sniffed) {
144
112
  state.container = sniffed;
145
113
  return state.container;
146
114
  }
147
- const ByName = ContainerFactory.byName(file.name);
115
+ const ByName = ContainerFactory.byName(source.name);
148
116
  state.container = ByName ? new ByName(params) : null;
149
117
  return state.container;
150
118
  }
@@ -156,12 +124,11 @@ async function containerOver(state, torrent, file) {
156
124
  * missing — they are kilobytes, they are needed before anything can be offered,
157
125
  * and the codec probe has already pulled the head for every file that plays.
158
126
  *
159
- * @param {object} torrent
160
- * @param {number} fileIndex
127
+ * @param {import("./SubtitleCues.js").HeldFile} source
161
128
  * @param {string} key - `sourceKey:fileIndex`.
162
129
  * @returns {Promise<object | null>}
163
130
  */
164
- async function planFor(torrent, fileIndex, key) {
131
+ async function planFor(source, key) {
165
132
  const state = stateFor(key);
166
133
  if (state.plan !== null) {
167
134
  return state.plan;
@@ -170,7 +137,7 @@ async function planFor(torrent, fileIndex, key) {
170
137
  // callers arriving together would both make them. One promise, awaited by
171
138
  // whoever asks while it is in flight.
172
139
  if (!state.planPromise) {
173
- state.planPromise = readPlan(torrent, fileIndex, state).finally(() => {
140
+ state.planPromise = readPlan(source, state).finally(() => {
174
141
  state.planPromise = null;
175
142
  });
176
143
  }
@@ -201,6 +168,11 @@ function stateFor(key) {
201
168
  // — a `moov` box is tens of megabytes off a torrent — so building a fresh
202
169
  // one per call would throw that away on every request.
203
170
  container: null,
171
+ // Which byte ranges of this file are downloaded WHOLE, as of the start of
172
+ // the pass now running. Taken once and read many times: a pass asks about
173
+ // every cluster of the file, and what knows the answer is on the other
174
+ // thread.
175
+ held: null,
204
176
  harvested: new Map(),
205
177
  cues: new Map(),
206
178
  seq: new Map(),
@@ -246,23 +218,22 @@ function serialize(state, work) {
246
218
  * holding them are. Called once per file; see `planFor`.
247
219
  *
248
220
  * @param {object} torrent
249
- * @param {number} fileIndex
250
221
  * @param {object} state
251
222
  * @returns {Promise<object>}
252
223
  */
253
- async function readPlan(torrent, fileIndex, state) {
254
- const file = torrent?.files?.[fileIndex];
224
+ async function readPlan(source, state) {
255
225
  // `declared` is what the container itself says about its subtitle tracks, in
256
226
  // its own order. Empty means the container said nothing — which is a real
257
227
  // answer and not a missing one: nothing is then shown unasked. An MP4 has no
258
228
  // element that means "show this subtitle track by default", so it declares
259
229
  // nothing however many tracks it carries.
260
230
  const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
261
- if (!file) {
231
+ if (!source || !(Number(source.length) > 0)) {
262
232
  state.plan = empty;
263
233
  return state.plan;
264
234
  }
265
- const container = await containerOver(state, torrent, file);
235
+ state.held = await source.heldRanges();
236
+ const container = await containerOver(state, source);
266
237
  if (!container) {
267
238
  state.plan = empty;
268
239
  return state.plan;
@@ -271,7 +242,7 @@ async function readPlan(torrent, fileIndex, state) {
271
242
  state.plan = plan ?? empty;
272
243
  if (state.plan.tracks.length > 0) {
273
244
  logger.info(
274
- `subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
245
+ `subtitles: "${String(source.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
275
246
  `of ${state.plan.declared.length} declared — ` +
276
247
  state.plan.tracks
277
248
  // `s:N` is the number the browser names (ffmpeg's own), and it differs
@@ -318,47 +289,47 @@ function nextSeq(state, trackNumber) {
318
289
  * @param {number} trackNumber
319
290
  * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
320
291
  */
321
- export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
322
- const key = `${sourceKey}:${fileIndex}`;
323
- // A torrent that cannot say which pieces it holds makes every range read as
324
- // "not downloaded", so the walk reads nothing and returns an empty list —
292
+ export async function cuesHeldFor(source, trackNumber) {
293
+ const key = `${source.sourceKey}:${source.fileIndex}`;
294
+ // A source that cannot say which of itself is downloaded makes every range
295
+ // read as "not there", so the walk reads nothing and returns an empty list —
325
296
  // which is also what a file with no cues yet returns, and that is how this
326
297
  // went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
327
- // the browser served `WEBVTT` and nothing else). The stand-in the main thread
328
- // holds is exactly such a torrent; only the thread that owns the object has
329
- // the bitfield. Nothing here can repair that, so it says so instead.
330
- if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
298
+ // the browser served `WEBVTT` and nothing else). It is not repairable here,
299
+ // so it is said rather than swallowed.
300
+ if (typeof source.heldRanges !== "function" || typeof source.readHeld !== "function") {
331
301
  logger.warn(
332
- `subtitles: asked for cues of "${String(torrent?.name ?? sourceKey).slice(0, 40)}" ` +
333
- "on a torrent that cannot say which pieces it holds — no cluster can be read here, " +
302
+ `subtitles: asked for cues of "${String(source.name ?? key).slice(0, 40)}" ` +
303
+ "on a source that cannot say which of it is downloaded — no cluster can be read, " +
334
304
  "and the answer would be an empty document indistinguishable from a file with no cues"
335
305
  );
336
306
  return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
337
307
  }
338
- const plan = await planFor(torrent, fileIndex, key);
308
+ const plan = await planFor(source, key);
339
309
  const state = stateFor(key);
340
310
  const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
341
311
  if (!track) {
342
312
  return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
343
313
  }
344
- return serialize(state, () => walkFor(torrent, fileIndex, state, plan, track, trackNumber));
314
+ return serialize(state, () => walkFor(source, state, plan, track, trackNumber));
345
315
  }
346
316
 
347
317
  /**
348
318
  * The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
349
319
  * one file to one walk at a time.
350
320
  *
351
- * @param {object} torrent
352
- * @param {number} fileIndex
321
+ * @param {import("./SubtitleCues.js").HeldFile} source
353
322
  * @param {object} state
354
323
  * @param {object} plan
355
324
  * @param {object} track
356
325
  * @param {number} trackNumber
357
326
  * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
358
327
  */
359
- async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
360
- const file = torrent.files[fileIndex];
361
- const container = await containerOver(state, torrent, file);
328
+ async function walkFor(source, state, plan, track, trackNumber) {
329
+ // Taken once per pass: pieces keep arriving, and what may be read now is a
330
+ // different list from what could be read when the plan was built.
331
+ state.held = await source.heldRanges();
332
+ const container = await containerOver(state, source);
362
333
  if (!container) {
363
334
  return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
364
335
  }
@@ -412,15 +383,15 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
412
383
  * readable tracks either: counting those alone puts every text track after a
413
384
  * picture-based one in the wrong place.
414
385
  */
415
- export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
416
- const key = `${sourceKey}:${fileIndex}`;
417
- const plan = await planFor(torrent, fileIndex, key);
386
+ export async function warmSubtitleCues(source) {
387
+ const key = `${source.sourceKey}:${source.fileIndex}`;
388
+ const plan = await planFor(source, key);
418
389
  const state = stateFor(key);
419
390
  const fresh = [];
420
391
  const tracks = plan?.tracks ?? [];
421
392
  for (let order = 0; order < tracks.length; order += 1) {
422
393
  const track = tracks[order];
423
- const held = await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
394
+ const held = await cuesHeldFor(source, track.trackNumber);
424
395
  const since = state.pushed.get(track.trackNumber) ?? 0;
425
396
  const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
426
397
  if (newCues.length === 0) {
@@ -467,8 +438,8 @@ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
467
438
  * @param {string} sourceKey
468
439
  * @returns {Promise<object[]>}
469
440
  */
470
- export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
471
- const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
441
+ export async function subtitleTracksOf(source) {
442
+ const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
472
443
  return (plan?.tracks ?? []).map((track, order) => ({
473
444
  trackNumber: track.trackNumber,
474
445
  declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
@@ -494,8 +465,8 @@ export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
494
465
  * @param {string} sourceKey
495
466
  * @returns {Promise<object[]>}
496
467
  */
497
- export async function declaredSubtitleTracksOf(torrent, fileIndex, sourceKey) {
498
- const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
468
+ export async function declaredSubtitleTracksOf(source) {
469
+ const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
499
470
  return plan?.declared ?? [];
500
471
  }
501
472
 
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @file Subtitle orchestrator — the track list and the cues, behind the
3
+ * Container/Track abstraction.
4
+ *
5
+ * Provides per-file track list and cue streaming, with the same "only already
6
+ * downloaded clusters" rule as before. Controllers (HTTP or data-channel)
7
+ * depend on this, not on the worker module directly. Delegates to
8
+ * `ContainerOrchestrator` for track enumeration so subtitle tracks and their
9
+ * flags come from the unified `ContainerTrack` hierarchy.
10
+ *
11
+ * **The cue reading is HANDED IN, not imported, and that is the layer boundary.**
12
+ * Which clusters may be read, where the cursor stands and what one walk at a
13
+ * time means are the TORRENT's rules, and they live in
14
+ * `torrent-worker/subtitle-cues.js`. This file used to import them, so the media
15
+ * layer named the torrent's worker — the one direction the layers may not go.
16
+ * Now the wiring puts the two together (`server/controllers/SubtitleController.js`) and
17
+ * this can be exercised with four plain functions.
18
+ */
19
+
20
+ import { logger } from "../../utils/logger.js";
21
+ import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
22
+ import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
23
+ import { detectLanguage } from "./tracks/language-detect.js";
24
+
25
+ export class SubtitleOrchestrator {
26
+ /**
27
+ * @param {import("./ContainerOrchestrator.js").ContainerOrchestrator} containerOrchestrator
28
+ * @param {object} [cues] - How the torrent reads subtitle cues: `warm`,
29
+ * `tracksOf`, `declaredTracksOf`, `forget`. Absent, this answers from the
30
+ * containers alone, which is what a proxy with no torrent behind it can do.
31
+ */
32
+ constructor(containerOrchestrator, cues = {}) {
33
+ this.containers = containerOrchestrator;
34
+ this.cues = cues ?? {};
35
+ }
36
+
37
+ /**
38
+ * Tracks for menu — text tracks via domain, enriched with ContainerTrack flags.
39
+ * Falls back to container tracks when domain has no plan yet.
40
+ * @param {object} torrent
41
+ * @param {number} fileIndex
42
+ * @param {string} sourceKey
43
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} [readRange]
44
+ * @param {number} [fileSize]
45
+ * @returns {Promise<import("./tracks/index.js").ContainerTrack[]>}
46
+ */
47
+ async getTracks(torrent, fileIndex, sourceKey, readRange, fileSize) {
48
+ try {
49
+ const domain = await this.cues.tracksOf?.(torrent, fileIndex, sourceKey);
50
+ if (Array.isArray(domain) && domain.length > 0) return domain;
51
+ } catch {}
52
+ if (readRange && Number.isFinite(fileSize)) {
53
+ try {
54
+ const tracks = await this.containers.getTracks({ sourceKey, fileIndex, readRange, fileSize, label: torrent?.files?.[fileIndex]?.name ?? "" });
55
+ return tracks.filter((t) => t.type === "subtitle");
56
+ } catch {}
57
+ }
58
+ return [];
59
+ }
60
+
61
+ /**
62
+ * Declared subtitle tracks in container order (including image tracks) — for declaredIndex alignment.
63
+ */
64
+ async getDeclaredTracks(torrent, fileIndex, sourceKey) {
65
+ try {
66
+ return (await this.cues.declaredTracksOf?.(torrent, fileIndex, sourceKey)) ?? [];
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The cues a file already holds for one track.
74
+ *
75
+ * The PULL path: a browser asking `/api/subtitles` rather than being pushed
76
+ * to. It runs the same walk as the push, on this thread — it used to be sent
77
+ * to the torrent thread as a command, and when that command went the guard
78
+ * here answered an EMPTY document instead of failing, which is
79
+ * indistinguishable from a file that genuinely holds no cues. That is the one
80
+ * failure this whole path is written to avoid, so an absent walk says so.
81
+ *
82
+ * @param {object} torrent
83
+ * @param {number} fileIndex
84
+ * @param {string} sourceKey
85
+ * @param {number} trackNumber
86
+ * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
87
+ */
88
+ async getCues(torrent, fileIndex, sourceKey, trackNumber) {
89
+ const empty = () => ({ cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
90
+ if (typeof this.cues.held !== "function") {
91
+ logger.warn(
92
+ "subtitle-orchestrator: no walk was supplied, so no cue can be read — " +
93
+ "an empty document here would be indistinguishable from a file with none"
94
+ );
95
+ return empty();
96
+ }
97
+ try {
98
+ const answer = await this.cues.held(torrent, fileIndex, sourceKey, trackNumber);
99
+ return answer ?? empty();
100
+ } catch (e) {
101
+ logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
102
+ return empty();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Warm all subtitle tracks of a file — called periodically and on verified pieces.
108
+ * Returns per-track fresh cues for push.
109
+ */
110
+ async warm(torrent, fileIndex, sourceKey) {
111
+ try {
112
+ return (await this.cues.warm?.(torrent, fileIndex, sourceKey)) ?? [];
113
+ } catch {
114
+ return [];
115
+ }
116
+ }
117
+
118
+ /**
119
+ * A subtitle FILE as WebVTT, and the language its words are in.
120
+ *
121
+ * The language is read from the CONVERTED document, not from the file. The
122
+ * conversion has already dropped everything that is not the words — and on
123
+ * an ASS file that is half of it, in Latin letters, which is what made a
124
+ * Russian track answer `en` (field 2026-09-01,
125
+ * `research/subtitle-language-ass-markup-2026-09-01.md`).
126
+ *
127
+ * @param {Buffer} bytes
128
+ * @param {string} extension - With its dot, lower case.
129
+ * @returns {{ vtt: string, language: string | null } | null} Null for a
130
+ * format this cannot convert.
131
+ */
132
+ fileAsVtt(bytes, extension) {
133
+ const text = SubtitleFileContainer.decodeBytes(bytes);
134
+ const vtt = SubtitleFileContainer.toVtt(text, extension);
135
+ if (!vtt) {
136
+ return null;
137
+ }
138
+ return { vtt, language: TextSubtitleTrack.detectLanguageFromVtt(vtt) };
139
+ }
140
+
141
+ /**
142
+ * Cues held for an embedded track as WebVTT, and the language of the track.
143
+ *
144
+ * Two things this reads, and each of them was wrong before 2.68.1. It reads
145
+ * the cues through `finalizeCues`, so what reaches the detector is the words
146
+ * and not ASS's `{\…}` override groups, which are Latin on a Russian track.
147
+ * And it reads EVERY cue held so far, not the subset being sent: a
148
+ * re-subscription after a reconnect asks only for what the page missed,
149
+ * which can be three lines, and three lines are not a sample of a language.
150
+ *
151
+ * @param {object[]} sending - The cues this answer carries.
152
+ * @param {object[]} held - Every cue held for the track so far.
153
+ * @param {string} codecId
154
+ * @returns {{ vtt: string, language: string | null }}
155
+ */
156
+ cuesAsVtt(sending, held, codecId) {
157
+ return {
158
+ vtt: TextSubtitleTrack.cuesToVtt(sending, codecId),
159
+ language: detectLanguage(TextSubtitleTrack.finalizeCues(held, codecId).map((cue) => cue.text).join("\n"))
160
+ };
161
+ }
162
+
163
+ forget(sourceKey, fileIndex) {
164
+ this.cues.forget?.(sourceKey, fileIndex);
165
+ this.containers.forget(sourceKey, fileIndex);
166
+ }
167
+ }
@@ -56,6 +56,7 @@ import { AudioTrack } from "./tracks/AudioTrack.js";
56
56
  * @property {boolean} isVisualImpaired
57
57
  * @property {boolean} isEnabled
58
58
  * @property {number | null} channels
59
+ * @property {number | null} bitrateKbps - The rate the file states for this track; an average, not a bound.
59
60
  * @property {string} fileName - For a sidecar: its own file name. "" otherwise.
60
61
  * @property {string[]} folders - For a sidecar: the folders above it, relative
61
62
  * to the torrent root. What the browser reads a language and a releaser from.
@@ -73,7 +74,7 @@ import { AudioTrack } from "./tracks/AudioTrack.js";
73
74
  * @param {object} params
74
75
  * @param {object[]} params.embedded - Merged banner+container tracks of the video.
75
76
  * @param {number} params.videoFileIndex
76
- * @param {Array<{ file: import("./torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
77
+ * @param {Array<{ file: import("../torrent/files.js").SidecarFile, tracks: object[] }>} params.sidecars
77
78
  * Each sidecar file with the audio tracks IT holds. A file whose container
78
79
  * could not be read contributes one track, which is what a bare elementary
79
80
  * stream is.
@@ -105,6 +106,11 @@ export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
105
106
  isVisualImpaired: track?.isVisualImpaired === true,
106
107
  isEnabled: track?.isEnabled !== false,
107
108
  channels: Number.isFinite(track?.channels) ? track.channels : null,
109
+ // The rate the file STATES for this track, in kbit/s — an average, not a
110
+ // bound — or null where nothing states one (a sidecar read from its
111
+ // container table, a Matroska stream without statistics tags). What a
112
+ // viewer's link is asked to carry for a copied soundtrack is this.
113
+ bitrateKbps: Number.isFinite(track?.bitrateKbps) && track.bitrateKbps > 0 ? track.bitrateKbps : null,
108
114
  fileName: kind === "sidecar" ? (file?.name ?? "") : "",
109
115
  folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
110
116
  // What the file's own path says, for a track that ships as its own file: