@torrent-tv/proxy 2.86.0 → 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 (382) hide show
  1. package/CHANGELOG.md +2138 -1915
  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 -483
  157. package/services/viewer/Viewers.js +368 -257
  158. package/services/viewer/active-output.js +37 -54
  159. package/services/viewer/audio-start.js +4 -3
  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 -45
  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 -515
  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 -202
  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 -9608
  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/services/viewer/link-readings.js +0 -56
  347. package/test/produced-index.test.js +0 -142
  348. /package/{services → bin}/thread-pool.js +0 -0
  349. /package/services/{available-share.js → encode/available-share.js} +0 -0
  350. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  351. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  352. /package/services/{host-load.js → encode/host-load.js} +0 -0
  353. /package/services/{output → encode/output}/cut-grid.js +0 -0
  354. /package/services/{output → encode/output}/rates.js +0 -0
  355. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  356. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  357. /package/services/{container → media/container}/ebml-reader.js +0 -0
  358. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  359. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  360. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  361. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  362. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/index.js +0 -0
  365. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  366. /package/services/{disk → storage}/keep.js +0 -0
  367. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  368. /package/services/{disk → storage}/returns.js +0 -0
  369. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  370. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  371. /package/services/{demand → torrent/demand}/Window.js +0 -0
  372. /package/services/{demand → torrent/demand}/index.js +0 -0
  373. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  374. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  375. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  376. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  377. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  378. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  379. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  380. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  381. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  382. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -13,15 +13,16 @@
13
13
  */
14
14
 
15
15
  import assert from "node:assert/strict";
16
- import { SourceFile } from "../services/source/SourceFile.js";
16
+ import { SourceFile } from "../services/media/SourceFile.js";
17
17
  import { startRunOn } from "./helpers/encode-run.js";
18
- import { Timeline } from "../services/output/Timeline.js";
18
+ import { Timeline } from "../services/encode/output/Timeline.js";
19
19
  import test from "node:test";
20
20
 
21
- import { HlsSessionManager } from "../services/hls-session-manager.js";
21
+ import { wireOutputs } from "../services/server/wire-outputs.js";
22
22
  import { trueStartOf } from "../services/encode/run-command.js";
23
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
23
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
24
24
  import { ENCODE_RUN_STATE, INITIAL_RUN_STATE } from "../services/encode/encode-run-state.js";
25
+ import { outputSpec } from "./helpers/output-spec.js";
25
26
 
26
27
  const BOUNDARIES = [0, 4, 8, 12, 16, 20];
27
28
 
@@ -29,13 +30,13 @@ const BOUNDARIES = [0, 4, 8, 12, 16, 20];
29
30
  * A film's family: the picture, and a soundtrack rendition of it. Both runs
30
31
  * begin at boundary #2, which the container's table puts at 8 s.
31
32
  *
32
- * @returns {{ manager: HlsSessionManager, picture: object, sound: object }}
33
+ * @returns {{ manager: object, picture: object, sound: object }}
33
34
  */
34
35
  function familyAtBoundaryTwo() {
35
36
  // ONE table for the film. Where it is cut is a fact about the FILE, so the
36
37
  // picture and its soundtrack hold the same array rather than a copy each.
37
38
  const boundaries = [...BOUNDARIES];
38
- const manager = new HlsSessionManager({
39
+ const manager = wireOutputs({
39
40
  enabled: true,
40
41
  ffmpegBin: "ffmpeg",
41
42
  localBindHost: "127.0.0.1",
@@ -43,6 +44,8 @@ function familyAtBoundaryTwo() {
43
44
  });
44
45
  const picture = {
45
46
  id: "picture",
47
+ spec: outputSpec({ transcodeVideo: false }),
48
+ get outputKey() { return this.spec.toKey(); },
46
49
  state: "ready",
47
50
  timeline: new Timeline({ boundaries: boundaries, cutGrid: "uniform" }),
48
51
  file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
@@ -57,16 +60,15 @@ function familyAtBoundaryTwo() {
57
60
  segmentFormat: fmp4Format,
58
61
  // Where its encoder has got to, which a run writes into as it works.
59
62
  progress: { processedSeconds: 0 },
60
- runs: new Set(),
61
- pendingRun: null,
62
- indexCheck: null
63
+ runs: new Set()
63
64
  };
64
65
  // The soundtrack of that picture: the same file, published on its own. Found
65
66
  // by what it is, since no list of ids names it any more.
66
67
  const sound = {
67
68
  id: "sound",
69
+ spec: outputSpec({ audioOnly: true }),
70
+ get outputKey() { return this.spec.toKey(); },
68
71
  state: "ready",
69
- audioOnly: true,
70
72
  baseSessionId: "picture",
71
73
  timeline: new Timeline({ boundaries: boundaries, cutGrid: "uniform" }),
72
74
  file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
@@ -81,14 +83,14 @@ function familyAtBoundaryTwo() {
81
83
  segmentFormat: fmp4Format,
82
84
  // Where its encoder has got to, which a run writes into as it works.
83
85
  progress: { processedSeconds: 0 },
84
- runs: new Set(),
85
- pendingRun: null,
86
- indexCheck: null
86
+ runs: new Set()
87
87
  };
88
- startRunOn(picture, { from: 2 });
89
- startRunOn(sound, { from: 2 });
90
- manager.sessionsById.set("picture", picture);
91
- manager.sessionsById.set("sound", sound);
88
+ const pictureRun = startRunOn(picture, { from: 2 });
89
+ const soundRun = startRunOn(sound, { from: 2 });
90
+ manager.encodeOrchestrator.adopt(picture.outputKey, pictureRun);
91
+ manager.encodeOrchestrator.adopt(sound.outputKey, soundRun);
92
+ manager.outputs.set("picture", picture);
93
+ manager.outputs.set("sound", sound);
92
94
 
93
95
  return { manager, picture, sound };
94
96
  }
@@ -97,7 +99,7 @@ test("a soundtrack follows the picture to the instant the picture really began",
97
99
  const { manager, picture, sound } = familyAtBoundaryTwo();
98
100
  const runBefore = [...sound.runs][0];
99
101
 
100
- manager.correctBoundaryFromSegment(picture, 2, 10.5);
102
+ manager.outputTimes.correctBoundaryFromSegment(picture, 2, 10.5);
101
103
 
102
104
  assert.deepEqual(
103
105
  picture.timeline.boundaries,
@@ -116,7 +118,11 @@ test("a soundtrack follows the picture to the instant the picture really began",
116
118
  // it is known and no further: the run goes, the stretch it held returns to the
117
119
  // map, and where the next one stands follows from where the viewers are.
118
120
  assert.equal(runBefore.state, ENCODE_RUN_STATE.STOPPED, "the run in the wrong place goes");
119
- assert.equal(sound.pendingRun, null, "and this path places nothing");
121
+ assert.equal(
122
+ manager.encodeOrchestrator.runsOn(sound.outputKey).filter((run) => run.isAlive).length,
123
+ 0,
124
+ "and this path places nothing"
125
+ );
120
126
  // The corrected instant reaches whatever the plan places there through the
121
127
  // table, which every session of the file shares. It used to be passed as an
122
128
  // argument from this one call site, so only a run started by that line ever
@@ -149,7 +155,7 @@ test("a correction the table already holds moves nobody", () => {
149
155
  // Within the tolerance: the reading agrees with the table, so there is
150
156
  // nothing to correct and nothing to move. This is what makes the repositioning
151
157
  // converge instead of repeating on every produced segment.
152
- manager.correctBoundaryFromSegment(picture, 2, 8.1);
158
+ manager.outputTimes.correctBoundaryFromSegment(picture, 2, 8.1);
153
159
  assert.deepEqual(sound.timeline.boundaries, before);
154
160
  });
155
161
 
@@ -161,8 +167,12 @@ test("a member that is not running is left alone", () => {
161
167
  soundRun.stop("the viewer switched away");
162
168
  // A stopped run is no longer live, so nothing of this session begins at #2
163
169
  // any more — which is what "left alone" means here.
164
- manager.correctBoundaryFromSegment(picture, 2, 10.5);
165
- assert.equal(sound.pendingRun, null, "a stopped member is not started again for nobody");
170
+ manager.outputTimes.correctBoundaryFromSegment(picture, 2, 10.5);
171
+ assert.equal(
172
+ manager.encodeOrchestrator.runsOn(sound.outputKey).filter((run) => run.isAlive).length,
173
+ 0,
174
+ "a stopped member is not started again for nobody"
175
+ );
166
176
  assert.equal(soundRun.from, 2, "a stopped member keeps its place and its silence");
167
177
  assert.equal(soundRun.state, ENCODE_RUN_STATE.STOPPED);
168
178
  assert.notEqual(INITIAL_RUN_STATE, ENCODE_RUN_STATE.STOPPED);
@@ -183,7 +193,7 @@ test("a soundtrack does not move the grid the picture is cut on", () => {
183
193
  // 1.951 s apart, each overwriting the other for as long as the film ran. The
184
194
  // table never converged, so the guard that stops a correction the table
185
195
  // already holds never fired.
186
- manager.correctBoundaryFromSegment(sound, 2, 10.5);
196
+ manager.outputTimes.correctBoundaryFromSegment(sound, 2, 10.5);
187
197
 
188
198
  assert.deepEqual(
189
199
  picture.timeline.boundaries,
@@ -191,5 +201,9 @@ test("a soundtrack does not move the grid the picture is cut on", () => {
191
201
  "the grid is the picture's cut list and a soundtrack may not move it"
192
202
  );
193
203
  assert.deepEqual(sound.timeline.boundaries, soundBefore);
194
- assert.equal(picture.pendingRun, null, "and nothing is restarted on a soundtrack's say-so");
204
+ assert.equal(
205
+ manager.encodeOrchestrator.runsOn(picture.outputKey).filter((run) => run.isAlive).length,
206
+ 1,
207
+ "and nothing is restarted on a soundtrack's say-so"
208
+ );
195
209
  });
@@ -17,7 +17,7 @@ import assert from "node:assert/strict";
17
17
  import test from "node:test";
18
18
  import { WebSocketServer } from "ws";
19
19
 
20
- import { createTunnelClient } from "../services/tunnel-client.js";
20
+ import { createTunnelClient } from "../services/transport/tunnel-client.js";
21
21
 
22
22
  /**
23
23
  * Wait for the thing being asserted, not for a length of time.
@@ -22,8 +22,8 @@ import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js"
22
22
  * @param {object} session
23
23
  * @returns {boolean}
24
24
  */
25
- function encoding(session) {
26
- return [...(session?.runs ?? [])].some((run) => run.isAlive);
25
+ function encoding(manager, output) {
26
+ return manager.encodeOrchestrator.runsOn(output?.outputKey ?? "").some((run) => run.isAlive);
27
27
  }
28
28
  /**
29
29
  * Whether anybody is still watching this output.
@@ -42,22 +42,23 @@ function encoding(session) {
42
42
  * @param {object} session
43
43
  * @returns {boolean}
44
44
  */
45
- function watched(session) {
46
- return [...(session?.viewers ?? new Map()).values()].length > 0;
45
+ function watched(manager, session) {
46
+ return session ? manager.viewers.forOutput(session).size > 0 : false;
47
47
  }
48
48
 
49
49
  import assert from "node:assert/strict";
50
- import { SourceFile } from "../services/source/SourceFile.js";
51
- import { Timeline } from "../services/output/Timeline.js";
50
+ import { SourceFile } from "../services/media/SourceFile.js";
51
+ import { Timeline } from "../services/encode/output/Timeline.js";
52
52
  import { mkdtemp, rm } from "node:fs/promises";
53
53
  import os from "node:os";
54
54
  import path from "node:path";
55
- import { audioRenditionKey, HlsSessionManager } from "../services/hls-session-manager.js";
56
- import { viewerOf } from "../services/viewer/Viewer.js";
57
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
58
- import { Output } from "../services/output/Output.js";
55
+ import { audioRenditionKey } from "../services/encode/Renditions.js";
56
+ import { managerWithOwnStore } from "./helpers/manager.js";
57
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
58
+ import { Output } from "../services/encode/output/Output.js";
59
+ import { outputSpec } from "./helpers/output-spec.js";
59
60
 
60
- const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
61
+ const BASE_ID = "aaaaaaaabbbbcccc";
61
62
  const SEGMENT_SECONDS = 4;
62
63
  const FIRST = "viewer-one";
63
64
  const SECOND = "viewer-two";
@@ -65,12 +66,35 @@ const SECOND = "viewer-two";
65
66
  /**
66
67
  * A session shaped like a live one, without the ffmpeg run behind it.
67
68
  *
68
- * @param {{ id: string, dirPath: string, audioTrackIndex?: number, transcodeAudio?: boolean }} params
69
+ * @param {{ id: string, dirPath: string, audioTrackIndex?: number, transcodeAudio?: boolean, audioOnly?: boolean, audioSeparate?: boolean }} params
69
70
  * @returns {object}
70
71
  */
71
- function fakeSession({ id, dirPath, audioTrackIndex = 0, transcodeAudio = true }) {
72
+ function fakeSession({
73
+ id,
74
+ dirPath,
75
+ audioTrackIndex = 0,
76
+ transcodeAudio = true,
77
+ audioOnly = false,
78
+ audioSeparate = false,
79
+ // THE SIZE IS PART OF THE IDENTITY, so two steps of different sizes are two
80
+ // addresses. Left out, every step of this fixture shared one key and the
81
+ // record of which output answers a height could not tell them apart — which
82
+ // is the fixture describing something production does not do, not a rule
83
+ // being too strict.
84
+ encodeHeight = 0
85
+ }) {
72
86
  return {
73
87
  id,
88
+ spec: outputSpec({
89
+ transcodeVideo: !audioOnly,
90
+ transcodeAudio,
91
+ audioOnly,
92
+ audioSeparate,
93
+ audioSourceTrackIndex: audioTrackIndex,
94
+ height: encodeHeight,
95
+ width: encodeHeight > 0 ? Math.round((encodeHeight * 16) / 9) : 0
96
+ }),
97
+ get outputKey() { return this.spec.toKey(); },
74
98
  dirPath,
75
99
  // Where this file is cut, held by the file. A fixture that stated it
76
100
  // on the session was describing what production no longer does.
@@ -85,12 +109,9 @@ function fakeSession({ id, dirPath, audioTrackIndex = 0, transcodeAudio = true }
85
109
  get inputFile() { return this.file; },
86
110
  get audioFile() { return this.file; },
87
111
  startedAt: Date.now(),
88
- createEntryMs: Date.now(),
89
112
  lastAccessedAt: Date.now(),
90
113
  ffmpeg: null,
91
114
  lastError: "",
92
- consumers: new Set(),
93
- viewers: new Map(),
94
115
 
95
116
  segmentFormat: fmp4Format,
96
117
  transcodeVideo: false,
@@ -115,31 +136,35 @@ function fakeSession({ id, dirPath, audioTrackIndex = 0, transcodeAudio = true }
115
136
  };
116
137
  }
117
138
 
139
+ function startManagedRun(manager, output, options = {}) {
140
+ const run = startRunOn(output, options);
141
+ manager.encodeOrchestrator.adopt(output.outputKey, run);
142
+ return run;
143
+ }
144
+
118
145
 
119
146
  /**
120
147
  * A base picture serving two viewers, with its audio published separately and
121
148
  * every rendition created by a stub instead of an encoder.
122
149
  *
123
- * @returns {Promise<{ manager: HlsSessionManager, base: object, dirPath: string, renditions: Map<string, object> }>}
150
+ * @returns {Promise<{ manager: object, base: object, dirPath: string, renditions: Map<string, object> }>}
124
151
  */
125
152
  async function pictureWithTwoViewers() {
126
153
  const dirPath = await mkdtemp(path.join(os.tmpdir(), "two-viewers-"));
127
- const manager = new HlsSessionManager({
128
- enabled: true,
129
- ffmpegBin: "ffmpeg",
130
- localBindHost: "127.0.0.1",
131
- localPort: 9090
132
- });
133
- const base = fakeSession({ id: BASE_ID, dirPath });
134
- base.audioSeparate = true;
135
- base.consumers = new Set([FIRST, SECOND]);
154
+ // Its own store root — see `helpers/manager.js` for what sharing one cost.
155
+ const { manager } = managerWithOwnStore();
156
+ // These checks cover ownership changes made by viewer requests. Encoder
157
+ // placement is covered by the plan tests and must not run asynchronously in
158
+ // the middle of an assertion about the request path.
159
+ manager.encodeRuns.planEncodersSoon = () => {};
160
+ const base = fakeSession({ id: BASE_ID, dirPath, audioSeparate: true });
136
161
  // Both viewers are watching the picture, which is what keeps their choices
137
162
  // alive; a viewer whose head has expired holds no encoder.
138
- viewerOf(base, FIRST).position = { segment: 3, seconds: 12, at: Date.now() };
139
- viewerOf(base, SECOND).position = { segment: 3, seconds: 12, at: Date.now() };
140
- viewerOf(base, FIRST).audio = { trackIndex: 0, transcode: true };
141
- viewerOf(base, SECOND).audio = { trackIndex: 1, transcode: true };
142
- manager.sessionsById.set(BASE_ID, base);
163
+ manager.viewers.of(base, FIRST).position = { segment: 3, seconds: 12, at: Date.now() };
164
+ manager.viewers.of(base, SECOND).position = { segment: 3, seconds: 12, at: Date.now() };
165
+ manager.viewers.of(base, FIRST).audio = { trackIndex: 0, transcode: true };
166
+ manager.viewers.of(base, SECOND).audio = { trackIndex: 1, transcode: true };
167
+ manager.outputs.set(BASE_ID, base);
143
168
  manager.getCachedAudioTracks = () => [
144
169
  { index: 0, language: "rus", title: "Дубляж", isDefault: true, fileIndex: 0, sourceTrackIndex: 0 },
145
170
  { index: 1, language: "eng", title: "", isDefault: false, fileIndex: 0, sourceTrackIndex: 1 }
@@ -148,7 +173,7 @@ async function pictureWithTwoViewers() {
148
173
 
149
174
  /** @type {Map<string, object>} */
150
175
  const renditions = new Map();
151
- manager.createOrGetSession = async (params) => {
176
+ manager.viewerRequests.createOrGetSession = async (params) => {
152
177
  const key = audioRenditionKey(params.audioTrackIndex, params.transcodeAudio);
153
178
  const existing = renditions.get(key);
154
179
  if (existing) {
@@ -158,11 +183,12 @@ async function pictureWithTwoViewers() {
158
183
  id: `rendition-${key}`,
159
184
  dirPath,
160
185
  audioTrackIndex: params.audioTrackIndex,
161
- transcodeAudio: params.transcodeAudio
186
+ transcodeAudio: params.transcodeAudio,
187
+ audioOnly: true
162
188
  });
163
- rendition.audioOnly = true;
164
- startRunOn(rendition, { process: fakeEncoder() });
165
- manager.sessionsById.set(rendition.id, rendition);
189
+ rendition.id = rendition.spec.toName();
190
+ startManagedRun(manager, rendition, { process: fakeEncoder() });
191
+ manager.outputs.set(rendition.id, rendition);
166
192
  renditions.set(key, rendition);
167
193
  return rendition;
168
194
  };
@@ -172,12 +198,12 @@ async function pictureWithTwoViewers() {
172
198
  test("one viewer fetching their soundtrack does not stop the other viewer's", async (t) => {
173
199
  const { manager, renditions, dirPath } = await pictureWithTwoViewers();
174
200
  t.after(async () => {
175
- await manager.disposeAll();
201
+ await manager.lifecycle.disposeAll();
176
202
  await rm(dirPath, { recursive: true, force: true });
177
203
  });
178
204
 
179
- const first = await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
180
- const second = await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00003.mp4", SECOND);
205
+ const first = await manager.renditions.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
206
+ const second = await manager.renditions.resolveAudioRenditionFile(BASE_ID, 1, "segment-00003.mp4", SECOND);
181
207
 
182
208
  assert.notEqual(first.sessionId, second.sessionId, "two soundtracks are two encodes");
183
209
  for (const [key, rendition] of renditions) {
@@ -186,8 +212,8 @@ test("one viewer fetching their soundtrack does not stop the other viewer's", as
186
212
  }
187
213
 
188
214
  // And it holds under the traffic that actually happens: they alternate.
189
- await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00004.mp4", FIRST);
190
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", SECOND);
215
+ await manager.renditions.resolveAudioRenditionFile(BASE_ID, 0, "segment-00004.mp4", FIRST);
216
+ await manager.renditions.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", SECOND);
191
217
  for (const [key, rendition] of renditions) {
192
218
  assert.deepEqual([...rendition.runs][0]?.process?.signals ?? [], [], `nothing signalled ${key} on the second round`);
193
219
  }
@@ -196,22 +222,20 @@ test("one viewer fetching their soundtrack does not stop the other viewer's", as
196
222
  test("a soundtrack nobody is listening to any more is let go of", async (t) => {
197
223
  const { manager, base, renditions, dirPath } = await pictureWithTwoViewers();
198
224
  t.after(async () => {
199
- await manager.disposeAll();
225
+ await manager.lifecycle.disposeAll();
200
226
  await rm(dirPath, { recursive: true, force: true });
201
227
  });
202
228
  // One viewer only, so what they leave is left for nobody. This is the case
203
229
  // the stop exists for: an encoder AND a reader holding pieces of the torrent.
204
- base.consumers = new Set([FIRST]);
205
- base.viewers.delete(SECOND);
206
- base.viewers.delete(SECOND);
230
+ manager.viewers.leaves(base, SECOND);
207
231
 
208
- await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
209
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", FIRST);
232
+ await manager.renditions.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
233
+ await manager.renditions.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", FIRST);
210
234
 
211
235
  const left = renditions.get(audioRenditionKey(0, true));
212
236
  const moved = renditions.get(audioRenditionKey(1, true));
213
- assert.equal(watched(left), false, "the track the viewer left is nobody's now");
214
- assert.ok(watched(moved), "and the track they moved to is theirs");
237
+ assert.equal(watched(manager, left), false, "the track the viewer left is nobody's now");
238
+ assert.ok(watched(manager, moved), "and the track they moved to is theirs");
215
239
  assert.deepEqual(
216
240
  [...left.runs][0]?.process?.signals ?? [],
217
241
  [],
@@ -222,35 +246,35 @@ test("a soundtrack nobody is listening to any more is let go of", async (t) => {
222
246
  test("each viewer's browser decides for itself whether its soundtrack is re-encoded", async (t) => {
223
247
  const { manager, base, renditions, dirPath } = await pictureWithTwoViewers();
224
248
  t.after(async () => {
225
- await manager.disposeAll();
249
+ await manager.lifecycle.disposeAll();
226
250
  await rm(dirPath, { recursive: true, force: true });
227
251
  });
228
252
  // The same track, two browsers: one can decode it as it stands, the other
229
253
  // cannot. Answering both from the session's own flag would leave the second
230
254
  // viewer with silence.
231
- viewerOf(base, FIRST).audio = { trackIndex: 0, transcode: false };
232
- viewerOf(base, SECOND).audio = { trackIndex: 0, transcode: true };
255
+ manager.viewers.of(base, FIRST).audio = { trackIndex: 0, transcode: false };
256
+ manager.viewers.of(base, SECOND).audio = { trackIndex: 0, transcode: true };
233
257
 
234
- const copied = await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
235
- const encoded = await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", SECOND);
258
+ const copied = await manager.renditions.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
259
+ const encoded = await manager.renditions.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", SECOND);
236
260
 
237
261
  assert.notEqual(copied.sessionId, encoded.sessionId);
238
262
  assert.equal(renditions.get(audioRenditionKey(0, false)).transcodeAudio, false);
239
263
  assert.equal(renditions.get(audioRenditionKey(0, true)).transcodeAudio, true);
240
264
  // Both are wanted, so neither is stopped.
241
- assert.ok(encoding(renditions.get(audioRenditionKey(0, false))));
242
- assert.ok(encoding(renditions.get(audioRenditionKey(0, true))));
265
+ assert.ok(encoding(manager, renditions.get(audioRenditionKey(0, false))));
266
+ assert.ok(encoding(manager, renditions.get(audioRenditionKey(0, true))));
243
267
  });
244
268
 
245
269
  test("the master marks each viewer's own soundtrack as the default one", async (t) => {
246
270
  const { manager, dirPath } = await pictureWithTwoViewers();
247
271
  t.after(async () => {
248
- await manager.disposeAll();
272
+ await manager.lifecycle.disposeAll();
249
273
  await rm(dirPath, { recursive: true, force: true });
250
274
  });
251
275
 
252
- const forFirst = manager.buildMasterPlaylist(BASE_ID, FIRST);
253
- const forSecond = manager.buildMasterPlaylist(BASE_ID, SECOND);
276
+ const forFirst = manager.renditions.buildMasterPlaylist(BASE_ID, FIRST);
277
+ const forSecond = manager.renditions.buildMasterPlaylist(BASE_ID, SECOND);
254
278
 
255
279
  const defaultsOf = (master) =>
256
280
  [...master.matchAll(/^#EXT-X-MEDIA:.*?NAME="([^"]+)".*?DEFAULT=(YES|NO)/gm)]
@@ -264,53 +288,62 @@ test("the master marks each viewer's own soundtrack as the default one", async (
264
288
  test("one viewer changing quality does not take the other off their step", async (t) => {
265
289
  const { manager, base, dirPath } = await pictureWithTwoViewers();
266
290
  t.after(async () => {
267
- await manager.disposeAll();
291
+ await manager.lifecycle.disposeAll();
268
292
  await rm(dirPath, { recursive: true, force: true });
269
293
  });
270
294
  /** @type {Map<number, object>} */
271
295
  const variants = new Map();
272
- manager.createOrGetSession = async (params) => {
296
+ manager.viewerRequests.createOrGetSession = async (params) => {
273
297
  const height = params.targetHeight;
274
298
  const existing = variants.get(height);
275
299
  if (existing) {
276
300
  return existing;
277
301
  }
278
- const variant = fakeSession({ id: `variant-${height}`, dirPath });
302
+ // A STEP CARRIES WHAT ITS PICTURE CARRIES. This picture publishes its
303
+ // soundtrack separately, so its steps do too — which is what production
304
+ // passes (`audioRenditions: servesAudioSeparately(base)`). Built muxed, a
305
+ // step is not the same material as the picture it is a step of, and is
306
+ // rightly refused.
307
+ const variant = fakeSession({ id: `variant-${height}`, dirPath, encodeHeight: height, audioSeparate: true });
279
308
  variant.transcodeVideo = true;
280
309
  variant.output.encodeHeight = height;
281
310
  variant.variantHeight = height;
282
311
  variant.isStep = true;
312
+ manager.outputs.markStep(variant);
283
313
  variant.file = base.file;
284
- startRunOn(variant, { process: fakeEncoder() });
285
- manager.sessionsById.set(variant.id, variant);
314
+ startManagedRun(manager, variant, { process: fakeEncoder() });
315
+ manager.outputs.set(variant.id, variant);
286
316
  variants.set(height, variant);
287
317
  return variant;
288
318
  };
289
319
 
290
- await manager.resolveVariantFile(BASE_ID, 720, "segment-00003.mp4", FIRST);
291
- await manager.resolveVariantFile(BASE_ID, 540, "segment-00003.mp4", SECOND);
320
+ await manager.renditions.resolveVariantFile(BASE_ID, 720, "segment-00003.mp4", FIRST);
321
+ manager.renditions.viewerPlays(BASE_ID, FIRST, 720, 12);
322
+ await manager.renditions.resolveVariantFile(BASE_ID, 540, "segment-00003.mp4", SECOND);
323
+ manager.renditions.viewerPlays(BASE_ID, SECOND, 540, 12);
292
324
  // Both viewers go on watching their own step, which is what a player does
293
325
  // every few seconds.
294
- await manager.resolveVariantFile(BASE_ID, 720, "segment-00004.mp4", FIRST);
295
- await manager.resolveVariantFile(BASE_ID, 540, "segment-00004.mp4", SECOND);
326
+ await manager.renditions.resolveVariantFile(BASE_ID, 720, "segment-00004.mp4", FIRST);
327
+ await manager.renditions.resolveVariantFile(BASE_ID, 540, "segment-00004.mp4", SECOND);
296
328
 
297
- assert.ok(encoding(variants.get(720)), "the first viewer's step is still encoding");
298
- assert.ok(encoding(variants.get(540)), "and so is the second viewer's");
329
+ assert.ok(encoding(manager, variants.get(720)), "the first viewer's step is still encoding");
330
+ assert.ok(encoding(manager, variants.get(540)), "and so is the second viewer's");
299
331
 
300
332
  // Now the first viewer steps down. Theirs is left for nobody and stops; the
301
333
  // other viewer's is untouched.
302
- await manager.resolveVariantFile(BASE_ID, 480, "segment-00005.mp4", FIRST);
334
+ await manager.renditions.resolveVariantFile(BASE_ID, 480, "segment-00005.mp4", FIRST);
335
+ manager.renditions.viewerPlays(BASE_ID, FIRST, 480, 20);
303
336
 
304
- assert.equal(watched(variants.get(720)), false, "the step nobody is on is nobody's");
305
- assert.ok(watched(variants.get(540)), "the step the other viewer is watching stays theirs");
306
- assert.ok(watched(variants.get(480)), "and the one they moved to is now theirs");
307
- assert.ok(encoding(variants.get(540)), "and nothing here touched the other viewer's encoder");
337
+ assert.equal(watched(manager, variants.get(720)), false, "the step nobody is on is nobody's");
338
+ assert.ok(watched(manager, variants.get(540)), "the step the other viewer is watching stays theirs");
339
+ assert.ok(watched(manager, variants.get(480)), "and the one they moved to is now theirs");
340
+ assert.ok(encoding(manager, variants.get(540)), "and nothing here touched the other viewer's encoder");
308
341
  });
309
342
 
310
343
  test("a step somebody is watching is never withdrawn from the offer", async (t) => {
311
344
  const { manager, base, dirPath } = await pictureWithTwoViewers();
312
345
  t.after(async () => {
313
- await manager.disposeAll();
346
+ await manager.lifecycle.disposeAll();
314
347
  await rm(dirPath, { recursive: true, force: true });
315
348
  });
316
349
  // A host that can re-encode 240p and nothing above it — the shape of the
@@ -330,20 +363,20 @@ test("a step somebody is watching is never withdrawn from the offer", async (t)
330
363
  // One file, two sessions of it.
331
364
  variant.file = base.file;
332
365
  variant.isStep = true;
366
+ manager.outputs.markStep(variant);
333
367
  variant.file = base.file;
334
- manager.sessionsById.set(variant.id, variant);
335
- base.file.stepHeights.set(720, 720);
368
+ manager.outputs.set(variant.id, variant);
336
369
 
337
370
  // Nobody on it: measured below realtime, it is withdrawn. This half is the
338
371
  // control — without it the other half proves nothing.
339
- const withoutAViewer = manager.offeredHeights(base);
372
+ const withoutAViewer = manager.quality.offeredHeights(base);
340
373
  assert.ok(
341
374
  !withoutAViewer.includes(720),
342
375
  `a step nobody is on and that cannot keep up is withdrawn: ${withoutAViewer.join(" ")}`
343
376
  );
344
377
 
345
- viewerOf(base, SECOND).activeVariantId = variant.id;
346
- const withAViewer = manager.offeredHeights(base);
378
+ manager.viewers.of(base, SECOND).activeVariantId = variant.id;
379
+ const withAViewer = manager.quality.offeredHeights(base);
347
380
 
348
381
  assert.ok(
349
382
  withAViewer.includes(720),
@@ -354,20 +387,20 @@ test("a step somebody is watching is never withdrawn from the offer", async (t)
354
387
  test("a viewer whose picture has gone quiet holds no soundtrack encoder", async (t) => {
355
388
  const { manager, base, renditions, dirPath } = await pictureWithTwoViewers();
356
389
  t.after(async () => {
357
- await manager.disposeAll();
390
+ await manager.lifecycle.disposeAll();
358
391
  await rm(dirPath, { recursive: true, force: true });
359
392
  });
360
- await manager.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
361
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00003.mp4", SECOND);
393
+ await manager.renditions.resolveAudioRenditionFile(BASE_ID, 0, "segment-00003.mp4", FIRST);
394
+ await manager.renditions.resolveAudioRenditionFile(BASE_ID, 1, "segment-00003.mp4", SECOND);
362
395
  // The second viewer's tab is gone. Nothing releases the session when a
363
396
  // channel closes (roadmap item 54), so what expires is their head on the
364
397
  // picture — and with it their claim on an encoder.
365
- base.viewers.delete(SECOND);
398
+ manager.viewers.leaves(base, SECOND);
366
399
 
367
- await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", FIRST);
400
+ await manager.renditions.resolveAudioRenditionFile(BASE_ID, 1, "segment-00004.mp4", FIRST);
368
401
 
369
402
  assert.equal(
370
- watched(renditions.get(audioRenditionKey(0, true))),
403
+ watched(manager, renditions.get(audioRenditionKey(0, true))),
371
404
  false,
372
405
  "the first viewer moved on, so their old track is nobody's"
373
406
  );
@@ -17,9 +17,9 @@
17
17
 
18
18
  import test from "node:test";
19
19
  import assert from "node:assert/strict";
20
- import { decideUploadLimit, torrentsForUploadPolicy } from "../services/torrent-pool.js";
21
- import { demandFor, forgetTorrent } from "../services/download/registry.js";
22
- import { Urgency } from "../services/demand/index.js";
20
+ import { decideUploadLimit, torrentsForUploadPolicy } from "../services/torrent/torrent-pool.js";
21
+ import { demandFor, forgetTorrent } from "../services/torrent/download/registry.js";
22
+ import { Urgency } from "../services/torrent/demand/index.js";
23
23
 
24
24
  /**
25
25
  * A torrent whose metadata has arrived and which nothing is wanted of.
@@ -11,7 +11,7 @@ import test from "node:test";
11
11
  import assert from "node:assert/strict";
12
12
  import { EventEmitter } from "node:events";
13
13
 
14
- import { createUsrsctpStateReader, SCTPSTATE_SCRIPT_PATH } from "../services/usrsctp-state.js";
14
+ import { createUsrsctpStateReader, SCTPSTATE_SCRIPT_PATH } from "../services/transport/usrsctp-state.js";
15
15
 
16
16
  class FakeChild extends EventEmitter {
17
17
  constructor(command, args) {
@@ -17,7 +17,7 @@
17
17
  import test from "node:test";
18
18
  import assert from "node:assert/strict";
19
19
 
20
- import { Container } from "../services/container/Container.js";
20
+ import { Container } from "../services/media/container/Container.js";
21
21
 
22
22
  const banner = (fields) => ({ width: null, height: null, fps: null, isHdr: false, bitDepth: null, ...fields });
23
23