@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
@@ -1,414 +0,0 @@
1
- /**
2
- * The tracks a file declares, read from the file itself on the thread that owns
3
- * the torrent.
4
- *
5
- * This exists because the container layer (`services/container/`) reads bytes,
6
- * and the bytes live here: the torrent client runs on this worker thread, so the
7
- * main thread cannot open a read stream on one of its files. The main thread
8
- * asks over the channel (`Command.CONTAINER_TRACKS`) and gets plain objects
9
- * back, because a class instance does not survive the boundary as a class.
10
- *
11
- * It reads whatever container the file is — the same `ContainerFactory` that
12
- * serves the picture serves a `.mka` beside it, since a `.mka` IS Matroska and
13
- * differs only in having no video track. That is the whole reason no new class
14
- * was needed for a soundtrack shipped as its own file.
15
- *
16
- * Unlike the subtitle walk, the reads here DO wait on the swarm. A sidecar file
17
- * has usually had nothing downloaded at all when this is first asked, and its
18
- * header is a few hundred kilobytes; the alternative is offering the viewer a
19
- * soundtrack with nothing known about it.
20
- */
21
-
22
- import { containerOrchestrator } from "../orchestrators/ContainerOrchestrator.js";
23
- import { logger } from "../../utils/logger.js";
24
-
25
- /**
26
- * How long one range read may take before it is given up.
27
- *
28
- * Not a measurement and nothing is derived from it: it is the point past which a
29
- * read of a file nobody is playing is presumed lost, so that one stream which
30
- * never ends cannot hold the answer — and with it the viewer's audio menu — for
31
- * the rest of the session.
32
- */
33
- const READ_ABANDON_MS = 60_000;
34
-
35
- /** Bytes of the head a container's track table lives in. */
36
- const HEAD_BYTES = 256 * 1024;
37
-
38
- /**
39
- * Tracks already read, by `sourceKey:fileIndex`. Only a non-empty reading is
40
- * kept: an empty one usually means the header has not arrived yet, and caching
41
- * that would hide the file's tracks for the life of the process.
42
- *
43
- * @type {Map<string, object[]>}
44
- */
45
- const byFile = new Map();
46
-
47
- /** @type {Map<string, Promise<object[]>>} */
48
- const inFlight = new Map();
49
-
50
- /**
51
- * Read a byte range of a file, fetching what is missing.
52
- *
53
- * @param {object} file
54
- * @param {number} start
55
- * @param {number} end - Inclusive.
56
- * @returns {Promise<Buffer | null>}
57
- */
58
- function readFetching(file, start, end) {
59
- return new Promise((resolve) => {
60
- const chunks = [];
61
- let stream;
62
- try {
63
- stream = file.createReadStream({ start, end });
64
- } catch {
65
- resolve(null);
66
- return;
67
- }
68
- let settled = false;
69
- /** @type {ReturnType<typeof setTimeout> | null} */
70
- let abandon = null;
71
- const settle = (value) => {
72
- if (settled) {
73
- return;
74
- }
75
- settled = true;
76
- if (abandon !== null) {
77
- clearTimeout(abandon);
78
- }
79
- if (value === null) {
80
- stream.destroy?.();
81
- }
82
- resolve(value);
83
- };
84
- abandon = setTimeout(() => {
85
- logger.info(
86
- `container-tracks: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
87
- `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
88
- );
89
- settle(null);
90
- }, READ_ABANDON_MS);
91
- abandon.unref?.();
92
- stream.on("data", (chunk) => chunks.push(chunk));
93
- stream.on("end", () => settle(Buffer.concat(chunks)));
94
- stream.on("error", () => settle(null));
95
- });
96
- }
97
-
98
- /**
99
- * One track as it crosses the thread boundary: the fields the inventory and the
100
- * master playlist read, and nothing that would not survive being cloned.
101
- *
102
- * @param {import("../tracks/index.js").ContainerTrack} track
103
- * @returns {object}
104
- */
105
- function plainTrack(track) {
106
- return {
107
- type: track.type,
108
- trackNumber: track.trackNumber,
109
- declaredIndex: track.declaredIndex,
110
- codecId: track.codecId,
111
- language: track.resolvedLanguage(),
112
- languageBcp47: track.languageBcp47,
113
- name: track.name,
114
- isEnabled: track.isEnabled,
115
- isDefault: track.isDefault,
116
- declaresDefault: track.declaresDefault,
117
- // Audio-only, per RFC 9559 — absent on other types, and read as false there
118
- // rather than being placed on the base track where they do not belong.
119
- isOriginal: track.isOriginal === true,
120
- isCommentary: track.isCommentary === true,
121
- isVisualImpaired: track.isVisualImpaired === true,
122
- channels: Number.isFinite(track.channels) ? track.channels : null,
123
- samplingFrequency: Number.isFinite(track.samplingFrequency) ? track.samplingFrequency : null
124
- };
125
- }
126
-
127
- /**
128
- * Every track one file declares, in container order.
129
- *
130
- * @param {object} torrent
131
- * @param {number} fileIndex
132
- * @param {string} sourceKey
133
- * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
134
- * @returns {Promise<object[]>}
135
- */
136
- export async function containerTracksOf(torrent, fileIndex, sourceKey, options = {}) {
137
- const key = `${sourceKey}:${fileIndex}`;
138
- const held = byFile.get(key);
139
- if (Array.isArray(held)) {
140
- return held;
141
- }
142
- const running = inFlight.get(key);
143
- if (running) {
144
- return running;
145
- }
146
- const work = readTracks(torrent, fileIndex, sourceKey, options)
147
- .then((tracks) => {
148
- if (tracks.length > 0) {
149
- byFile.set(key, tracks);
150
- }
151
- return tracks;
152
- })
153
- .finally(() => {
154
- inFlight.delete(key);
155
- });
156
- inFlight.set(key, work);
157
- return work;
158
- }
159
-
160
- /**
161
- * @param {object} torrent
162
- * @param {number} fileIndex
163
- * @param {string} sourceKey
164
- * @param {{ prefetchEdges?: () => Promise<unknown> }} options
165
- * @returns {Promise<object[]>}
166
- */
167
- async function readTracks(torrent, fileIndex, sourceKey, options) {
168
- const file = torrent?.files?.[fileIndex];
169
- if (!file || !Number.isFinite(file.length) || file.length <= 0) {
170
- return [];
171
- }
172
- if (typeof options.prefetchEdges === "function") {
173
- try {
174
- await options.prefetchEdges();
175
- } catch {
176
- // A prefetch that failed is not a reason to skip the read: the read
177
- // fetches what it needs itself, only more slowly.
178
- }
179
- }
180
- const readRange = async (start, end) =>
181
- readFetching(file, start, Math.min(end, file.length - 1));
182
- try {
183
- const tracks = await containerOrchestrator.getTracks({
184
- sourceKey,
185
- fileIndex,
186
- readRange,
187
- fileSize: file.length,
188
- label: String(file.name ?? "")
189
- });
190
- const plain = tracks.map(plainTrack);
191
- if (plain.length > 0) {
192
- logger.info(
193
- `container-tracks: "${String(file.name).slice(0, 40)}" declares ` +
194
- `${plain.filter((track) => track.type === "video").length} video, ` +
195
- `${plain.filter((track) => track.type === "audio").length} audio, ` +
196
- `${plain.filter((track) => track.type === "subtitle").length} subtitle track(s)`
197
- );
198
- }
199
- return plain;
200
- } catch (error) {
201
- logger.warn(
202
- `container-tracks: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
203
- );
204
- return [];
205
- }
206
- }
207
-
208
- /** Bytes of a file's head worth fetching before its track table is read. */
209
- export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
210
-
211
- /**
212
- * How much of the file to pull in under the viewer's resume position.
213
- *
214
- * One piece of a video torrent is 4-16 MB and a resume lands anywhere inside
215
- * one, so anything smaller would still leave the encoder waiting for the piece
216
- * it starts in. Eight megabytes covers that piece and usually the next.
217
- */
218
- const RESUME_REGION_BYTES = 8 * 1024 * 1024;
219
-
220
- /**
221
- * Where a position in seconds falls in a file, in bytes.
222
- *
223
- * Proportional, and therefore approximate on a variable bitrate — which is what
224
- * it is for: a prefetch that puts the swarm to work on roughly the right place
225
- * while the plan and the session are still being built. The encoder's own read
226
- * asks for the exact bytes a moment later and corrects it.
227
- *
228
- * A position past the end is clamped to the end rather than refused: a resume
229
- * position can outlive the file it was recorded against, and reading the last
230
- * bytes is harmless where reading past them is an error.
231
- *
232
- * @param {number} fileLength
233
- * @param {number} durationSeconds
234
- * @param {number} positionSeconds
235
- * @returns {number}
236
- */
237
- export function resumeByteOffset(fileLength, durationSeconds, positionSeconds) {
238
- if (!(fileLength > 0) || !(durationSeconds > 0) || !(positionSeconds > 0)) {
239
- return 0;
240
- }
241
- const within = Math.min(positionSeconds, durationSeconds);
242
- return Math.min(fileLength - 1, Math.floor((fileLength * within) / durationSeconds));
243
- }
244
-
245
- /**
246
- * Start fetching the region a viewer is about to resume at.
247
- *
248
- * Where that region IS can only be worked out from two numbers the file itself
249
- * holds — its length and its duration — so this belongs beside the container
250
- * read rather than in the route: the route knows a position in seconds and
251
- * nothing else. The conversion is proportional and therefore approximate on a
252
- * variable bitrate; it is a prefetch, and the encoder's own read corrects it.
253
- *
254
- * @param {object} torrent
255
- * @param {number} fileIndex
256
- * @param {string} sourceKey
257
- * @param {number} positionSeconds
258
- * @param {{ prefetchEdges?: () => Promise<unknown>, fetchRegion?: (start: number, bytes: number) => Promise<unknown> }} options
259
- * @returns {Promise<boolean>} Whether a region was asked for.
260
- */
261
- export async function warmResumePosition(torrent, fileIndex, sourceKey, positionSeconds, options = {}) {
262
- const file = torrent?.files?.[fileIndex];
263
- if (!file || !(positionSeconds > 0) || typeof options.fetchRegion !== "function") {
264
- return false;
265
- }
266
- const info = await containerMediaInfoOf(torrent, fileIndex, sourceKey, options);
267
- const duration = info?.durationSeconds;
268
- if (!Number.isFinite(duration) || duration <= 0) {
269
- logger.info(
270
- `warm ${sourceKey.slice(0, 8)}: "${String(file.name).slice(0, 40)}" does not declare its ` +
271
- "duration, so where the viewer's position falls in it cannot be worked out — " +
272
- "the region under it is left to the encoder's own read"
273
- );
274
- return false;
275
- }
276
- const at = resumeByteOffset(file.length, duration, positionSeconds);
277
- logger.info(
278
- `warm ${sourceKey.slice(0, 8)}: fetching ${(RESUME_REGION_BYTES / (1024 * 1024)).toFixed(0)}MB under the ` +
279
- `viewer's position ${positionSeconds.toFixed(1)}s of ${duration.toFixed(1)}s, which is ` +
280
- `${(at / (1024 * 1024)).toFixed(1)}MB into "${String(file.name).slice(0, 40)}"`
281
- );
282
- await options.fetchRegion(at, RESUME_REGION_BYTES);
283
- return true;
284
- }
285
-
286
- /**
287
- * Where one file's keyframes are, from the container's own table.
288
- *
289
- * A property of immutable bytes, like the duration and the track list, and read
290
- * here for the same reason they are: the container is cached per file, so the
291
- * table is read ONCE however many sessions ask for it, and two sessions created
292
- * in the same moment join one read instead of making two. That is the two-viewer
293
- * case exactly — measured 2026-09-03, two pictures of one file were created 13 ms
294
- * apart, and each read the table for itself over the proxy's own HTTP.
295
- *
296
- * It decides which branch a copy takes: a picture can only be cut where the
297
- * source already has a keyframe, so a file with no readable index is re-encoded
298
- * instead. That decision belongs to the FILE, and it is the same for everybody
299
- * watching it.
300
- *
301
- * @param {object} torrent
302
- * @param {number} fileIndex
303
- * @param {string} sourceKey
304
- * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
305
- * @returns {Promise<{ times: number[], tolerance: number } | null>}
306
- */
307
- export async function containerKeyframesOf(torrent, fileIndex, sourceKey, options = {}) {
308
- const file = torrent?.files?.[fileIndex];
309
- if (!file || !Number.isFinite(file.length) || file.length <= 0) {
310
- return null;
311
- }
312
- // Timed apart from the read below, and reported apart from it, because the
313
- // two are different quantities and reading them as one led to a wrong
314
- // conclusion on 2026-09-04: this wait is the SWARM delivering the head and
315
- // tail of the file, and it reached 60 s on a torrent with one peer, while the
316
- // read that follows it is parsing bytes already in hand and never passed
317
- // 8.4 s across four containers. A bound belongs on the first, not the second.
318
- const edgesStartedAt = Date.now();
319
- if (typeof options.prefetchEdges === "function") {
320
- try {
321
- await options.prefetchEdges();
322
- } catch {
323
- // A prefetch that failed is not a reason to skip the read: the read
324
- // fetches what it needs itself, only more slowly.
325
- }
326
- }
327
- const edgesMs = Date.now() - edgesStartedAt;
328
- const readRange = async (start, end) =>
329
- readFetching(file, start, Math.min(end, file.length - 1));
330
- const startedAt = Date.now();
331
- const params = {
332
- sourceKey,
333
- fileIndex,
334
- readRange,
335
- fileSize: file.length,
336
- label: String(file.name ?? "")
337
- };
338
- try {
339
- const index = await containerOrchestrator.getKeyframeIndex(params);
340
- // Which container answered, reported whether or not it produced anything:
341
- // how often an index disagrees with its own file is a question about the
342
- // CONTAINER, and a measurement that does not say which one cannot answer
343
- // it. Free — the container is the cached one that has just read the table.
344
- const format = (await containerOrchestrator.getContainer(params))?.formatName ?? "unrecognised";
345
- const times = Array.isArray(index?.times) && index.times.length > 0 ? index.times : null;
346
- logger.info(
347
- `container-keyframes: "${String(file.name).slice(0, 40)}" ` +
348
- (times
349
- ? `${times.length} keyframes from the ${format} index in ${Date.now() - startedAt}ms`
350
- : `has no readable index (${format}, ${Date.now() - startedAt}ms)`) +
351
- `, after ${edgesMs}ms waiting for the file's edges`
352
- );
353
- return {
354
- times,
355
- format,
356
- tolerance: Number.isFinite(index?.tolerance) ? index.tolerance : 0
357
- };
358
- } catch (error) {
359
- logger.warn(
360
- `container-keyframes: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
361
- );
362
- return null;
363
- }
364
- }
365
-
366
- /**
367
- * What one file declares about itself as a whole.
368
- *
369
- * @param {object} torrent
370
- * @param {number} fileIndex
371
- * @param {string} sourceKey
372
- * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
373
- * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
374
- */
375
- export async function containerMediaInfoOf(torrent, fileIndex, sourceKey, options = {}) {
376
- const file = torrent?.files?.[fileIndex];
377
- if (!file || !Number.isFinite(file.length) || file.length <= 0) {
378
- return null;
379
- }
380
- if (typeof options.prefetchEdges === "function") {
381
- try {
382
- await options.prefetchEdges();
383
- } catch {
384
- // A prefetch that failed is not a reason to skip the read: the read
385
- // fetches what it needs itself, only more slowly.
386
- }
387
- }
388
- const readRange = async (start, end) =>
389
- readFetching(file, start, Math.min(end, file.length - 1));
390
- try {
391
- const info = await containerOrchestrator.getMediaInfo({
392
- sourceKey,
393
- fileIndex,
394
- readRange,
395
- fileSize: file.length,
396
- label: String(file.name ?? "")
397
- });
398
- if (info) {
399
- logger.info(
400
- `container-info: "${String(file.name).slice(0, 40)}" is ${info.format}, ` +
401
- `${info.durationSeconds === null ? "duration not declared" : `${info.durationSeconds.toFixed(3)}s`}, ` +
402
- `${info.startTimeSeconds === null
403
- ? "start of its timeline not declared"
404
- : `its timeline starts at ${info.startTimeSeconds.toFixed(6)}s`}`
405
- );
406
- }
407
- return info;
408
- } catch (error) {
409
- logger.warn(
410
- `container-info: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
411
- );
412
- return null;
413
- }
414
- }
@@ -1,142 +0,0 @@
1
- /**
2
- * @file The session's one statement of what it has produced.
3
- *
4
- * Every check builds real directories and real files, because what is under
5
- * test is exactly the reading of them: which run's copy wins, what an empty
6
- * file counts as, and whether a directory that has not changed is read again.
7
- */
8
-
9
- import test from "node:test";
10
- import assert from "node:assert/strict";
11
- import path from "node:path";
12
- import os from "node:os";
13
- import { mkdtempSync, writeFileSync, rmSync, unlinkSync } from "node:fs";
14
-
15
- import { ProducedIndex } from "../services/produced-index.js";
16
-
17
- /** The naming the fMP4 format uses, reduced to what the index asks of it. */
18
- const segmentFormat = {
19
- isSegmentFileName: (name) => /^segment-\d{5}\.mp4$/.test(name),
20
- segmentIndexFromName: (name) => {
21
- const match = /^segment-(\d{5})\.mp4$/.exec(name);
22
- return match ? Number(match[1]) : -1;
23
- }
24
- };
25
-
26
- const nameOf = (index) => `segment-${String(index).padStart(5, "0")}.mp4`;
27
-
28
- function output() {
29
- const dirPath = mkdtempSync(path.join(os.tmpdir(), "produced-index-"));
30
- return {
31
- dirPath,
32
- write(index, contents) {
33
- writeFileSync(path.join(dirPath, nameOf(index)), contents);
34
- return path.join(dirPath, nameOf(index));
35
- },
36
- writeNamed(name, contents) {
37
- writeFileSync(path.join(dirPath, name), contents);
38
- return path.join(dirPath, name);
39
- },
40
- index: () => new ProducedIndex({ dirPath, segmentFormat }),
41
- remove: () => rmSync(dirPath, { recursive: true, force: true })
42
- };
43
- }
44
-
45
- test("a file with bytes in it is produced; one that was only opened is not", () => {
46
- const s = output();
47
- try {
48
- s.write(1, "a piece");
49
- s.write(2, "");
50
- const index = s.index();
51
-
52
- assert.deepEqual([...index.segmentNumbers()], [1]);
53
- assert.equal(index.pathOf(nameOf(1)), path.join(s.dirPath, nameOf(1)));
54
- // The empty file exists and is not an answer — this is the pair of beliefs
55
- // that stopped playback for ten minutes on 2026-09-03, now one belief.
56
- assert.equal(index.pathOf(nameOf(2)), null);
57
- } finally {
58
- s.remove();
59
- }
60
- });
61
-
62
-
63
-
64
- test("a file that is not a segment is found by name", () => {
65
- const s = output();
66
- try {
67
- s.writeNamed("init.mp4", "header");
68
- const index = s.index();
69
- assert.equal(index.pathOf("init.mp4"), path.join(s.dirPath, "init.mp4"));
70
- assert.ok(index.fileNames().includes("init.mp4"));
71
- } finally {
72
- s.remove();
73
- }
74
- });
75
-
76
- test("a directory that is gone stops answering", () => {
77
- const s = output();
78
- try {
79
- s.write(4, "piece");
80
- const index = s.index();
81
- assert.equal(index.segmentNumbers().size, 1);
82
-
83
- rmSync(s.dirPath, { recursive: true, force: true });
84
- assert.equal(index.segmentNumbers().size, 0);
85
- assert.equal(index.pathOf(nameOf(4)), null);
86
- } finally {
87
- s.remove();
88
- }
89
- });
90
-
91
- test("a file removed on purpose is not still an answer", () => {
92
- const s = output();
93
- try {
94
- const written = s.write(9, "piece");
95
- const index = s.index();
96
- assert.equal(index.pathOf(nameOf(9)), written);
97
-
98
- unlinkSync(written);
99
- // The directory's own time has moved, but a caller that deletes and asks in
100
- // the same tick must not be told the file is there.
101
- index.invalidate();
102
- assert.equal(index.pathOf(nameOf(9)), null);
103
- } finally {
104
- s.remove();
105
- }
106
- });
107
-
108
- test("an unchanged directory is not listed again", () => {
109
- const s = output();
110
- try {
111
- s.write(1, "piece");
112
- const index = s.index();
113
-
114
- index.segmentNumbers();
115
- assert.equal(index.directoryReads, 1, "the first question reads what is there and no more");
116
-
117
- for (let asked = 0; asked < 20; asked += 1) {
118
- index.segmentNumbers();
119
- index.pathOf(nameOf(1));
120
- }
121
- assert.equal(
122
- index.directoryReads,
123
- 1,
124
- "forty more questions about a directory that has not changed must read nothing"
125
- );
126
-
127
- s.write(2, "another piece");
128
- index.segmentNumbers();
129
- assert.equal(index.directoryReads, 2, "a directory that gained a file is read once more");
130
- } finally {
131
- s.remove();
132
- }
133
- });
134
-
135
- test("an output directory that does not exist answers with nothing", () => {
136
- const index = new ProducedIndex({
137
- dirPath: path.join(os.tmpdir(), "produced-index-absent-0000"),
138
- segmentFormat
139
- });
140
- assert.equal(index.pathOf(nameOf(1)), null);
141
- assert.deepEqual([...index.segmentNumbers()], []);
142
- });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes