@torrent-tv/proxy 2.86.0 → 2.87.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/CHANGELOG.md +2142 -1915
  2. package/CLAUDE.md +322 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/browser-proxy-contract.md +94 -0
  7. package/docs/container-architecture.md +218 -213
  8. package/docs/download-architecture.md +7 -7
  9. package/docs/encode-architecture.md +287 -11
  10. package/docs/encode-run-state.md +2 -2
  11. package/docs/logs.md +97 -97
  12. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  13. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  14. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  15. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  18. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  19. package/openspec/changes/cold-start/design.md +1 -1
  20. package/openspec/changes/disk-cap/proposal.md +1 -1
  21. package/openspec/changes/proxy-observability/proposal.md +2 -2
  22. package/openspec/changes/track-selection/proposal.md +1 -1
  23. package/openspec/changes/transcode-quality/proposal.md +1 -1
  24. package/package.json +2 -2
  25. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  26. package/research/double-spawn-2026-09-10.md +171 -171
  27. package/routes/api/playback-plan/post.js +2 -2
  28. package/routes/api/sources/files/get.js +1 -1
  29. package/routes/api/sources/stats/get.js +1 -1
  30. package/routes/api/sources/warm/post.js +10 -3
  31. package/routes/api/subtitles/get.js +230 -230
  32. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  33. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  34. package/routes/api/transcode-sessions/post.js +160 -139
  35. package/routes/api/transcode-sessions/progress/get.js +3 -3
  36. package/routes/api/transcode-sessions/release/post.js +3 -3
  37. package/routes/api/transcode-sessions/seek/post.js +12 -3
  38. package/routes/stream/get.js +2 -2
  39. package/routes/transcode/audio-file/get.js +15 -6
  40. package/routes/transcode/audio-warm/get.js +9 -6
  41. package/routes/transcode/session-file/get.js +52 -268
  42. package/routes/transcode/variant-file/get.js +46 -7
  43. package/routes/transcode/variant-warm/get.js +19 -6
  44. package/scripts/render-run-graph.js +1 -1
  45. package/server.js +720 -465
  46. package/services/encode/CoverageMap.js +428 -428
  47. package/services/encode/CushionReport.js +345 -0
  48. package/services/encode/EncodeAdmission.js +332 -0
  49. package/services/{orchestrators → encode}/EncodeOrchestrator.js +307 -29
  50. package/services/encode/EncodeRun.js +16 -9
  51. package/services/encode/EncodeRuns.js +1229 -0
  52. package/services/encode/Encoder.js +18 -2
  53. package/services/encode/EncoderSelection.js +38 -0
  54. package/services/encode/LocalObservations.js +359 -0
  55. package/services/encode/NvencEncoder.js +5 -0
  56. package/services/encode/OutputOpening.js +755 -0
  57. package/services/encode/OutputTimes.js +579 -0
  58. package/services/encode/Renditions.js +2162 -0
  59. package/services/encode/RunProgress.js +84 -0
  60. package/services/encode/SoftwareEncoder.js +12 -7
  61. package/services/encode/args.js +402 -186
  62. package/services/encode/calibration.js +361 -0
  63. package/services/encode/contention.js +128 -128
  64. package/services/encode/encode-run-state.js +16 -20
  65. package/services/encode/fingerprint.js +322 -0
  66. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  67. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  68. package/services/encode/output/EncodedOutput.js +57 -0
  69. package/services/encode/output/Output.js +131 -0
  70. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  71. package/services/encode/output/OutputSpec.js +390 -0
  72. package/services/{output → encode/output}/Timeline.js +12 -37
  73. package/services/encode/output/index.js +20 -0
  74. package/services/encode/output/ladder.js +32 -0
  75. package/services/{output → encode/output}/playlists.js +3 -0
  76. package/services/encode/output-key-format.js +21 -0
  77. package/services/encode/quality/EncodeCost.js +1083 -0
  78. package/services/encode/quality/HostLoad.js +525 -0
  79. package/services/encode/quality/HostTimings.js +249 -0
  80. package/services/encode/quality/OutputQualityState.js +28 -0
  81. package/services/encode/quality/QualityController.js +935 -0
  82. package/services/encode/quality/QualityOffer.js +416 -0
  83. package/services/encode/quality/drain-threat.js +47 -0
  84. package/services/encode/quality/link-budget.js +254 -0
  85. package/services/encode/quality/output-format.js +266 -0
  86. package/services/encode/quality/serving-output.js +216 -0
  87. package/services/encode/quality/visible-rung.js +57 -0
  88. package/services/encode/run-command.js +23 -20
  89. package/services/encode/run-inputs.js +42 -0
  90. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  91. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  92. package/services/encode/segment-formats/init-compat.js +324 -0
  93. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  94. package/services/encode/throughput.js +97 -0
  95. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  96. package/services/media/KeyframeTables.js +316 -0
  97. package/services/{source → media}/SourceFile.js +7 -55
  98. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  99. package/services/media/SubtitleOrchestrator.js +167 -0
  100. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  101. package/services/{container → media/container}/AviContainer.js +266 -266
  102. package/services/{container → media/container}/Container.js +596 -601
  103. package/services/media/container/ContainerFactory.js +56 -0
  104. package/services/media/container/KeyframeTable.js +272 -0
  105. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  106. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  107. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  108. package/services/media/keyframe-probe.js +157 -0
  109. package/services/media/media-info-probe.js +93 -0
  110. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  111. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  112. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  113. package/services/server/OutputLifecycle.js +418 -0
  114. package/services/server/SegmentServing.js +1146 -0
  115. package/services/server/ViewerRequests.js +522 -0
  116. package/services/server/controllers/PlaybackController.js +36 -0
  117. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  118. package/services/server/http-responses.js +60 -0
  119. package/services/server/transcode-session-files.js +325 -0
  120. package/services/server/wire-outputs.js +983 -0
  121. package/services/storage/Diagnostics.js +202 -0
  122. package/services/storage/MachineBudget.js +235 -0
  123. package/services/{piece-store → storage}/allowance.js +107 -107
  124. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  125. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  126. package/services/{disk → storage}/free.js +33 -0
  127. package/services/storage/machine-memory.js +56 -0
  128. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  129. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  130. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  131. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  132. package/services/storage/wire.js +175 -0
  133. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  134. package/services/{download → torrent/download}/registry.js +2 -2
  135. package/services/torrent/files.js +1 -1
  136. package/services/torrent/piece-store-of.js +29 -0
  137. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  138. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  139. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  140. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  141. package/services/torrent/worker/held-bytes.js +155 -0
  142. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  143. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  144. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  145. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  146. package/services/torrent/worker/resume-warm.js +107 -0
  147. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  148. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  149. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  150. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  151. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  152. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  153. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  154. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  155. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  156. package/services/viewer/Assignments.js +340 -0
  157. package/services/{priority → viewer}/PriorityMap.js +390 -390
  158. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  159. package/services/viewer/Viewer.js +657 -483
  160. package/services/viewer/Viewers.js +368 -257
  161. package/services/viewer/active-output.js +37 -54
  162. package/services/viewer/audio-start.js +4 -3
  163. package/services/viewer/buffer-trend.js +81 -0
  164. package/services/viewer/choices.js +647 -0
  165. package/services/viewer/positions.js +126 -120
  166. package/services/viewer/report-intake.js +46 -45
  167. package/test/asset-paths.test.js +28 -0
  168. package/test/audio-inventory.test.js +3 -3
  169. package/test/audio-track-bitrate.test.js +40 -0
  170. package/test/auto-quality-step.test.js +707 -515
  171. package/test/available-share.test.js +1 -1
  172. package/test/background-fill.test.js +1 -1
  173. package/test/client-log-file.test.js +110 -110
  174. package/test/completed-files.test.js +1 -1
  175. package/test/concurrent-cost.test.js +13 -3
  176. package/test/container-media-info.test.js +3 -3
  177. package/test/core-dumps.test.js +1 -1
  178. package/test/coverage-follows-the-disk.test.js +46 -9
  179. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  180. package/test/cut-times-timeline.test.js +2 -2
  181. package/test/cuts-follow-published-grid.test.js +9 -8
  182. package/test/data-channel-frame.test.js +1 -1
  183. package/test/dead-torrent-handle.test.js +1 -1
  184. package/test/declared-rates.test.js +2 -2
  185. package/test/declared-tracks.test.js +2 -2
  186. package/test/decode-cost-fit.test.js +1 -1
  187. package/test/decode-cost.test.js +34 -25
  188. package/test/delivery-probe.test.js +1 -1
  189. package/test/demand-register.test.js +4 -4
  190. package/test/dht-bootstrap.test.js +1 -1
  191. package/test/drain-threat.test.js +65 -0
  192. package/test/duplicate-add.test.js +1 -1
  193. package/test/encode-admission.test.js +146 -0
  194. package/test/encode-cost.test.js +199 -10
  195. package/test/encode-orchestrator.test.js +39 -1
  196. package/test/encode-plan-viewers.test.js +2 -2
  197. package/test/encode-run.test.js +2 -2
  198. package/test/encode-slope.test.js +1 -1
  199. package/test/encoded-output.test.js +39 -0
  200. package/test/encoder-readings.test.js +1 -1
  201. package/test/every-claimant-is-told-its-share.test.js +152 -0
  202. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  203. package/test/failed-start-breaker.test.js +81 -30
  204. package/test/fastest-wires.test.js +1 -1
  205. package/test/ffmpeg-stream-counts.test.js +1 -1
  206. package/test/file-edges.test.js +3 -3
  207. package/test/fmp4-self-contained.test.js +1 -1
  208. package/test/fmp4-timestamps.test.js +1 -1
  209. package/test/fmp4-track-completeness.test.js +1 -1
  210. package/test/format-keyed-outputs.test.js +78 -0
  211. package/test/health-metrics.test.js +3 -2
  212. package/test/held-request-width.test.js +22 -23
  213. package/test/helpers/encode-run.js +136 -136
  214. package/test/helpers/held-file.js +29 -0
  215. package/test/helpers/manager.js +57 -0
  216. package/test/helpers/output-spec.js +63 -0
  217. package/test/host-load.test.js +1 -1
  218. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  219. package/test/init-compat.test.js +219 -0
  220. package/test/input-lost-quiets-the-plan.test.js +1 -1
  221. package/test/input-unavailable.test.js +1 -1
  222. package/test/keeping-period.test.js +4 -4
  223. package/test/keyframe-index-accuracy.test.js +97 -45
  224. package/test/keyframe-table-budget.test.js +28 -33
  225. package/test/keyframes-belong-to-the-file.test.js +174 -57
  226. package/test/learned-median.test.js +1 -1
  227. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  228. package/test/limit-row-by-area.test.js +60 -0
  229. package/test/link-budget.test.js +111 -0
  230. package/test/link-probe.test.js +56 -56
  231. package/test/local-encoding-data.test.js +273 -0
  232. package/test/look-ahead-coverage.test.js +1 -1
  233. package/test/manager-surface.test.js +294 -0
  234. package/test/matroska-blocks.test.js +0 -0
  235. package/test/matroska-cues-track.test.js +1 -1
  236. package/test/memory-budget.test.js +2 -2
  237. package/test/mp4-composition-times.test.js +0 -0
  238. package/test/mp4-subtitles.test.js +1 -1
  239. package/test/one-authority.test.js +45 -41
  240. package/test/one-budget.test.js +149 -0
  241. package/test/one-output-one-session.test.js +204 -0
  242. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  243. package/test/one-serving-output-per-height.test.js +458 -0
  244. package/test/orchestrator-wired.test.js +46 -45
  245. package/test/output-catalog.test.js +39 -0
  246. package/test/output-shape.test.js +4 -51
  247. package/test/output-spec.test.js +251 -157
  248. package/test/output-speed.test.js +17 -15
  249. package/test/outputs-being-prepared.test.js +40 -0
  250. package/test/packet-witness-ring.test.js +2 -2
  251. package/test/packet-witness.test.js +1 -1
  252. package/test/piece-disk-store.test.js +2 -2
  253. package/test/piece-from-whole-file.test.js +1 -1
  254. package/test/piece-lru.test.js +1 -1
  255. package/test/piece-reader.test.js +2 -2
  256. package/test/piece-store-eviction.test.js +1 -1
  257. package/test/piece-store-never-refuses.test.js +1 -1
  258. package/test/piece-store-reservations.test.js +1 -1
  259. package/test/piece-store-slow-disk.test.js +1 -1
  260. package/test/piece-tail.test.js +1 -1
  261. package/test/plan-host-timings.test.js +1 -1
  262. package/test/plan-is-steady.test.js +225 -225
  263. package/test/priority-map-download.test.js +4 -4
  264. package/test/priority-map-emptied.test.js +204 -205
  265. package/test/priority-map-per-output.test.js +215 -209
  266. package/test/priority-map.test.js +1 -1
  267. package/test/probe-wedge-certainty.test.js +1 -1
  268. package/test/produced-copy-choice.test.js +15 -60
  269. package/test/quality-offer.test.js +162 -0
  270. package/test/quality-variants.test.js +1229 -1167
  271. package/test/rate-control-in-the-output.test.js +180 -0
  272. package/test/read-survives-withdrawal.test.js +2 -2
  273. package/test/read-window.test.js +10 -3
  274. package/test/resume-warm.test.js +1 -1
  275. package/test/run-inputs.test.js +80 -0
  276. package/test/run-position-follows-published-grid.test.js +13 -11
  277. package/test/run-progress.test.js +65 -0
  278. package/test/same-height-switch.test.js +664 -0
  279. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  280. package/test/seek-landing.test.js +13 -13
  281. package/test/seek-target-not-superseded.test.js +28 -29
  282. package/test/segment-closed-on-both-branches.test.js +96 -0
  283. package/test/segment-cut-times.test.js +1 -1
  284. package/test/segment-hold-supersede.test.js +28 -35
  285. package/test/segment-judged-by-its-own-header.test.js +221 -0
  286. package/test/segment-serve-wiring.test.js +130 -39
  287. package/test/segment-store-eviction.test.js +22 -7
  288. package/test/segment-store.test.js +98 -14
  289. package/test/segment-true-start.test.js +1 -1
  290. package/test/segments-are-shared.test.js +17 -16
  291. package/test/send-chunk-bytes.test.js +1 -1
  292. package/test/serving-existing-output.test.js +102 -0
  293. package/test/serving-output.test.js +102 -0
  294. package/test/shared-piece-store.test.js +1 -1
  295. package/test/source-file.test.js +1 -1
  296. package/test/stale-request-after-seek.test.js +17 -21
  297. package/test/startup-readings.test.js +10 -5
  298. package/test/subtitle-cue-framing.test.js +5 -5
  299. package/test/subtitle-cue-source.test.js +36 -38
  300. package/test/subtitle-cue-walk.test.js +11 -10
  301. package/test/subtitle-defaults.test.js +1 -1
  302. package/test/subtitle-language.test.js +4 -4
  303. package/test/subtitle-track-numbering.test.js +5 -4
  304. package/test/supply-margin.test.js +1 -1
  305. package/test/swarm-follows-readers.test.js +3 -3
  306. package/test/swarm-reach.test.js +1 -1
  307. package/test/swarm-selection.test.js +3 -3
  308. package/test/tail-duplication.test.js +1 -1
  309. package/test/the-channel-has-both-ends.test.js +91 -0
  310. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  311. package/test/thread-pool.test.js +3 -3
  312. package/test/timeline.test.js +1 -12
  313. package/test/torrent-cost.test.js +1 -1
  314. package/test/tracks-begin-together.test.js +38 -24
  315. package/test/tunnel-renewal.test.js +1 -1
  316. package/test/two-viewers-one-picture.test.js +120 -87
  317. package/test/upload-hurry.test.js +3 -3
  318. package/test/usrsctp-state.test.js +1 -1
  319. package/test/video-facts.test.js +1 -1
  320. package/test/viewer-assignments.test.js +143 -0
  321. package/test/viewer-outputs.test.js +60 -52
  322. package/test/viewer-presence.test.js +22 -25
  323. package/test/viewer-requests-admission.test.js +89 -0
  324. package/test/viewer-states.test.js +189 -202
  325. package/test/viewer-subtitle-subscription.test.js +1 -1
  326. package/test/viewer.test.js +139 -136
  327. package/test/viewing-generations.test.js +390 -0
  328. package/test/visible-rung.test.js +37 -0
  329. package/test/wait-ledger.test.js +1 -1
  330. package/test/webrtc-shim.test.js +3 -3
  331. package/test/wedge-certainty.test.js +2 -2
  332. package/test/withdraw-piece-claim.test.js +2 -2
  333. package/test/worker-channel.test.js +1 -31
  334. package/utils/client-log-file.js +151 -151
  335. package/scripts/read-container-index.mjs +0 -108
  336. package/services/container/ContainerFactory.js +0 -122
  337. package/services/controllers/PlaybackController.js +0 -33
  338. package/services/disk/DiskSpace.js +0 -150
  339. package/services/disk/wire.js +0 -61
  340. package/services/hls-session-manager.js +0 -9608
  341. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  342. package/services/output/Output.js +0 -94
  343. package/services/output/OutputSpec.js +0 -202
  344. package/services/output/index.js +0 -1
  345. package/services/output/ladder.js +0 -26
  346. package/services/produced-index.js +0 -222
  347. package/services/quality/EncodeCost.js +0 -555
  348. package/services/torrent-worker/container-tracks.js +0 -414
  349. package/services/viewer/link-readings.js +0 -56
  350. package/test/produced-index.test.js +0 -142
  351. /package/{services → bin}/thread-pool.js +0 -0
  352. /package/services/{available-share.js → encode/available-share.js} +0 -0
  353. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  354. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  355. /package/services/{host-load.js → encode/host-load.js} +0 -0
  356. /package/services/{output → encode/output}/cut-grid.js +0 -0
  357. /package/services/{output → encode/output}/rates.js +0 -0
  358. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  359. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  360. /package/services/{container → media/container}/ebml-reader.js +0 -0
  361. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  362. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  365. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  366. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  367. /package/services/{tracks → media/tracks}/index.js +0 -0
  368. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  369. /package/services/{disk → storage}/keep.js +0 -0
  370. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  371. /package/services/{disk → storage}/returns.js +0 -0
  372. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  373. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  374. /package/services/{demand → torrent/demand}/Window.js +0 -0
  375. /package/services/{demand → torrent/demand}/index.js +0 -0
  376. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  377. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  378. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  379. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  380. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  381. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  382. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  383. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  384. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  385. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -8,8 +8,8 @@ import assert from "node:assert/strict";
8
8
  import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
- import { directoryNameFor, SegmentStore } from "../services/encode/SegmentStore.js";
12
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
11
+ import { directoryNameFor, SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
12
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
13
13
 
14
14
  /**
15
15
  * @returns {{ store: SegmentStore, root: string, lines: string[] }}
@@ -37,7 +37,14 @@ const KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
37
37
 
38
38
  test("two viewers of one output are given the same directory", (t) => {
39
39
  const { store, root } = storeInATempRoot();
40
- t.after(() => rmSync(root, { recursive: true, force: true }));
40
+ t.after(() => {
41
+ // The store lets its directories go, and only then is the root removed: it
42
+ // holds a watch on each of them — that is how a piece closed by a muxer
43
+ // with no channel of its own becomes an event — and a directory with an
44
+ // open watch cannot be removed on Windows.
45
+ store.dropAll("the check is over");
46
+ rmSync(root, { recursive: true, force: true });
47
+ });
41
48
 
42
49
  // Which viewer asked never enters it — the address is what the segments are.
43
50
  assert.equal(store.directoryFor(KEY), store.directoryFor(KEY));
@@ -46,7 +53,14 @@ test("two viewers of one output are given the same directory", (t) => {
46
53
 
47
54
  test("the directory says what it holds, so a later process can tell", (t) => {
48
55
  const { store, root } = storeInATempRoot();
49
- t.after(() => rmSync(root, { recursive: true, force: true }));
56
+ t.after(() => {
57
+ // The store lets its directories go, and only then is the root removed: it
58
+ // holds a watch on each of them — that is how a piece closed by a muxer
59
+ // with no channel of its own becomes an event — and a directory with an
60
+ // open watch cannot be removed on Windows.
61
+ store.dropAll("the check is over");
62
+ rmSync(root, { recursive: true, force: true });
63
+ });
50
64
 
51
65
  const dir = store.directoryFor(KEY);
52
66
  const names = readdirSync(dir);
@@ -55,7 +69,14 @@ test("the directory says what it holds, so a later process can tell", (t) => {
55
69
 
56
70
  test("a piece under its served name is finished, and the last one too", (t) => {
57
71
  const { store, root } = storeInATempRoot();
58
- t.after(() => rmSync(root, { recursive: true, force: true }));
72
+ t.after(() => {
73
+ // The store lets its directories go, and only then is the root removed: it
74
+ // holds a watch on each of them — that is how a piece closed by a muxer
75
+ // with no channel of its own becomes an event — and a directory with an
76
+ // open watch cannot be removed on Windows.
77
+ store.dropAll("the check is over");
78
+ rmSync(root, { recursive: true, force: true });
79
+ });
59
80
 
60
81
  const dir = store.directoryFor(KEY);
61
82
  store.useFormat(KEY, fmp4Format);
@@ -72,7 +93,14 @@ test("a piece under its served name is finished, and the last one too", (t) => {
72
93
 
73
94
  test("a piece still being written is not a piece, and its name says so", (t) => {
74
95
  const { store, root } = storeInATempRoot();
75
- t.after(() => rmSync(root, { recursive: true, force: true }));
96
+ t.after(() => {
97
+ // The store lets its directories go, and only then is the root removed: it
98
+ // holds a watch on each of them — that is how a piece closed by a muxer
99
+ // with no channel of its own becomes an event — and a directory with an
100
+ // open watch cannot be removed on Windows.
101
+ store.dropAll("the check is over");
102
+ rmSync(root, { recursive: true, force: true });
103
+ });
76
104
 
77
105
  const dir = store.directoryFor(KEY);
78
106
  store.useFormat(KEY, fmp4Format);
@@ -91,7 +119,14 @@ test("a piece still being written is not a piece, and its name says so", (t) =>
91
119
 
92
120
  test("a file of no bytes is not a segment, whatever it is called", (t) => {
93
121
  const { store, root } = storeInATempRoot();
94
- t.after(() => rmSync(root, { recursive: true, force: true }));
122
+ t.after(() => {
123
+ // The store lets its directories go, and only then is the root removed: it
124
+ // holds a watch on each of them — that is how a piece closed by a muxer
125
+ // with no channel of its own becomes an event — and a directory with an
126
+ // open watch cannot be removed on Windows.
127
+ store.dropAll("the check is over");
128
+ rmSync(root, { recursive: true, force: true });
129
+ });
95
130
 
96
131
  const dir = store.directoryFor(KEY);
97
132
  store.useFormat(KEY, fmp4Format);
@@ -109,7 +144,14 @@ test("a file of no bytes is not a segment, whatever it is called", (t) => {
109
144
 
110
145
  test("clearing up after one run leaves every other run's work alone", (t) => {
111
146
  const { store, root } = storeInATempRoot();
112
- t.after(() => rmSync(root, { recursive: true, force: true }));
147
+ t.after(() => {
148
+ // The store lets its directories go, and only then is the root removed: it
149
+ // holds a watch on each of them — that is how a piece closed by a muxer
150
+ // with no channel of its own becomes an event — and a directory with an
151
+ // open watch cannot be removed on Windows.
152
+ store.dropAll("the check is over");
153
+ rmSync(root, { recursive: true, force: true });
154
+ });
113
155
 
114
156
  const dir = store.directoryFor(KEY);
115
157
  store.useFormat(KEY, fmp4Format);
@@ -134,7 +176,14 @@ test("clearing up after one run leaves every other run's work alone", (t) => {
134
176
 
135
177
  test("a directory that has not moved is not read again", (t) => {
136
178
  const { store, root } = storeInATempRoot();
137
- t.after(() => rmSync(root, { recursive: true, force: true }));
179
+ t.after(() => {
180
+ // The store lets its directories go, and only then is the root removed: it
181
+ // holds a watch on each of them — that is how a piece closed by a muxer
182
+ // with no channel of its own becomes an event — and a directory with an
183
+ // open watch cannot be removed on Windows.
184
+ store.dropAll("the check is over");
185
+ rmSync(root, { recursive: true, force: true });
186
+ });
138
187
 
139
188
  const dir = store.directoryFor(KEY);
140
189
  store.useFormat(KEY, fmp4Format);
@@ -148,7 +197,14 @@ test("a directory that has not moved is not read again", (t) => {
148
197
 
149
198
  test("what a killed process left is found, named and counted", (t) => {
150
199
  const { store, root, lines } = storeInATempRoot();
151
- t.after(() => rmSync(root, { recursive: true, force: true }));
200
+ t.after(() => {
201
+ // The store lets its directories go, and only then is the root removed: it
202
+ // holds a watch on each of them — that is how a piece closed by a muxer
203
+ // with no channel of its own becomes an event — and a directory with an
204
+ // open watch cannot be removed on Windows.
205
+ store.dropAll("the check is over");
206
+ rmSync(root, { recursive: true, force: true });
207
+ });
152
208
 
153
209
  const dir = path.join(root, directoryNameFor(KEY));
154
210
  mkdirSync(dir, { recursive: true });
@@ -169,7 +225,14 @@ test("what a killed process left is found, named and counted", (t) => {
169
225
 
170
226
  test("what survived a kill is taken back whole, minus what was still being written", (t) => {
171
227
  const { store, root } = storeInATempRoot();
172
- t.after(() => rmSync(root, { recursive: true, force: true }));
228
+ t.after(() => {
229
+ // The store lets its directories go, and only then is the root removed: it
230
+ // holds a watch on each of them — that is how a piece closed by a muxer
231
+ // with no channel of its own becomes an event — and a directory with an
232
+ // open watch cannot be removed on Windows.
233
+ store.dropAll("the check is over");
234
+ rmSync(root, { recursive: true, force: true });
235
+ });
173
236
 
174
237
  const dir = path.join(root, directoryNameFor(KEY));
175
238
  mkdirSync(dir, { recursive: true });
@@ -195,7 +258,14 @@ test("what survived a kill is taken back whole, minus what was still being writt
195
258
 
196
259
  test("a directory that cannot name itself is thrown away rather than served", (t) => {
197
260
  const { store, root } = storeInATempRoot();
198
- t.after(() => rmSync(root, { recursive: true, force: true }));
261
+ t.after(() => {
262
+ // The store lets its directories go, and only then is the root removed: it
263
+ // holds a watch on each of them — that is how a piece closed by a muxer
264
+ // with no channel of its own becomes an event — and a directory with an
265
+ // open watch cannot be removed on Windows.
266
+ store.dropAll("the check is over");
267
+ rmSync(root, { recursive: true, force: true });
268
+ });
199
269
 
200
270
  const orphan = path.join(root, "0123456789abcdef");
201
271
  mkdirSync(orphan, { recursive: true });
@@ -210,7 +280,14 @@ test("a directory that cannot name itself is thrown away rather than served", (t
210
280
 
211
281
  test("an output this proxy can no longer serve is thrown away too", (t) => {
212
282
  const { store, root } = storeInATempRoot();
213
- t.after(() => rmSync(root, { recursive: true, force: true }));
283
+ t.after(() => {
284
+ // The store lets its directories go, and only then is the root removed: it
285
+ // holds a watch on each of them — that is how a piece closed by a muxer
286
+ // with no channel of its own becomes an event — and a directory with an
287
+ // open watch cannot be removed on Windows.
288
+ store.dropAll("the check is over");
289
+ rmSync(root, { recursive: true, force: true });
290
+ });
214
291
 
215
292
  const dir = path.join(root, directoryNameFor(KEY));
216
293
  mkdirSync(dir, { recursive: true });
@@ -225,7 +302,14 @@ test("an output this proxy can no longer serve is thrown away too", (t) => {
225
302
 
226
303
  test("what the store weighs is reported, and dropping one output frees it", (t) => {
227
304
  const { store, root } = storeInATempRoot();
228
- t.after(() => rmSync(root, { recursive: true, force: true }));
305
+ t.after(() => {
306
+ // The store lets its directories go, and only then is the root removed: it
307
+ // holds a watch on each of them — that is how a piece closed by a muxer
308
+ // with no channel of its own becomes an event — and a directory with an
309
+ // open watch cannot be removed on Windows.
310
+ store.dropAll("the check is over");
311
+ rmSync(root, { recursive: true, force: true });
312
+ });
229
313
 
230
314
  const dir = store.directoryFor(KEY);
231
315
  store.useFormat(KEY, fmp4Format);
@@ -15,7 +15,7 @@
15
15
 
16
16
  import test from "node:test";
17
17
  import assert from "node:assert/strict";
18
- import { readSelfContainedStartSeconds } from "../services/segment-formats/mp4-boxes.js";
18
+ import { readSelfContainedStartSeconds } from "../services/encode/segment-formats/mp4-boxes.js";
19
19
 
20
20
  /**
21
21
  * A minimal MP4 carrying `moov > mvhd` and `moov > trak > edts > elst`, with a
@@ -15,24 +15,25 @@
15
15
 
16
16
  import test from "node:test";
17
17
  import assert from "node:assert/strict";
18
- import { SourceFile } from "../services/source/SourceFile.js";
18
+ import { SourceFile } from "../services/media/SourceFile.js";
19
19
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
20
  import os from "node:os";
21
21
  import path from "node:path";
22
- import { HlsSessionManager } from "../services/hls-session-manager.js";
23
- import { SegmentStore } from "../services/encode/SegmentStore.js";
24
- import { Timeline } from "../services/output/Timeline.js";
25
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
22
+ import { wireOutputs } from "../services/server/wire-outputs.js";
23
+ import { SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
24
+ import { Timeline } from "../services/encode/output/Timeline.js";
25
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
26
+ import { outputSpec } from "./helpers/output-spec.js";
26
27
 
27
28
  const OUTPUT_KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
28
29
 
29
30
  /**
30
- * @returns {{ manager: HlsSessionManager, store: SegmentStore, root: string }}
31
+ * @returns {{ manager: object, store: SegmentStore, root: string }}
31
32
  */
32
33
  function managerOverATempStore() {
33
34
  const root = mkdtempSync(path.join(os.tmpdir(), "shared-segments-"));
34
35
  const store = new SegmentStore({ root });
35
- const manager = new HlsSessionManager({
36
+ const manager = wireOutputs({
36
37
  enabled: true,
37
38
  ffmpegBin: "ffmpeg",
38
39
  localBindHost: "127.0.0.1",
@@ -52,6 +53,7 @@ function managerOverATempStore() {
52
53
  function sessionOn({ id, dirPath, outputKey }) {
53
54
  return {
54
55
  id,
56
+ spec: outputSpec({ transcodeVideo: false }),
55
57
  outputKey,
56
58
  dirPath,
57
59
  state: "ready",
@@ -63,7 +65,6 @@ function sessionOn({ id, dirPath, outputKey }) {
63
65
  segmentFormat: fmp4Format,
64
66
  // Where the file is cut, which every live session holds.
65
67
  timeline: new Timeline({ boundaries: [0, 4, 8], cutGrid: "keyframe" }),
66
- consumers: new Set(),
67
68
  lastAccessedAt: Date.now()
68
69
  };
69
70
  }
@@ -93,12 +94,12 @@ test("each session serves the segments the other one's encoder made", (t) => {
93
94
 
94
95
  const viewerOne = sessionOn({ id: "s-one", dirPath, outputKey: OUTPUT_KEY });
95
96
  const viewerTwo = sessionOn({ id: "s-two", dirPath, outputKey: OUTPUT_KEY });
96
- manager.sessionsById.set(viewerOne.id, viewerOne);
97
- manager.sessionsById.set(viewerTwo.id, viewerTwo);
97
+ manager.outputs.set(viewerOne.id, viewerOne);
98
+ manager.outputs.set(viewerTwo.id, viewerTwo);
98
99
 
99
100
  const sorted = (numbers) => [...numbers].sort((left, right) => left - right);
100
- const heldByOne = sorted(manager.producedSegmentNumbers(viewerOne));
101
- const heldByTwo = sorted(manager.producedSegmentNumbers(viewerTwo));
101
+ const heldByOne = sorted(manager.serving.producedSegmentNumbers(viewerOne));
102
+ const heldByTwo = sorted(manager.serving.producedSegmentNumbers(viewerTwo));
102
103
 
103
104
  assert.deepEqual(heldByOne, [0, 100]);
104
105
  assert.deepEqual(heldByTwo, [0, 100], "neither viewer's session is the owner of either run");
@@ -114,11 +115,11 @@ test("a session leaving does not take the segments with it", async (t) => {
114
115
 
115
116
  const viewerOne = sessionOn({ id: "s-one", dirPath, outputKey: OUTPUT_KEY });
116
117
  const viewerTwo = sessionOn({ id: "s-two", dirPath, outputKey: OUTPUT_KEY });
117
- manager.sessionsById.set(viewerOne.id, viewerOne);
118
- manager.sessionsById.set(viewerTwo.id, viewerTwo);
118
+ manager.outputs.set(viewerOne.id, viewerOne);
119
+ manager.outputs.set(viewerTwo.id, viewerTwo);
119
120
 
120
- await manager.disposeSession(viewerOne.id);
121
- await manager.disposeSession(viewerTwo.id);
121
+ await manager.lifecycle.disposeSession(viewerOne.id);
122
+ await manager.lifecycle.disposeSession(viewerTwo.id);
122
123
 
123
124
  // Both sessions are gone and the material is still here. That is the whole
124
125
  // decoupling: a session ending says nothing about whether anybody will ask
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { bodySender } from "../services/data-channel-handler.js";
3
+ import { bodySender } from "../services/transport/data-channel-handler.js";
4
4
 
5
5
  /**
6
6
  * Drives the sender over a body arriving in the given read sizes, and answers
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @file An output already made serves a viewer when the rules decided with the
3
+ * user 2026-09-16 allow it, through the paths a real request takes.
4
+ */
5
+
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { managerWithOwnStore } from "./helpers/manager.js";
11
+ import { outputSpec } from "./helpers/output-spec.js";
12
+ import { SourceFile } from "../services/media/SourceFile.js";
13
+ import { Timeline } from "../services/encode/output/Timeline.js";
14
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
15
+ import { Output } from "../services/encode/output/Output.js";
16
+
17
+ const TORRENT = "torrent:11f0929918e2b5aa2e5b71ecdbe5c0f1a4bbf7d1";
18
+
19
+ function hostWithA1080pOutput(t) {
20
+ const { manager, store, cleanup } = managerWithOwnStore({
21
+ startupWaitMs: 0,
22
+ videoEncoder: { kind: "vaapi", name: "h264_vaapi", inputArgs: [] }
23
+ });
24
+ t.after(async () => {
25
+ await manager.lifecycle.disposeAll();
26
+ cleanup();
27
+ });
28
+ manager.encodeRuns.planEncodersNow = () => {};
29
+ manager.encodeRuns.planEncodersSoon = () => {};
30
+ manager.getCachedMediaInfo = () => ({ durationSeconds: 400, width: 1920, height: 1080, fps: 24 });
31
+ const spec = outputSpec({
32
+ sourceKey: TORRENT,
33
+ transcodeVideo: true,
34
+ transcodeAudio: false,
35
+ encoder: "h264_vaapi",
36
+ width: 1920,
37
+ height: 1080,
38
+ fps: 24,
39
+ cutGrid: "uniform"
40
+ });
41
+ const key = spec.toKey();
42
+ store.useFormat(key, fmp4Format);
43
+ // The piece at the start of the film is made.
44
+ writeFileSync(path.join(store.directoryFor(key), fmp4Format.segmentFileName(0)), Buffer.alloc(100));
45
+ const file = new SourceFile({ sourceKey: TORRENT, fileIndex: 0, name: "film.mkv" }).learn({ width: 1920, height: 1080, durationSeconds: 400 });
46
+ const made = {
47
+ id: spec.toName(),
48
+ spec,
49
+ get outputKey() { return this.spec.toKey(); },
50
+ file,
51
+ timeline: new Timeline({ boundaries: Array.from({ length: 101 }, (_, index) => index * 4), cutGrid: "uniform" }),
52
+ output: new Output({ encodeWidth: 1920, encodeHeight: 1080, outputFps: 24 }),
53
+ segmentFormat: fmp4Format,
54
+ useSyntheticPlaylist: true
55
+ };
56
+ manager.outputs.set(made.id, made);
57
+ return { manager, made };
58
+ }
59
+
60
+ function request(over) {
61
+ return {
62
+ sourceKey: TORRENT,
63
+ fileIndex: 0,
64
+ fileName: "film.mkv",
65
+ transcodeVideo: true,
66
+ transcodeAudio: false,
67
+ segmentFormatId: "fmp4",
68
+ ...over
69
+ };
70
+ }
71
+
72
+ test("the automatic choice is served by a better output whose piece at the viewer's position is made", async (t) => {
73
+ const { manager, made } = hostWithA1080pOutput(t);
74
+ const answered = await manager.viewerRequests.createOrGetSession(
75
+ request({ consumerId: "auto-viewer", targetWidth: 1280, targetHeight: 720 })
76
+ );
77
+ assert.equal(answered, made);
78
+ });
79
+
80
+ test("a size picked by hand is not served by another size", async (t) => {
81
+ const { manager, made } = hostWithA1080pOutput(t);
82
+ let answered = null;
83
+ try {
84
+ answered = await manager.viewerRequests.createOrGetSession(
85
+ request({ consumerId: "picker", targetWidth: 1280, targetHeight: 720, exactSize: true })
86
+ );
87
+ } catch {
88
+ answered = null;
89
+ }
90
+ assert.notEqual(answered, made);
91
+ });
92
+
93
+ test("a step asked for by an automatic viewer and served by the picture leaves the picture a picture", async (t) => {
94
+ const { manager, made } = hostWithA1080pOutput(t);
95
+ manager.viewers.of(made, "auto-viewer").qualityMode = "auto";
96
+
97
+ const variant = await manager.renditions.resolveVariantSession(made.id, 720, 0, "auto-viewer");
98
+
99
+ assert.equal(variant, made, "the picture already made at 1080p serves the 720p request");
100
+ assert.notEqual(made.isStep, true, "and is not turned into a step of itself");
101
+ assert.ok(manager.viewers.forOutput(made).has("auto-viewer"));
102
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @file Which existing output serves a viewer: the rules decided with the user
3
+ * 2026-09-16, with the viewer's own link asked in every mode and on every
4
+ * branch (roadmap item 97, step 11).
5
+ */
6
+
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { chooseServingOutput } from "../services/encode/quality/serving-output.js";
10
+ import { linkCouldCarry, loadOf, PEAK_CLASS } from "../services/encode/quality/link-budget.js";
11
+
12
+ const WANTED = { width: 1824, height: 1026 };
13
+ const NEXT_LOWER = 1280 * 720;
14
+
15
+ /**
16
+ * @param {string} key
17
+ * @param {number} width
18
+ * @param {number} height
19
+ * @param {{ mbps?: number | null, peakClass?: string, readyHere?: boolean }} [over]
20
+ */
21
+ function candidate(key, width, height, { mbps = 5, peakClass = PEAK_CLASS.KNOWN, readyHere = true } = {}) {
22
+ return { key, width, height, readyHere, load: loadOf({ mbps, peakClass: mbps === null ? PEAK_CLASS.UNKNOWN : peakClass }, null) };
23
+ }
24
+
25
+ /**
26
+ * @param {object} over
27
+ * @param {number | null} [linkMbps] - What this viewer's link measured.
28
+ */
29
+ function choose(over, linkMbps = null) {
30
+ return chooseServingOutput({
31
+ mode: "auto",
32
+ wanted: WANTED,
33
+ nextLowerArea: NEXT_LOWER,
34
+ atRisk: false,
35
+ judge: (one) => linkCouldCarry(linkMbps, one.load),
36
+ candidates: [],
37
+ ...over
38
+ })?.key ?? null;
39
+ }
40
+
41
+ test("the same quality or higher is served, the nearest of them", () => {
42
+ assert.equal(choose({ candidates: [candidate("2160", 3840, 2160), candidate("1080", 1920, 1080)] }), "1080");
43
+ });
44
+
45
+ test("higher quality is not served over a link it does not fit", () => {
46
+ assert.equal(choose({ candidates: [candidate("1080", 1920, 1080, { mbps: 12 })] }, 10), null);
47
+ });
48
+
49
+ test("a little lower — above the next rung down — is served, and only if the link admits it", () => {
50
+ assert.equal(choose({ candidates: [candidate("1000", 1776, 1000)] }), "1000");
51
+ assert.equal(
52
+ choose({ candidates: [candidate("1000", 1776, 1000, { mbps: 12 })] }, 10),
53
+ null,
54
+ "a smaller picture proves nothing by itself: its own load is asked"
55
+ );
56
+ });
57
+
58
+ test("the next rung down or lower is served only when making the wanted format risks a wait, and only if it fits", () => {
59
+ const lower = [candidate("720", 1280, 720), candidate("480", 854, 480)];
60
+ assert.equal(choose({ candidates: lower }), null);
61
+ assert.equal(choose({ candidates: lower, atRisk: true }), "720");
62
+ assert.equal(
63
+ choose({ candidates: [candidate("720", 1280, 720, { mbps: 12 }), candidate("480", 854, 480, { mbps: 3 })], atRisk: true }, 10),
64
+ "480",
65
+ "the larger one does not fit this link, so it is not taken"
66
+ );
67
+ });
68
+
69
+ test("an output without the piece at the viewer's position spares them nothing", () => {
70
+ assert.equal(choose({ candidates: [candidate("1080", 1920, 1080, { readyHere: false })] }), null);
71
+ });
72
+
73
+ test("a size chosen by hand is served only exactly, and only if the link admits it", () => {
74
+ const candidates = [candidate("1080", 1920, 1080), candidate("exact", 1824, 1026)];
75
+ assert.equal(choose({ mode: "manual", candidates }), "exact");
76
+ assert.equal(choose({ mode: "manual", candidates: [candidate("1080", 1920, 1080)], atRisk: true }), null);
77
+ assert.equal(
78
+ choose({ mode: "manual", candidates: [candidate("exact", 1824, 1026, { mbps: 12 })] }, 10),
79
+ null,
80
+ "the size they picked, but more than their link carries: not handed over"
81
+ );
82
+ });
83
+
84
+ test("of two outputs of the size picked by hand, the higher limit the link admits is served", () => {
85
+ const candidates = [candidate("2800", 1824, 1026, { mbps: 3.64 }), candidate("1400", 1824, 1026, { mbps: 1.82 })];
86
+ assert.equal(choose({ mode: "manual", candidates }, 10), "2800");
87
+ assert.equal(choose({ mode: "manual", candidates }, 3), "1400", "the link admits only the lower limit");
88
+ });
89
+
90
+ test("a bound is taken before an average, even when the average's limit is higher", () => {
91
+ const candidates = [
92
+ candidate("estimated", 1824, 1026, { mbps: 6, peakClass: PEAK_CLASS.ESTIMATED }),
93
+ candidate("known", 1824, 1026, { mbps: 4, peakClass: PEAK_CLASS.KNOWN })
94
+ ];
95
+ assert.equal(choose({ mode: "manual", candidates }, 10), "known");
96
+ });
97
+
98
+ test("an output with no bound is not served against a measured link, and is while nothing measured it", () => {
99
+ const candidates = [candidate("hardware", 1824, 1026, { mbps: null })];
100
+ assert.equal(choose({ mode: "manual", candidates }, 50), null);
101
+ assert.equal(choose({ mode: "manual", candidates }, null), "hardware");
102
+ });
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
12
12
  import fs from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
- import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
15
+ import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
16
16
 
17
17
  const CHUNK = 64 * 1024;
18
18
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  import test from "node:test";
14
14
  import assert from "node:assert/strict";
15
- import { SourceFile, SourceFiles, sourceDecodeCharacteristics } from "../services/source/SourceFile.js";
15
+ import { SourceFile, SourceFiles, sourceDecodeCharacteristics } from "../services/media/SourceFile.js";
16
16
 
17
17
  test("a file's key is the pair that identifies it, spelled in one place", () => {
18
18
  const file = new SourceFile({ sourceKey: "abc123", fileIndex: 4 });
@@ -19,31 +19,31 @@
19
19
  */
20
20
 
21
21
  import assert from "node:assert/strict";
22
- import { SourceFile } from "../services/source/SourceFile.js";
23
- import { Timeline } from "../services/output/Timeline.js";
22
+ import { SourceFile } from "../services/media/SourceFile.js";
23
+ import { Timeline } from "../services/encode/output/Timeline.js";
24
24
  import test from "node:test";
25
25
  import { mkdtemp, rm } from "node:fs/promises";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
28
 
29
- import { HlsSessionManager } from "../services/hls-session-manager.js";
29
+ import { wireOutputs } from "../services/server/wire-outputs.js";
30
30
  import { startRunOn } from "./helpers/encode-run.js";
31
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
31
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
32
32
 
33
33
  const SEGMENT_SECONDS = 4;
34
34
  const RUN_STARTS_AT = 373;
35
35
  const BEHIND_INDEX = 371;
36
- const SESSION_ID = "22222222-3333-4444-5555-666666666666";
36
+ const SESSION_ID = "2222222233334444";
37
37
 
38
38
  /**
39
39
  * A live session whose run begins at #373 and whose directory is empty, so any
40
40
  * segment request is a request for something not yet produced.
41
41
  *
42
- * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
42
+ * @returns {Promise<{ manager: object, session: object, dirPath: string }>}
43
43
  */
44
44
  async function sessionWithRunAt373() {
45
45
  const dirPath = await mkdtemp(path.join(os.tmpdir(), "stale-request-"));
46
- const manager = new HlsSessionManager({
46
+ const manager = wireOutputs({
47
47
  enabled: true,
48
48
  ffmpegBin: "ffmpeg",
49
49
  localBindHost: "127.0.0.1",
@@ -63,7 +63,6 @@ async function sessionWithRunAt373() {
63
63
  segmentFormat: fmp4Format,
64
64
  segmentCount: 900,
65
65
  startedAt: Date.now(),
66
- createEntryMs: Date.now(),
67
66
  lastAccessedAt: Date.now(),
68
67
  runs: new Set(),
69
68
  consumers: new Set(),
@@ -72,16 +71,15 @@ async function sessionWithRunAt373() {
72
71
  useSyntheticPlaylist: true,
73
72
  playlistText: "#EXTM3U\n",
74
73
  lastError: "",
75
- firstSegmentLogged: false,
76
74
  waitEpoch: 0
77
75
  };
78
- manager.sessionsById.set(SESSION_ID, session);
76
+ manager.outputs.set(SESSION_ID, session);
79
77
  startRunOn(session, { from: RUN_STARTS_AT, usesExplicitCuts: true, speedX: 2 });
80
78
  return { manager, session, dirPath };
81
79
  }
82
80
 
83
81
  /**
84
- * @param {HlsSessionManager} manager
82
+ * @param {object} manager
85
83
  * @param {object} session
86
84
  * @param {string} dirPath
87
85
  */
@@ -90,7 +88,7 @@ async function tidy(manager, session, dirPath) {
90
88
  clearTimeout(session.seekSettleTimer);
91
89
  session.seekSettleTimer = null;
92
90
  }
93
- manager.sessionsById.clear();
91
+ manager.outputs.clear();
94
92
  manager.stop?.();
95
93
  await rm(dirPath, { recursive: true, force: true });
96
94
  }
@@ -102,12 +100,11 @@ test("a request behind where the viewer said they are does not move the encoder"
102
100
  });
103
101
 
104
102
  // The viewer stated their position: 2083.4 s, which is segment #520 here.
105
- manager.requestSeek(SESSION_ID, 2083.4, "viewer-1");
103
+ manager.viewerRequests.requestSeek(SESSION_ID, 2083.4, "viewer-1");
106
104
 
107
- await manager.getFileStream(
105
+ await manager.serving.getFileStream(
108
106
  SESSION_ID,
109
- fmp4Format.segmentFileName(BEHIND_INDEX),
110
- { requestSeq: 1 }
107
+ fmp4Format.segmentFileName(BEHIND_INDEX)
111
108
  );
112
109
 
113
110
  // The field log's line was `seek settle → restart at segment #370`. Nothing
@@ -136,10 +133,9 @@ test("the same traffic moves nothing when the viewer has said nothing either", a
136
133
  // spoken. That exception is the thing that was removed: a request is evidence
137
134
  // about what a player is reading, never about where a person is, and an
138
135
  // encoder placed from it is placed from a number the player picked.
139
- await manager.getFileStream(
136
+ await manager.serving.getFileStream(
140
137
  SESSION_ID,
141
- fmp4Format.segmentFileName(BEHIND_INDEX),
142
- { requestSeq: 1 }
138
+ fmp4Format.segmentFileName(BEHIND_INDEX)
143
139
  );
144
140
 
145
141
  assert.equal(session.seekTarget ?? null, null);
@@ -152,8 +148,8 @@ test("a request cannot move the viewer's position backwards", async (t) => {
152
148
  await tidy(manager, session, dirPath);
153
149
  });
154
150
 
155
- manager.requestSeek(SESSION_ID, 2083.4, "viewer-1");
156
- await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
151
+ manager.viewerRequests.requestSeek(SESSION_ID, 2083.4, "viewer-1");
152
+ await manager.serving.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX));
157
153
 
158
154
  assert.ok(
159
155
  Math.abs((manager.viewers.get("viewer-1")?.positionSeconds() ?? -1) - 2083.4) < 1,