@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
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @file One budget: one owner, one policy, a share per resource.
3
+ *
4
+ * What it replaced was two owners applying one rule to two readings — memory
5
+ * divided inside the torrent thread, disk divided on the main one, neither able
6
+ * to see the other. The claimants TRADE ACROSS RESOURCES: pieces that do not
7
+ * fit in memory are spilled to disk, so how much memory the piece store is
8
+ * given decides how much disk it needs. Field 2026-08-31: 14 400 MB spilled in
9
+ * fifty minutes while the memory store held 312-424 MB.
10
+ *
11
+ * And "disk" is not one resource. Measured on the addon host 2026-09-05,
12
+ * `/tmp` is the overlay filesystem and `/data` is ext4 on the nvme — two
13
+ * devices, so one figure divided between claimants on both gives each a share
14
+ * of a disk it does not write to.
15
+ */
16
+
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { MachineBudget } from "../services/storage/MachineBudget.js";
20
+
21
+ const MEGABYTE = 1024 * 1024;
22
+
23
+ /**
24
+ * @param {{ name: string, resource: string, held?: number, wanted: number, minimum?: number }} shape
25
+ * @returns {{ claimant: object, allowed: () => number }}
26
+ */
27
+ function claimantOf({ name, resource, held = 0, wanted, minimum }) {
28
+ let allowed = 0;
29
+ return {
30
+ claimant: {
31
+ name,
32
+ resource,
33
+ minimum,
34
+ held: () => held,
35
+ wanted: () => wanted,
36
+ allow: (bytes) => {
37
+ allowed = bytes;
38
+ }
39
+ },
40
+ allowed: () => allowed
41
+ };
42
+ }
43
+
44
+ test("each resource is divided on its own — a share of memory is not a share of disk", async () => {
45
+ const budget = new MachineBudget({ logger: { info: () => {}, warn: () => {} } });
46
+ budget.defineResource({ name: "memory", readFree: () => 100 * MEGABYTE });
47
+ budget.defineResource({ name: "disk:tmp", readFree: () => 10 * MEGABYTE });
48
+
49
+ const pieces = claimantOf({ name: "pieces in memory", resource: "memory", wanted: Number.MAX_SAFE_INTEGER });
50
+ const segments = claimantOf({ name: "segments", resource: "disk:tmp", wanted: Number.MAX_SAFE_INTEGER });
51
+ budget.register(pieces.claimant);
52
+ budget.register(segments.claimant);
53
+
54
+ await budget.revise();
55
+
56
+ assert.equal(pieces.allowed(), 100 * MEGABYTE, "memory is divided from what memory has");
57
+ assert.equal(segments.allowed(), 10 * MEGABYTE, "and disk from what that disk has");
58
+ });
59
+
60
+ test("two devices are two resources — one is not cut to pay for the other", async () => {
61
+ const budget = new MachineBudget({ logger: { info: () => {}, warn: () => {} } });
62
+ budget.defineResource({ name: "disk:66305", readFree: () => 8 * MEGABYTE });
63
+ budget.defineResource({ name: "disk:68", readFree: () => 80 * MEGABYTE });
64
+
65
+ // The shape on the addon host: segments and spill on the overlay, the
66
+ // evidence on the nvme.
67
+ const segments = claimantOf({ name: "segments", resource: "disk:68", wanted: Number.MAX_SAFE_INTEGER });
68
+ const spill = claimantOf({ name: "spilled pieces", resource: "disk:68", wanted: Number.MAX_SAFE_INTEGER });
69
+ const evidence = claimantOf({ name: "diagnostics", resource: "disk:66305", wanted: Number.MAX_SAFE_INTEGER });
70
+ for (const one of [segments, spill, evidence]) {
71
+ budget.register(one.claimant);
72
+ }
73
+
74
+ await budget.revise();
75
+
76
+ assert.equal(evidence.allowed(), 8 * MEGABYTE, "the evidence gets the disk it actually writes to, whole");
77
+ assert.equal(segments.allowed() + spill.allowed(), 80 * MEGABYTE, "and the other device is divided among its own");
78
+ });
79
+
80
+ test("a fixed ceiling is the operator's, and it binds", async () => {
81
+ const budget = new MachineBudget({
82
+ policy: { kind: "fixed", bytes: 5 * MEGABYTE },
83
+ logger: { info: () => {}, warn: () => {} }
84
+ });
85
+ budget.defineResource({ name: "disk", readFree: () => 1000 * MEGABYTE });
86
+ const segments = claimantOf({ name: "segments", resource: "disk", wanted: Number.MAX_SAFE_INTEGER });
87
+ budget.register(segments.claimant);
88
+
89
+ await budget.revise();
90
+
91
+ assert.equal(segments.allowed(), 5 * MEGABYTE, "a disk with a gigabyte free is still capped at what was asked for");
92
+ });
93
+
94
+ test("a share of free is the operator's too", async () => {
95
+ const budget = new MachineBudget({
96
+ policy: { kind: "share", share: 0.25 },
97
+ logger: { info: () => {}, warn: () => {} }
98
+ });
99
+ budget.defineResource({ name: "disk", readFree: () => 40 * MEGABYTE });
100
+ const segments = claimantOf({ name: "segments", resource: "disk", held: 0, wanted: Number.MAX_SAFE_INTEGER });
101
+ budget.register(segments.claimant);
102
+
103
+ await budget.revise();
104
+
105
+ assert.equal(segments.allowed(), 10 * MEGABYTE);
106
+ });
107
+
108
+ test("a floor lifts a policy that would allow less, and cannot invent room", async () => {
109
+ const budget = new MachineBudget({
110
+ policy: { kind: "fixed", bytes: MEGABYTE, floors: { disk: 6 * MEGABYTE } },
111
+ logger: { info: () => {}, warn: () => {} }
112
+ });
113
+ budget.defineResource({ name: "disk", readFree: () => 4 * MEGABYTE });
114
+ const segments = claimantOf({ name: "segments", resource: "disk", wanted: Number.MAX_SAFE_INTEGER });
115
+ budget.register(segments.claimant);
116
+
117
+ await budget.revise();
118
+
119
+ assert.equal(
120
+ segments.allowed(),
121
+ 4 * MEGABYTE,
122
+ "the floor lifts the ceiling above the fixed one, but never past what the machine has"
123
+ );
124
+ });
125
+
126
+ test("a resource that cannot be read allows nothing to grow", async () => {
127
+ const budget = new MachineBudget({ logger: { info: () => {}, warn: () => {} } });
128
+ budget.defineResource({ name: "disk", readFree: () => null });
129
+ const segments = claimantOf({ name: "segments", resource: "disk", held: 0, wanted: Number.MAX_SAFE_INTEGER });
130
+ budget.register(segments.claimant);
131
+
132
+ await budget.revise();
133
+
134
+ assert.equal(segments.allowed(), 0, "unreadable is not the same as unbounded");
135
+ });
136
+
137
+ test("a claimant below its own minimum says so", async () => {
138
+ const said = [];
139
+ const budget = new MachineBudget({ logger: { info: () => {}, warn: (line) => said.push(line) } });
140
+ budget.defineResource({ name: "memory", readFree: () => MEGABYTE });
141
+ budget.register(claimantOf({
142
+ name: "pieces in memory", resource: "memory", wanted: Number.MAX_SAFE_INTEGER, minimum: 64 * MEGABYTE
143
+ }).claimant);
144
+
145
+ await budget.revise();
146
+
147
+ assert.equal(said.length, 1, "working below what it needs is a fault of the machine, and invisible otherwise");
148
+ assert.match(said[0], /below the 64MB it needs/);
149
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @file Many viewers of one output are handed one session.
3
+ *
4
+ * This is the product behaviour the whole output key exists for: two people
5
+ * watching the same film, in the same form, at whatever moments, are served by
6
+ * one encode and one set of pieces. It had no check of any kind — every test
7
+ * that needed a session either built one by hand or replaced
8
+ * `createOrGetSession` with a stub — so the one line that decides it could have
9
+ * been broken by any edit without a word.
10
+ *
11
+ * What is checked here is the decision, not the creation: a session under the
12
+ * name this request produces already exists, and the request must be answered
13
+ * with it rather than with a new one. The name is the format produced, so the
14
+ * request is first taken as far as the format — the file's facts and its
15
+ * keyframe table are given as already known — and making the FIRST session,
16
+ * which reaches the disk, is a different subject.
17
+ */
18
+
19
+ import test from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { wireOutputs } from "../services/server/wire-outputs.js";
22
+ import { AudioOutput, CutGrid, OutputSpec, VideoOutput } from "../services/encode/output/OutputSpec.js";
23
+ import { Timeline } from "../services/encode/output/Timeline.js";
24
+ import { SourceFile } from "../services/media/SourceFile.js";
25
+
26
+ const TORRENT = "torrent:11f0929918e2b5aa2e5b71ecdbe5c0f1a4bbf7d1";
27
+
28
+ /**
29
+ * What a browser asks for: this file, copied, sound inside the stream.
30
+ *
31
+ * @param {object} [over]
32
+ * @returns {object}
33
+ */
34
+ function request(over = {}) {
35
+ return {
36
+ sourceKey: TORRENT,
37
+ fileIndex: 0,
38
+ fileName: "film.mkv",
39
+ transcodeVideo: false,
40
+ transcodeAudio: false,
41
+ audioTrackIndex: 0,
42
+ segmentFormatId: "fmp4",
43
+ ...over
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The name the manager will look that request up under, built the way it builds
49
+ * it: a copied picture carrying its own sound, cut at the file's keyframes.
50
+ *
51
+ * @returns {string}
52
+ */
53
+ function specOfThatOutput() {
54
+ return new OutputSpec({
55
+ sourceKey: TORRENT,
56
+ segmentFormatId: "fmp4",
57
+ grid: new CutGrid({ kind: "keyframe", fileIndex: 0 }),
58
+ video: new VideoOutput({ fileIndex: 0, encode: null }),
59
+ audio: new AudioOutput({ fileIndex: 0, trackIndex: 0, transcode: false })
60
+ });
61
+ }
62
+
63
+ /** @returns {string} */
64
+ function nameOfThatOutput() {
65
+ return specOfThatOutput().toName();
66
+ }
67
+
68
+ /**
69
+ * @param {object} manager
70
+ * @returns {object} The session it is seeded with.
71
+ */
72
+ function seedOneSession(manager) {
73
+ const session = {
74
+ id: nameOfThatOutput(),
75
+ spec: specOfThatOutput(),
76
+ outputKey: "seeded",
77
+ state: "ready",
78
+ file: new SourceFile({ sourceKey: TORRENT, fileIndex: 0, name: "film.mkv" }),
79
+ timeline: new Timeline({ boundaries: [0, 4, 8], cutGrid: "keyframe" }),
80
+ runs: new Set(),
81
+ progress: { processedSeconds: 0, startPositionSeconds: 0, updatedAt: Date.now() },
82
+ lastAccessedAt: Date.now(),
83
+ // READY, because a joining viewer waits for the session to be able to
84
+ // answer them, and this file is about which session answers rather than
85
+ // about that wait. A session publishing its own playlist from the probed
86
+ // duration has nothing to wait for; left out, every check here would sit
87
+ // out the warm-up deadline and would be measuring that timer.
88
+ useSyntheticPlaylist: true
89
+ };
90
+ manager.outputs.set(session.id, session);
91
+ return session;
92
+ }
93
+
94
+ /**
95
+ * What the proxy already knows about the file when a second viewer arrives:
96
+ * what a probe said, and where its keyframes are.
97
+ *
98
+ * @param {object} manager
99
+ */
100
+ function knownFile(manager) {
101
+ manager.getCachedMediaInfo = () => ({ durationSeconds: 8, width: 1920, height: 1080, fps: 24 });
102
+ manager.keyframeTables.learn({ sourceKey: TORRENT, fileIndex: 0 }, { times: [0, 4], format: "test" });
103
+ }
104
+
105
+ test("a second viewer of one output is served by the session that exists", async (t) => {
106
+ const manager = wireOutputs({
107
+ enabled: true,
108
+ ffmpegBin: "ffmpeg",
109
+ localBindHost: "127.0.0.1",
110
+ localPort: 9090
111
+ });
112
+ t.after(() => manager.lifecycle.disposeAll());
113
+ // No plan runs here: this file is about which session answers a request, and
114
+ // placing encoders would spawn real processes.
115
+ manager.encodeRuns.planEncodersNow = () => {};
116
+ manager.encodeRuns.planEncodersSoon = () => {};
117
+ knownFile(manager);
118
+ const seeded = seedOneSession(manager);
119
+
120
+ const answered = await manager.viewerRequests.createOrGetSession(request({ consumerId: "viewer-two" }));
121
+
122
+ assert.equal(answered, seeded, "one output, one session");
123
+ assert.ok(manager.viewers.forOutput(seeded).has("viewer-two"), "and the second viewer is on it");
124
+ });
125
+
126
+ test("a request whose parameters differ is not that session", async (t) => {
127
+ // The other half, and it is what makes the first mean anything: the name is
128
+ // the output's, so a request producing something else must not find it. Here
129
+ // the sound is to be re-encoded, which is a different output and a different
130
+ // set of bytes.
131
+ const manager = wireOutputs({
132
+ enabled: true,
133
+ ffmpegBin: "ffmpeg",
134
+ localBindHost: "127.0.0.1",
135
+ localPort: 9090,
136
+ // A session made for real here waits for a playlist that nothing is going
137
+ // to write. Zero, so this check spends no time on a deadline it is not
138
+ // about — the deadline has its own subject elsewhere.
139
+ startupWaitMs: 0
140
+ });
141
+ t.after(() => manager.lifecycle.disposeAll());
142
+ manager.encodeRuns.planEncodersNow = () => {};
143
+ manager.encodeRuns.planEncodersSoon = () => {};
144
+ const seeded = seedOneSession(manager);
145
+
146
+ let answered = null;
147
+ try {
148
+ answered = await manager.viewerRequests.createOrGetSession(request({ consumerId: "viewer-two", transcodeAudio: true }));
149
+ } catch {
150
+ // Making a session for real needs a probe and a disk, which this file does
151
+ // not give it. Failing there is the proof: it did not take the seeded one.
152
+ answered = null;
153
+ }
154
+
155
+ assert.notEqual(answered, seeded);
156
+ assert.ok(!manager.viewers.forOutput(seeded).has("viewer-two"), "and nobody was added to somebody else's output");
157
+ });
158
+
159
+ test("two screens that come to the same format share one output, and a different format does not", async (t) => {
160
+ const manager = wireOutputs({
161
+ enabled: true,
162
+ ffmpegBin: "ffmpeg",
163
+ localBindHost: "127.0.0.1",
164
+ localPort: 9090,
165
+ startupWaitMs: 0,
166
+ // A host with no ladder to choose from: the box asked for, fitted to the
167
+ // source, is what is produced.
168
+ videoEncoder: { kind: "vaapi", name: "h264_vaapi", inputArgs: [] }
169
+ });
170
+ t.after(() => manager.lifecycle.disposeAll());
171
+ manager.encodeRuns.planEncodersNow = () => {};
172
+ manager.encodeRuns.planEncodersSoon = () => {};
173
+ knownFile(manager);
174
+ const spec = new OutputSpec({
175
+ sourceKey: TORRENT,
176
+ segmentFormatId: "fmp4",
177
+ grid: new CutGrid({ kind: "uniform", fileIndex: 0 }),
178
+ video: new VideoOutput({
179
+ fileIndex: 0,
180
+ encode: { encoder: "h264_vaapi", width: 1920, height: 1080, fps: 24, preset: null, tonemap: false }
181
+ }),
182
+ audio: new AudioOutput({ fileIndex: 0, trackIndex: 0, transcode: false })
183
+ });
184
+ const seeded = { ...seedOneSession(manager), id: spec.toName(), spec, timeline: new Timeline({ boundaries: [0, 4, 8], cutGrid: "uniform" }) };
185
+ manager.outputs.delete(nameOfThatOutput());
186
+ manager.outputs.set(seeded.id, seeded);
187
+
188
+ // A taller window than the film: fitted to the source it is the same 1920x1080.
189
+ const joined = await manager.viewerRequests.createOrGetSession(
190
+ request({ consumerId: "tall-window", transcodeVideo: true, targetWidth: 1920, targetHeight: 1200 })
191
+ );
192
+ assert.equal(joined, seeded, "the key names the format produced, not the window it was asked for");
193
+
194
+ let other = null;
195
+ try {
196
+ other = await manager.viewerRequests.createOrGetSession(
197
+ request({ consumerId: "small-window", transcodeVideo: true, targetWidth: 1280, targetHeight: 720 })
198
+ );
199
+ } catch {
200
+ other = null;
201
+ }
202
+ assert.notEqual(other, seeded, "a smaller picture is another output");
203
+ assert.ok(!manager.viewers.forOutput(seeded).has("small-window"));
204
+ });
@@ -7,7 +7,7 @@
7
7
 
8
8
  import test from "node:test";
9
9
  import assert from "node:assert/strict";
10
- import { DiskSpace } from "../services/disk/DiskSpace.js";
10
+ import { MachineBudget } from "../services/storage/MachineBudget.js";
11
11
 
12
12
  const MEGABYTE = 1024 * 1024;
13
13
 
@@ -31,9 +31,29 @@ function consumerOf(name, held, wanted) {
31
31
  };
32
32
  }
33
33
 
34
+ /**
35
+ * A budget over ONE resource, which is what this file is about: the division
36
+ * itself, with the several-resources question asked in `one-budget.test.js`.
37
+ *
38
+ * @param {{ readFree: () => Promise<number | null>, logger?: object }} params
39
+ */
40
+ function diskOf({ readFree, logger }) {
41
+ const budget = new MachineBudget({ logger });
42
+ budget.defineResource({ name: "disk", readFree });
43
+ return {
44
+ register: (consumer) => budget.register({ ...consumer, resource: "disk" }),
45
+ forget: (name) => budget.forget(name),
46
+ describe: () => budget.describe(),
47
+ revise: async () => {
48
+ const readings = await budget.revise();
49
+ return readings.get("disk") ?? { freeBytes: 0, allowanceBytes: 0, shares: [] };
50
+ }
51
+ };
52
+ }
53
+
34
54
  test("what everyone may hold together never exceeds what the disk has", async () => {
35
55
  const free = 100 * MEGABYTE;
36
- const space = new DiskSpace({ readFree: async () => free });
56
+ const space = diskOf({ readFree: async () => free });
37
57
  const segments = consumerOf("segments", 10 * MEGABYTE, 1000 * MEGABYTE);
38
58
  const pieces = consumerOf("pieces", 20 * MEGABYTE, 1000 * MEGABYTE);
39
59
  const diagnostics = consumerOf("diagnostics", 5 * MEGABYTE, 1000 * MEGABYTE);
@@ -55,7 +75,7 @@ test("what everyone may hold together never exceeds what the disk has", async ()
55
75
  });
56
76
 
57
77
  test("when everyone's ask fits, everyone gets it and the disk never binds", async () => {
58
- const space = new DiskSpace({ readFree: async () => 1000 * MEGABYTE });
78
+ const space = diskOf({ readFree: async () => 1000 * MEGABYTE });
59
79
  const small = consumerOf("diagnostics", 0, 5 * MEGABYTE);
60
80
  const large = consumerOf("segments", 0, 500 * MEGABYTE);
61
81
  space.register(small.consumer);
@@ -70,7 +90,7 @@ test("when everyone's ask fits, everyone gets it and the disk never binds", asyn
70
90
  test("when they do not fit, each is cut in proportion to what it asked", async () => {
71
91
  // The same rule memory divides by, and the same reason: a share taken from
72
92
  // whoever asked most would hand the disk to whoever grew fastest.
73
- const space = new DiskSpace({ readFree: async () => 200 * MEGABYTE });
93
+ const space = diskOf({ readFree: async () => 200 * MEGABYTE });
74
94
  const modest = consumerOf("diagnostics", 0, 100 * MEGABYTE);
75
95
  const greedy = consumerOf("segments", 0, 300 * MEGABYTE);
76
96
  space.register(modest.consumer);
@@ -85,7 +105,7 @@ test("when they do not fit, each is cut in proportion to what it asked", async (
85
105
 
86
106
  test("a disk that fills lowers every share, rather than keeping one taken when it was empty", async () => {
87
107
  let free = 1000 * MEGABYTE;
88
- const space = new DiskSpace({ readFree: async () => free });
108
+ const space = diskOf({ readFree: async () => free });
89
109
  const segments = consumerOf("segments", 0, 10_000 * MEGABYTE);
90
110
  const pieces = consumerOf("pieces", 0, 10_000 * MEGABYTE);
91
111
  space.register(segments.consumer);
@@ -106,7 +126,7 @@ test("what another process took is left for it, not spent", async () => {
106
126
  // That is a measurement of somebody else's demand, and it is what the next
107
127
  // allowance leaves alone.
108
128
  let free = 1000 * MEGABYTE;
109
- const space = new DiskSpace({ readFree: async () => free });
129
+ const space = diskOf({ readFree: async () => free });
110
130
  const only = consumerOf("segments", 0, 10_000 * MEGABYTE);
111
131
  space.register(only.consumer);
112
132
 
@@ -118,7 +138,7 @@ test("what another process took is left for it, not spent", async () => {
118
138
  });
119
139
 
120
140
  test("a disk that cannot be read allows nothing to grow", async () => {
121
- const space = new DiskSpace({ readFree: async () => null });
141
+ const space = diskOf({ readFree: async () => null });
122
142
  const segments = consumerOf("segments", 0, 500 * MEGABYTE);
123
143
  space.register(segments.consumer);
124
144
 
@@ -130,7 +150,7 @@ test("a disk that cannot be read allows nothing to grow", async () => {
130
150
  });
131
151
 
132
152
  test("it says what the disk has and what each claimant may hold", async () => {
133
- const space = new DiskSpace({ readFree: async () => 100 * MEGABYTE });
153
+ const space = diskOf({ readFree: async () => 100 * MEGABYTE });
134
154
  assert.match(space.describe(), /nothing has claimed/);
135
155
  space.register(consumerOf("segments", 4 * MEGABYTE, 8 * MEGABYTE).consumer);
136
156
  await space.revise();
@@ -139,7 +159,7 @@ test("it says what the disk has and what each claimant may hold", async () => {
139
159
 
140
160
  test("it says what it decided, every pass", async () => {
141
161
  const lines = [];
142
- const space = new DiskSpace({
162
+ const space = diskOf({
143
163
  readFree: async () => 100 * MEGABYTE,
144
164
  logger: { info: (line) => lines.push(line) }
145
165
  });
@@ -154,7 +174,7 @@ test("the free space is read from the nearest directory that exists", async () =
154
174
  // when the proxy stops, so at every start `statfs` on it fails. Field
155
175
  // 2026-09-10: the first reading said `disk: 0MB free` on a host with 103 GB,
156
176
  // and zero means "no room" to everything that reads it.
157
- const { freeBytesFor } = await import("../services/disk/free.js");
177
+ const { freeBytesFor } = await import("../services/storage/free.js");
158
178
  const os = await import("node:os");
159
179
  const path = await import("node:path");
160
180
  const missing = path.join(os.tmpdir(), "no-such-directory-here", "nor-here");