@torrent-tv/proxy 2.85.1 → 2.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. package/CHANGELOG.md +2138 -1909
  2. package/CLAUDE.md +318 -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/container-architecture.md +218 -213
  7. package/docs/download-architecture.md +7 -7
  8. package/docs/encode-architecture.md +287 -11
  9. package/docs/encode-run-state.md +2 -2
  10. package/docs/logs.md +97 -97
  11. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  12. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  13. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  14. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  15. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  18. package/openspec/changes/cold-start/design.md +1 -1
  19. package/openspec/changes/disk-cap/proposal.md +1 -1
  20. package/openspec/changes/proxy-observability/proposal.md +2 -2
  21. package/openspec/changes/track-selection/proposal.md +1 -1
  22. package/openspec/changes/transcode-quality/proposal.md +1 -1
  23. package/package.json +2 -2
  24. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  25. package/research/double-spawn-2026-09-10.md +171 -171
  26. package/routes/api/playback-plan/post.js +2 -2
  27. package/routes/api/sources/files/get.js +1 -1
  28. package/routes/api/sources/stats/get.js +1 -1
  29. package/routes/api/sources/warm/post.js +10 -3
  30. package/routes/api/subtitles/get.js +230 -230
  31. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  32. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  33. package/routes/api/transcode-sessions/post.js +160 -139
  34. package/routes/api/transcode-sessions/progress/get.js +3 -3
  35. package/routes/api/transcode-sessions/release/post.js +3 -3
  36. package/routes/api/transcode-sessions/seek/post.js +12 -3
  37. package/routes/stream/get.js +2 -2
  38. package/routes/transcode/audio-file/get.js +15 -6
  39. package/routes/transcode/audio-warm/get.js +8 -5
  40. package/routes/transcode/session-file/get.js +429 -268
  41. package/routes/transcode/variant-file/get.js +47 -7
  42. package/routes/transcode/variant-warm/get.js +18 -6
  43. package/scripts/render-run-graph.js +1 -1
  44. package/server.js +720 -465
  45. package/services/encode/CoverageMap.js +428 -428
  46. package/services/encode/CushionReport.js +345 -0
  47. package/services/encode/EncodeAdmission.js +332 -0
  48. package/services/{orchestrators → encode}/EncodeOrchestrator.js +285 -17
  49. package/services/encode/EncodeRun.js +16 -9
  50. package/services/encode/EncodeRuns.js +1219 -0
  51. package/services/encode/Encoder.js +18 -2
  52. package/services/encode/EncoderSelection.js +38 -0
  53. package/services/encode/LocalObservations.js +359 -0
  54. package/services/encode/NvencEncoder.js +5 -0
  55. package/services/encode/OutputOpening.js +745 -0
  56. package/services/encode/OutputTimes.js +579 -0
  57. package/services/encode/Renditions.js +2162 -0
  58. package/services/encode/RunProgress.js +84 -0
  59. package/services/encode/SoftwareEncoder.js +12 -7
  60. package/services/encode/args.js +402 -186
  61. package/services/encode/calibration.js +361 -0
  62. package/services/encode/contention.js +128 -128
  63. package/services/encode/encode-run-state.js +16 -20
  64. package/services/encode/fingerprint.js +322 -0
  65. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  66. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  67. package/services/encode/output/EncodedOutput.js +57 -0
  68. package/services/encode/output/Output.js +131 -0
  69. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  70. package/services/encode/output/OutputSpec.js +390 -0
  71. package/services/{output → encode/output}/Timeline.js +12 -37
  72. package/services/encode/output/index.js +20 -0
  73. package/services/encode/output/ladder.js +32 -0
  74. package/services/{output → encode/output}/playlists.js +3 -0
  75. package/services/encode/output-key-format.js +21 -0
  76. package/services/encode/quality/EncodeCost.js +1083 -0
  77. package/services/encode/quality/HostLoad.js +525 -0
  78. package/services/encode/quality/HostTimings.js +249 -0
  79. package/services/encode/quality/OutputQualityState.js +28 -0
  80. package/services/encode/quality/QualityController.js +931 -0
  81. package/services/encode/quality/QualityOffer.js +416 -0
  82. package/services/encode/quality/drain-threat.js +47 -0
  83. package/services/encode/quality/link-budget.js +254 -0
  84. package/services/encode/quality/output-format.js +266 -0
  85. package/services/encode/quality/serving-output.js +216 -0
  86. package/services/encode/quality/visible-rung.js +57 -0
  87. package/services/encode/run-command.js +23 -20
  88. package/services/encode/run-inputs.js +42 -0
  89. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  90. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  91. package/services/encode/segment-formats/init-compat.js +324 -0
  92. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  93. package/services/encode/throughput.js +97 -0
  94. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  95. package/services/media/KeyframeTables.js +316 -0
  96. package/services/{source → media}/SourceFile.js +7 -55
  97. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  98. package/services/media/SubtitleOrchestrator.js +167 -0
  99. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  100. package/services/{container → media/container}/AviContainer.js +266 -266
  101. package/services/{container → media/container}/Container.js +596 -601
  102. package/services/media/container/ContainerFactory.js +56 -0
  103. package/services/media/container/KeyframeTable.js +272 -0
  104. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  105. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  106. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  107. package/services/media/keyframe-probe.js +157 -0
  108. package/services/media/media-info-probe.js +93 -0
  109. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  110. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  111. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  112. package/services/server/OutputLifecycle.js +418 -0
  113. package/services/server/SegmentServing.js +1146 -0
  114. package/services/server/ViewerRequests.js +522 -0
  115. package/services/server/controllers/PlaybackController.js +36 -0
  116. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  117. package/services/server/wire-outputs.js +958 -0
  118. package/services/storage/Diagnostics.js +202 -0
  119. package/services/storage/MachineBudget.js +235 -0
  120. package/services/{piece-store → storage}/allowance.js +107 -107
  121. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  122. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  123. package/services/{disk → storage}/free.js +33 -0
  124. package/services/storage/machine-memory.js +56 -0
  125. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  126. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  127. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  128. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  129. package/services/storage/wire.js +175 -0
  130. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  131. package/services/{download → torrent/download}/registry.js +2 -2
  132. package/services/torrent/files.js +1 -1
  133. package/services/torrent/piece-store-of.js +29 -0
  134. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  135. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  136. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  137. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  138. package/services/torrent/worker/held-bytes.js +155 -0
  139. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  140. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  141. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  142. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  143. package/services/torrent/worker/resume-warm.js +107 -0
  144. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  145. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  146. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  147. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  148. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  149. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  150. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  151. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  152. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  153. package/services/viewer/Assignments.js +340 -0
  154. package/services/{priority → viewer}/PriorityMap.js +390 -390
  155. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  156. package/services/viewer/Viewer.js +657 -489
  157. package/services/viewer/Viewers.js +368 -257
  158. package/services/viewer/active-output.js +37 -54
  159. package/services/viewer/audio-start.js +46 -0
  160. package/services/viewer/buffer-trend.js +81 -0
  161. package/services/viewer/choices.js +647 -0
  162. package/services/viewer/positions.js +126 -120
  163. package/services/viewer/report-intake.js +46 -44
  164. package/test/asset-paths.test.js +28 -0
  165. package/test/audio-inventory.test.js +3 -3
  166. package/test/audio-track-bitrate.test.js +40 -0
  167. package/test/auto-quality-step.test.js +707 -518
  168. package/test/available-share.test.js +1 -1
  169. package/test/background-fill.test.js +1 -1
  170. package/test/client-log-file.test.js +110 -110
  171. package/test/completed-files.test.js +1 -1
  172. package/test/concurrent-cost.test.js +13 -3
  173. package/test/container-media-info.test.js +3 -3
  174. package/test/core-dumps.test.js +1 -1
  175. package/test/coverage-follows-the-disk.test.js +40 -8
  176. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  177. package/test/cut-times-timeline.test.js +2 -2
  178. package/test/cuts-follow-published-grid.test.js +9 -8
  179. package/test/data-channel-frame.test.js +1 -1
  180. package/test/dead-torrent-handle.test.js +1 -1
  181. package/test/declared-rates.test.js +2 -2
  182. package/test/declared-tracks.test.js +2 -2
  183. package/test/decode-cost-fit.test.js +1 -1
  184. package/test/decode-cost.test.js +34 -25
  185. package/test/delivery-probe.test.js +1 -1
  186. package/test/demand-register.test.js +4 -4
  187. package/test/dht-bootstrap.test.js +1 -1
  188. package/test/drain-threat.test.js +65 -0
  189. package/test/duplicate-add.test.js +1 -1
  190. package/test/encode-admission.test.js +146 -0
  191. package/test/encode-cost.test.js +199 -10
  192. package/test/encode-orchestrator.test.js +39 -1
  193. package/test/encode-plan-viewers.test.js +2 -2
  194. package/test/encode-run.test.js +2 -2
  195. package/test/encode-slope.test.js +1 -1
  196. package/test/encoded-output.test.js +39 -0
  197. package/test/encoder-readings.test.js +1 -1
  198. package/test/every-claimant-is-told-its-share.test.js +152 -0
  199. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  200. package/test/failed-start-breaker.test.js +81 -30
  201. package/test/fastest-wires.test.js +1 -1
  202. package/test/ffmpeg-stream-counts.test.js +1 -1
  203. package/test/file-edges.test.js +3 -3
  204. package/test/fmp4-self-contained.test.js +1 -1
  205. package/test/fmp4-timestamps.test.js +1 -1
  206. package/test/fmp4-track-completeness.test.js +1 -1
  207. package/test/format-keyed-outputs.test.js +78 -0
  208. package/test/health-metrics.test.js +3 -2
  209. package/test/held-request-width.test.js +22 -23
  210. package/test/helpers/encode-run.js +136 -136
  211. package/test/helpers/held-file.js +29 -0
  212. package/test/helpers/manager.js +57 -0
  213. package/test/helpers/output-spec.js +63 -0
  214. package/test/host-load.test.js +1 -1
  215. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  216. package/test/init-compat.test.js +219 -0
  217. package/test/input-lost-quiets-the-plan.test.js +1 -1
  218. package/test/input-unavailable.test.js +1 -1
  219. package/test/keeping-period.test.js +4 -4
  220. package/test/keyframe-index-accuracy.test.js +97 -45
  221. package/test/keyframe-table-budget.test.js +28 -33
  222. package/test/keyframes-belong-to-the-file.test.js +174 -57
  223. package/test/learned-median.test.js +1 -1
  224. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  225. package/test/limit-row-by-area.test.js +60 -0
  226. package/test/link-budget.test.js +111 -0
  227. package/test/link-probe.test.js +56 -56
  228. package/test/local-encoding-data.test.js +273 -0
  229. package/test/look-ahead-coverage.test.js +1 -1
  230. package/test/manager-surface.test.js +294 -0
  231. package/test/matroska-blocks.test.js +0 -0
  232. package/test/matroska-cues-track.test.js +1 -1
  233. package/test/memory-budget.test.js +2 -2
  234. package/test/mp4-composition-times.test.js +0 -0
  235. package/test/mp4-subtitles.test.js +1 -1
  236. package/test/one-authority.test.js +45 -41
  237. package/test/one-budget.test.js +149 -0
  238. package/test/one-output-one-session.test.js +204 -0
  239. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  240. package/test/one-serving-output-per-height.test.js +458 -0
  241. package/test/orchestrator-wired.test.js +46 -45
  242. package/test/output-catalog.test.js +39 -0
  243. package/test/output-shape.test.js +4 -51
  244. package/test/output-spec.test.js +251 -157
  245. package/test/output-speed.test.js +17 -15
  246. package/test/outputs-being-prepared.test.js +40 -0
  247. package/test/packet-witness-ring.test.js +2 -2
  248. package/test/packet-witness.test.js +1 -1
  249. package/test/piece-disk-store.test.js +2 -2
  250. package/test/piece-from-whole-file.test.js +1 -1
  251. package/test/piece-lru.test.js +1 -1
  252. package/test/piece-reader.test.js +2 -2
  253. package/test/piece-store-eviction.test.js +1 -1
  254. package/test/piece-store-never-refuses.test.js +1 -1
  255. package/test/piece-store-reservations.test.js +1 -1
  256. package/test/piece-store-slow-disk.test.js +1 -1
  257. package/test/piece-tail.test.js +1 -1
  258. package/test/plan-host-timings.test.js +1 -1
  259. package/test/plan-is-steady.test.js +225 -225
  260. package/test/priority-map-download.test.js +4 -4
  261. package/test/priority-map-emptied.test.js +204 -205
  262. package/test/priority-map-per-output.test.js +215 -209
  263. package/test/priority-map.test.js +1 -1
  264. package/test/probe-wedge-certainty.test.js +1 -1
  265. package/test/produced-copy-choice.test.js +15 -60
  266. package/test/quality-offer.test.js +162 -0
  267. package/test/quality-variants.test.js +1229 -1167
  268. package/test/rate-control-in-the-output.test.js +180 -0
  269. package/test/read-survives-withdrawal.test.js +2 -2
  270. package/test/read-window.test.js +10 -3
  271. package/test/resume-warm.test.js +1 -1
  272. package/test/run-inputs.test.js +80 -0
  273. package/test/run-position-follows-published-grid.test.js +13 -11
  274. package/test/run-progress.test.js +65 -0
  275. package/test/same-height-switch.test.js +664 -0
  276. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  277. package/test/seek-landing.test.js +13 -13
  278. package/test/seek-target-not-superseded.test.js +28 -29
  279. package/test/segment-closed-on-both-branches.test.js +96 -0
  280. package/test/segment-cut-times.test.js +1 -1
  281. package/test/segment-hold-supersede.test.js +28 -35
  282. package/test/segment-judged-by-its-own-header.test.js +221 -0
  283. package/test/segment-serve-wiring.test.js +130 -39
  284. package/test/segment-store-eviction.test.js +22 -7
  285. package/test/segment-store.test.js +98 -14
  286. package/test/segment-true-start.test.js +1 -1
  287. package/test/segments-are-shared.test.js +17 -16
  288. package/test/send-chunk-bytes.test.js +1 -1
  289. package/test/serving-existing-output.test.js +102 -0
  290. package/test/serving-output.test.js +102 -0
  291. package/test/shared-piece-store.test.js +1 -1
  292. package/test/source-file.test.js +1 -1
  293. package/test/stale-request-after-seek.test.js +17 -21
  294. package/test/startup-readings.test.js +10 -5
  295. package/test/subtitle-cue-framing.test.js +5 -5
  296. package/test/subtitle-cue-source.test.js +36 -38
  297. package/test/subtitle-cue-walk.test.js +11 -10
  298. package/test/subtitle-defaults.test.js +1 -1
  299. package/test/subtitle-language.test.js +4 -4
  300. package/test/subtitle-track-numbering.test.js +5 -4
  301. package/test/supply-margin.test.js +1 -1
  302. package/test/swarm-follows-readers.test.js +3 -3
  303. package/test/swarm-reach.test.js +1 -1
  304. package/test/swarm-selection.test.js +3 -3
  305. package/test/tail-duplication.test.js +1 -1
  306. package/test/the-channel-has-both-ends.test.js +91 -0
  307. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  308. package/test/thread-pool.test.js +3 -3
  309. package/test/timeline.test.js +1 -12
  310. package/test/torrent-cost.test.js +1 -1
  311. package/test/tracks-begin-together.test.js +38 -24
  312. package/test/tunnel-renewal.test.js +1 -1
  313. package/test/two-viewers-one-picture.test.js +120 -87
  314. package/test/upload-hurry.test.js +3 -3
  315. package/test/usrsctp-state.test.js +1 -1
  316. package/test/video-facts.test.js +1 -1
  317. package/test/viewer-assignments.test.js +143 -0
  318. package/test/viewer-outputs.test.js +60 -52
  319. package/test/viewer-presence.test.js +22 -25
  320. package/test/viewer-requests-admission.test.js +89 -0
  321. package/test/viewer-states.test.js +189 -172
  322. package/test/viewer-subtitle-subscription.test.js +1 -1
  323. package/test/viewer.test.js +139 -136
  324. package/test/viewing-generations.test.js +390 -0
  325. package/test/visible-rung.test.js +37 -0
  326. package/test/wait-ledger.test.js +1 -1
  327. package/test/webrtc-shim.test.js +3 -3
  328. package/test/wedge-certainty.test.js +2 -2
  329. package/test/withdraw-piece-claim.test.js +2 -2
  330. package/test/worker-channel.test.js +1 -31
  331. package/utils/client-log-file.js +151 -151
  332. package/scripts/read-container-index.mjs +0 -108
  333. package/services/container/ContainerFactory.js +0 -122
  334. package/services/controllers/PlaybackController.js +0 -33
  335. package/services/disk/DiskSpace.js +0 -150
  336. package/services/disk/wire.js +0 -61
  337. package/services/hls-session-manager.js +0 -9689
  338. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  339. package/services/output/Output.js +0 -94
  340. package/services/output/OutputSpec.js +0 -202
  341. package/services/output/index.js +0 -1
  342. package/services/output/ladder.js +0 -26
  343. package/services/produced-index.js +0 -222
  344. package/services/quality/EncodeCost.js +0 -555
  345. package/services/torrent-worker/container-tracks.js +0 -414
  346. package/test/produced-index.test.js +0 -142
  347. /package/{services → bin}/thread-pool.js +0 -0
  348. /package/services/{available-share.js → encode/available-share.js} +0 -0
  349. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  350. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  351. /package/services/{host-load.js → encode/host-load.js} +0 -0
  352. /package/services/{output → encode/output}/cut-grid.js +0 -0
  353. /package/services/{output → encode/output}/rates.js +0 -0
  354. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  355. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  356. /package/services/{container → media/container}/ebml-reader.js +0 -0
  357. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  358. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  359. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  360. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  361. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  362. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/index.js +0 -0
  364. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  365. /package/services/{disk → storage}/keep.js +0 -0
  366. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  367. /package/services/{disk → storage}/returns.js +0 -0
  368. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  369. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  370. /package/services/{demand → torrent/demand}/Window.js +0 -0
  371. /package/services/{demand → torrent/demand}/index.js +0 -0
  372. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  373. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  374. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  375. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  376. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  377. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  378. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  379. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  380. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  381. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -17,7 +17,7 @@
17
17
  import { Worker } from "node:worker_threads";
18
18
  import { Readable } from "node:stream";
19
19
  import { fileURLToPath } from "node:url";
20
- import { logger, writeAlreadyDecided } from "../../utils/logger.js";
20
+ import { logger, writeAlreadyDecided } from "../../../utils/logger.js";
21
21
  import { createCaller, createReceiveStream } from "./channel.js";
22
22
  import { Command, Event } from "./protocol.js";
23
23
 
@@ -107,21 +107,22 @@ export class TorrentWorkerClient {
107
107
  wholeFiles = new Map();
108
108
 
109
109
  /** @type {(event: { sourceKey: string, fileIndex: number, trackIndex: number, cues: object[], language: string }) => void} */
110
- #onSubtitleCues;
110
+ /** @type {(event: { sourceKey: string, fileIndexes: number[] }) => void} */
111
+ #onPiecesArrived;
111
112
 
112
113
  /**
113
- * @param {{ maxDiskBytes?: number, memoryBytes?: number, stateDir?: string, onSubtitleCues?: (event: object) => void }} [options]
114
+ * @param {{ memoryBytes?: number, stateDir?: string, onPiecesArrived?: (event: object) => void }} [options]
114
115
  */
115
- constructor({ maxDiskBytes, memoryBytes, stateDir, onSubtitleCues } = {}) {
116
+ constructor({ memoryBytes, stateDir, onPiecesArrived } = {}) {
116
117
  this.#worker = new Worker(fileURLToPath(WORKER_URL), {
117
118
  // `stateDir` travels because the worker writes heap snapshots of its own
118
119
  // isolate there. It cannot choose a directory any other way: a worker may
119
120
  // not change the process's working directory, and the isolate that has
120
121
  // died three times is the one no snapshot has ever been taken of.
121
- workerData: { maxDiskBytes, memoryBytes, stateDir }
122
+ workerData: { memoryBytes, stateDir }
122
123
  });
123
124
  this.#caller = createCaller(this.#worker);
124
- this.#onSubtitleCues = onSubtitleCues ?? (() => undefined);
125
+ this.#onPiecesArrived = onPiecesArrived ?? (() => undefined);
125
126
 
126
127
  this.#worker.on("message", (message) => {
127
128
  // A failed read must fail its stream. This is checked BEFORE the caller
@@ -232,14 +233,12 @@ export class TorrentWorkerClient {
232
233
  name: message.name
233
234
  });
234
235
  break;
235
- case Event.SUBTITLE_CUES_READY:
236
- this.#onSubtitleCues({
236
+ case Event.PIECES_ARRIVED:
237
+ // ANNOUNCED by the thread that owns the swarm, acted on here: the
238
+ // subtitle walk is this thread's and needs to know a piece arrived.
239
+ this.#onPiecesArrived({
237
240
  sourceKey: message.sourceKey,
238
- fileIndex: message.fileIndex,
239
- trackIndex: message.trackIndex,
240
- cues: message.cues,
241
- language: message.language,
242
- cursor: message.cursor
241
+ fileIndexes: Array.isArray(message.fileIndexes) ? message.fileIndexes : []
243
242
  });
244
243
  break;
245
244
  default:
@@ -336,75 +335,54 @@ export class TorrentWorkerClient {
336
335
  * @param {{ sourceKey: string, fileIndex: number }} params
337
336
  * @returns {Promise<{ tracks: object[] }>}
338
337
  */
339
- /**
340
- * Fetch one whole file in the room the viewer's own reading leaves.
341
- *
342
- * @param {{ sourceKey: string, fileIndex: number }} params
343
- * @returns {Promise<{ started: boolean }>}
344
- */
345
- async fillFile({ sourceKey, fileIndex }) {
346
- return this.#caller.call(Command.FILL_FILE, { sourceKey, fileIndex });
347
- }
348
338
 
349
- async getSubtitleTracks({ sourceKey, fileIndex }) {
350
- return this.#caller.call(Command.SUBTITLE_TRACKS, { sourceKey, fileIndex });
351
- }
339
+
340
+
352
341
 
353
342
  /**
354
- * Every track one file declares, read from its own header.
355
- *
356
- * Asked of the picture for the flags ffmpeg's banner does not carry, and of a
357
- * soundtrack shipped as its own file beside it — the same question about a
358
- * different file, which is why there is one command rather than two.
343
+ * Start fetching the region a viewer is about to resume at.
359
344
  *
360
- * @param {{ sourceKey: string, fileIndex: number }} params
361
- * @returns {Promise<{ tracks: object[] }>}
345
+ * @param {{ sourceKey: string, fileIndex: number, positionSeconds: number }} params
346
+ * @returns {Promise<{ started: boolean }>}
362
347
  */
363
- async getContainerTracks({ sourceKey, fileIndex }) {
364
- return this.#caller.call(Command.CONTAINER_TRACKS, { sourceKey, fileIndex });
365
- }
366
-
367
348
  /**
368
- * What one file declares about itself — format, duration, and where its own
369
- * timeline begins.
349
+ * Fetch one whole file in the room the viewer's own reading leaves.
370
350
  *
371
351
  * @param {{ sourceKey: string, fileIndex: number }} params
372
- * @returns {Promise<{ info: import("../container/Container.js").ContainerMediaInfo | null }>}
352
+ * @returns {Promise<{ started: boolean }>}
373
353
  */
374
- async getContainerMediaInfo({ sourceKey, fileIndex }) {
375
- return this.#caller.call(Command.CONTAINER_MEDIA_INFO, { sourceKey, fileIndex });
354
+ async fillFile({ sourceKey, fileIndex }) {
355
+ return this.#caller.call(Command.FILL_FILE, { sourceKey, fileIndex });
376
356
  }
377
357
 
378
358
  /**
379
- * Where one file's keyframes are, from the container's own table.
359
+ * The byte ranges of one file the torrent holds WHOLE.
380
360
  *
381
361
  * @param {{ sourceKey: string, fileIndex: number }} params
382
- * @returns {Promise<{ index: { times: number[], tolerance: number } | null }>}
362
+ * @returns {Promise<Array<[number, number]>>}
383
363
  */
384
- async getContainerKeyframes({ sourceKey, fileIndex }) {
385
- return this.#caller.call(Command.CONTAINER_KEYFRAMES, { sourceKey, fileIndex });
364
+ async heldRanges({ sourceKey, fileIndex }) {
365
+ const answer = await this.#caller.call(Command.HELD_RANGES, { sourceKey, fileIndex });
366
+ return Array.isArray(answer?.ranges) ? answer.ranges : [];
386
367
  }
387
368
 
388
369
  /**
389
- * Start fetching the region a viewer is about to resume at.
370
+ * Bytes of a range the torrent already holds. Never fetched: null where they
371
+ * are not there.
390
372
  *
391
- * @param {{ sourceKey: string, fileIndex: number, positionSeconds: number }} params
392
- * @returns {Promise<{ started: boolean }>}
373
+ * @param {{ sourceKey: string, fileIndex: number, start: number, end: number }} params
374
+ * @returns {Promise<Buffer | null>}
393
375
  */
394
- async warmResumePosition({ sourceKey, fileIndex, positionSeconds }) {
395
- return this.#caller.call(Command.WARM_POSITION, { sourceKey, fileIndex, positionSeconds });
376
+ async readHeld({ sourceKey, fileIndex, start, end }) {
377
+ const answer = await this.#caller.call(Command.READ_HELD, { sourceKey, fileIndex, start, end });
378
+ return answer?.bytes ? Buffer.from(answer.bytes) : null;
396
379
  }
397
380
 
398
- /**
399
- * The cues of one subtitle track that can be read from what is downloaded.
400
- *
401
- * @param {{ sourceKey: string, fileIndex: number, trackNumber: number }} params
402
- * @returns {Promise<object>}
403
- */
404
- async getSubtitleCues({ sourceKey, fileIndex, trackNumber }) {
405
- return this.#caller.call(Command.SUBTITLE_CUES, { sourceKey, fileIndex, trackNumber });
381
+ async warmResumePosition({ sourceKey, fileIndex, positionSeconds, durationSeconds }) {
382
+ return this.#caller.call(Command.WARM_POSITION, { sourceKey, fileIndex, positionSeconds, durationSeconds });
406
383
  }
407
384
 
385
+
408
386
  /**
409
387
  * Bytes every torrent on the worker has moved, downloaded and uploaded apart.
410
388
  *
@@ -684,6 +662,47 @@ export class TorrentWorkerClient {
684
662
  */
685
663
  spilledBytes = 0;
686
664
 
665
+ /**
666
+ * Tell the piece stores on the torrent thread their share of MEMORY.
667
+ *
668
+ * The same owner divides memory and disk, because the two trade: pieces that
669
+ * do not fit in memory are spilled to disk, so what the stores are given here
670
+ * decides how much disk they need there.
671
+ *
672
+ * @param {number} bytes
673
+ * @returns {Promise<{ held: number, wanted: number }>}
674
+ */
675
+ async allowMemoryBytes(bytes) {
676
+ try {
677
+ const claim = await this.#caller.call(Command.MEMORY_ALLOWANCE, { bytes });
678
+ this.memoryClaim = {
679
+ held: Number(claim?.held) || 0,
680
+ wanted: Number(claim?.wanted) || 0
681
+ };
682
+ } catch {
683
+ // The thread is gone or busy; the next revision says it again.
684
+ }
685
+ return this.memoryClaim ?? { held: 0, wanted: 0 };
686
+ }
687
+
688
+ /**
689
+ * Tell the whole files on the torrent thread their share of the disk.
690
+ *
691
+ * Same shape as the spilled pieces, and for the same reason: they hold bytes
692
+ * on this machine and the owner of the disk is on the other thread.
693
+ *
694
+ * @param {number} bytes
695
+ * @returns {Promise<number>} What they hold after it.
696
+ */
697
+ async allowWholeFileBytes(bytes) {
698
+ try {
699
+ this.wholeFileBytes = Number(await this.#caller.call(Command.WHOLE_FILES_ALLOWANCE, { bytes })) || 0;
700
+ } catch {
701
+ // The thread is gone or busy; the next revision says it again.
702
+ }
703
+ return this.wholeFileBytes ?? 0;
704
+ }
705
+
687
706
  /**
688
707
  * Say how much disk the spilled pieces on this thread may take between them.
689
708
  *
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @file What of a file the torrent already HOLDS, and reading it without
3
+ * asking the swarm for anything.
4
+ *
5
+ * Two questions, and both are the torrent's own: which pieces have arrived is
6
+ * its bitfield, and reading a piece it has is its store. They are here because
7
+ * of that and for no other reason — everything else that used to keep them
8
+ * company, the subtitle plan and the cue walk and the cursor a browser follows,
9
+ * is what a FILE says about itself and moved to the thread the sessions are on
10
+ * (2026-09-15).
11
+ *
12
+ * **Why the subtitle walk cannot use the ordinary range read.** That one
13
+ * declares demand: it registers a window, steers the swarm toward the piece it
14
+ * is stopped on, and waits. Right for a viewer waiting on a segment, wrong
15
+ * here — switching subtitles on must never pull bytes the viewer is not waiting
16
+ * for, which is the rule the walk has kept since it was written. So these two
17
+ * read the store and say "not here" rather than fetching.
18
+ *
19
+ * **The held ranges are answered as a LIST, not asked piece by piece.** The
20
+ * walk asks "is this cluster downloaded" for every cluster of the file — on the
21
+ * field files that is hundreds of questions per pass, and a pass runs every
22
+ * three seconds. One list per pass costs one message; the same answers asked
23
+ * one at a time cost hundreds of round trips for a walk that is meant to be
24
+ * free when there is nothing new to find.
25
+ */
26
+
27
+ /**
28
+ * The byte ranges of one file that are downloaded WHOLE.
29
+ *
30
+ * Whole because a piece is the unit the swarm delivers and the unit the
31
+ * bitfield counts: a range that is half a piece short cannot be read, so
32
+ * reporting it would be reporting bytes that are not there.
33
+ *
34
+ * @param {object} torrent
35
+ * @param {number} fileIndex
36
+ * @returns {Array<[number, number]>} Ascending, non-overlapping, inclusive
37
+ * offsets WITHIN the file. Empty when nothing of it has arrived, or when the
38
+ * torrent cannot say — which reads the same way: nothing may be walked yet.
39
+ */
40
+ export function heldRangesOf(torrent, fileIndex) {
41
+ const file = torrent?.files?.[fileIndex];
42
+ const pieceLength = Number(torrent?.pieceLength);
43
+ if (!file || !Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
44
+ return [];
45
+ }
46
+ const offset = Number(file.offset) || 0;
47
+ const length = Number(file.length) || 0;
48
+ if (!(length > 0)) {
49
+ return [];
50
+ }
51
+ const first = Math.floor(offset / pieceLength);
52
+ const last = Math.floor((offset + length - 1) / pieceLength);
53
+ /** @type {Array<[number, number]>} */
54
+ const ranges = [];
55
+ let runStart = -1;
56
+ for (let index = first; index <= last; index += 1) {
57
+ if (torrent.bitfield.get(index)) {
58
+ if (runStart < 0) {
59
+ runStart = index;
60
+ }
61
+ continue;
62
+ }
63
+ if (runStart >= 0) {
64
+ ranges.push(rangeWithin(runStart, index - 1, pieceLength, offset, length));
65
+ runStart = -1;
66
+ }
67
+ }
68
+ if (runStart >= 0) {
69
+ ranges.push(rangeWithin(runStart, last, pieceLength, offset, length));
70
+ }
71
+ return ranges;
72
+ }
73
+
74
+ /**
75
+ * A run of whole pieces, as offsets within the file.
76
+ *
77
+ * @param {number} firstPiece
78
+ * @param {number} lastPiece
79
+ * @param {number} pieceLength
80
+ * @param {number} offset - Where the file begins in the torrent.
81
+ * @param {number} length - How long the file is.
82
+ * @returns {[number, number]}
83
+ */
84
+ function rangeWithin(firstPiece, lastPiece, pieceLength, offset, length) {
85
+ const start = Math.max(0, firstPiece * pieceLength - offset);
86
+ const end = Math.min(length - 1, (lastPiece + 1) * pieceLength - 1 - offset);
87
+ return [start, end];
88
+ }
89
+
90
+ /**
91
+ * How long a read of bytes the torrent already holds may take before it is
92
+ * given up.
93
+ *
94
+ * Not a measurement and nothing is derived from it: such a read either answers
95
+ * or it does not, and this is the point past which it is presumed lost — so
96
+ * that one stream which never ends cannot hold a file's walk, and with it the
97
+ * browser's own request for its subtitles, for the rest of the session.
98
+ */
99
+ const READ_ABANDON_MS = 30_000;
100
+
101
+ /**
102
+ * Read a byte range of a file straight from the store, without asking the swarm
103
+ * for anything.
104
+ *
105
+ * @param {object} torrent
106
+ * @param {number} fileIndex
107
+ * @param {number} start
108
+ * @param {number} end - Inclusive.
109
+ * @param {{ info: Function }} [logger]
110
+ * @returns {Promise<Buffer | null>}
111
+ */
112
+ export function readHeldBytes(torrent, fileIndex, start, end, logger = null) {
113
+ const file = torrent?.files?.[fileIndex];
114
+ if (!file || !(end >= start) || !(start >= 0)) {
115
+ return Promise.resolve(null);
116
+ }
117
+ const last = Math.min(end, Number(file.length) - 1);
118
+ return new Promise((resolve) => {
119
+ const chunks = [];
120
+ let stream;
121
+ try {
122
+ stream = file.createReadStream({ start, end: last });
123
+ } catch {
124
+ resolve(null);
125
+ return;
126
+ }
127
+ let settled = false;
128
+ /** @type {ReturnType<typeof setTimeout> | null} */
129
+ let abandon = null;
130
+ const settle = (value) => {
131
+ if (settled) {
132
+ return;
133
+ }
134
+ settled = true;
135
+ if (abandon !== null) {
136
+ clearTimeout(abandon);
137
+ }
138
+ if (value === null) {
139
+ stream.destroy?.();
140
+ }
141
+ resolve(value);
142
+ };
143
+ abandon = setTimeout(() => {
144
+ logger?.info(
145
+ `subtitles: a read of ${start}-${last} in "${String(file.name).slice(0, 40)}" ` +
146
+ `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
147
+ );
148
+ settle(null);
149
+ }, READ_ABANDON_MS);
150
+ abandon.unref?.();
151
+ stream.on("data", (chunk) => chunks.push(chunk));
152
+ stream.on("end", () => settle(Buffer.concat(chunks)));
153
+ stream.on("error", () => settle(null));
154
+ });
155
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Imported for its side effect, and imported FIRST by `worker.js` — ES module
5
5
  * bodies run in import order, so registering the hook here happens before
6
- * `torrent-pool.js` pulls in WebTorrent, which is what reaches
6
+ * `torrent/torrent-pool.js` pulls in WebTorrent, which is what reaches
7
7
  * `@thaunknown/simple-peer` and, through it, `webrtc-polyfill`.
8
8
  *
9
9
  * Scope is deliberately narrow. The hook lives in the worker's isolate, so the
@@ -20,10 +20,10 @@
20
20
  * with those bytes — not when they were sent, because nothing was sent.
21
21
  */
22
22
 
23
- import { findSharedStore } from "../piece-store/shared-piece-store.js";
23
+ import { pieceStoreOf } from "../piece-store-of.js";
24
24
  import { bytesOf, Urgency, urgencyName } from "../demand/index.js";
25
25
  import { demandFor } from "../download/registry.js";
26
- import { logger } from "../../utils/logger.js";
26
+ import { logger } from "../../../utils/logger.js";
27
27
  import {
28
28
  askFastestWiresFor,
29
29
  canPlaceRequests,
@@ -144,7 +144,7 @@ export function pieceSupply(torrent, pieceIndex) {
144
144
  *
145
145
  * @param {import("webtorrent").Torrent} torrent
146
146
  * @param {number} index
147
- * @param {{ isCancelled: () => boolean }} cancellation
147
+ * @param {{ isCancelled: () => boolean, onCancel?: (listener: () => void) => () => void }} cancellation
148
148
  * @returns {Promise<void>}
149
149
  */
150
150
  function whenPieceReady(torrent, index, cancellation) {
@@ -164,17 +164,21 @@ function whenPieceReady(torrent, index, cancellation) {
164
164
  cleanup();
165
165
  reject(new Error(`Torrent went away while waiting for piece ${index}.`));
166
166
  };
167
- // Cancellation is polled rather than pushed: a superseded seek destroys the
168
- // read, and without this the wait would outlive it and hold a pin.
169
- const poll = setInterval(() => {
170
- if (cancellation.isCancelled()) {
171
- cleanup();
172
- reject(new Error(`Read cancelled while waiting for piece ${index}.`));
173
- }
174
- }, 250);
167
+ // A superseded seek destroys the read, and without this the wait would
168
+ // outlive it and hold a pin. The read's own stream says so the moment it
169
+ // happens; it was polled every 250 ms before. A cancellation that cannot
170
+ // announce itself is one that is never cancelled (a test's plain object).
171
+ if (cancellation.isCancelled()) {
172
+ reject(new Error(`Read cancelled while waiting for piece ${index}.`));
173
+ return;
174
+ }
175
+ const stopListening = cancellation.onCancel?.(() => {
176
+ cleanup();
177
+ reject(new Error(`Read cancelled while waiting for piece ${index}.`));
178
+ }) ?? (() => {});
175
179
 
176
180
  function cleanup() {
177
- clearInterval(poll);
181
+ stopListening();
178
182
  torrent.removeListener("verified", onVerified);
179
183
  torrent.removeListener("close", onDestroyed);
180
184
  }
@@ -301,20 +305,8 @@ export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
301
305
  const waitsBySteering = new Map();
302
306
 
303
307
  /**
304
- * Record one wait against the band the reader was stopped in.
305
- *
306
- * What this replaces: until 2026-09-02 each read was assigned at random to one
307
- * of two ways of claiming — one band or four — and the waits were sorted by
308
- * which. The comparison never decided anything, and could not: the split halved
309
- * the sample for each arm, so on 2026-08-28 there were nine reads in one arm and
310
- * three in the other and the ten-wait threshold was never reached in either; on
311
- * 2026-08-29 the two arms printed together for the first and only time, as forty
312
- * waits against one.
313
- *
314
- * This is the more useful question anyway. A wait belongs to a LEVEL, and the
315
- * level says whether a width is wrong rather than whether the whole scheme is:
316
- * long waits in the band being watched mean the urgent window is too narrow,
317
- * long waits further out mean the lead is.
308
+ * Record one wait against the priority level of the requested piece. The level
309
+ * identifies which part of the priority map needs more lead time.
318
310
  *
319
311
  * @param {string} key
320
312
  * @param {number} waitedMs
@@ -536,7 +528,7 @@ export async function* readFragments({
536
528
  cancellation,
537
529
  windowBytes = READ_WINDOW_BYTES
538
530
  }) {
539
- const store = findSharedStore(torrent);
531
+ const store = pieceStoreOf(torrent);
540
532
  if (!store) {
541
533
  throw new Error("This torrent is not backed by a shared piece store.");
542
534
  }
@@ -649,9 +641,8 @@ export async function* readFragments({
649
641
  /**
650
642
  * State one band as a need, in bytes.
651
643
  *
652
- * The claimant carries the level, so the four bands of one reader are four
653
- * claimants and each is replaced on its own — restating the near band does
654
- * not disturb what was said about the tail.
644
+ * The urgency is part of the claimant key, so this blocked-read claim can be
645
+ * withdrawn without changing the priority map's claims for the same file.
655
646
  *
656
647
  * @param {{ from: number, to: number, urgency: number }} band
657
648
  * @returns {void}