@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
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @file What a viewer's own assignment to an output must do.
3
+ *
4
+ * The property under all of it: one viewer's link, seek or choice never moves
5
+ * another viewer's output. That is roadmap item 97, and the record it replaces
6
+ * — one entry per (file, height) for everybody — could not express it.
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { Assignments } from "../services/viewer/Assignments.js";
12
+
13
+ const WINDOW_MS = 30_000;
14
+ const make = () => new Assignments({ acceptWindowMs: WINDOW_MS });
15
+
16
+ test("the acceptance window is given, not invented", () => {
17
+ assert.throws(() => new Assignments({}), TypeError);
18
+ assert.throws(() => new Assignments({ acceptWindowMs: -1 }), TypeError);
19
+ });
20
+
21
+ test("a choice stands until the rule moves it, and a request never moves it", () => {
22
+ const mine = make();
23
+
24
+ mine.choose(720, "out-a");
25
+ mine.give(mine.generation, 720, 4, "out-a");
26
+
27
+ assert.equal(mine.chosenFor(720), "out-a");
28
+ mine.give(mine.generation, 720, 5, "out-b");
29
+ assert.equal(mine.chosenFor(720), "out-a", "being given something else does not re-choose");
30
+ mine.choose(720, "out-b");
31
+ assert.equal(mine.chosenFor(720), "out-b");
32
+ });
33
+
34
+ test("two viewers do not share an answer", () => {
35
+ const thin = make();
36
+ const thick = make();
37
+
38
+ thin.choose(720, "out-540-slow");
39
+ thick.choose(720, "out-720-full");
40
+
41
+ assert.equal(thin.chosenFor(720), "out-540-slow");
42
+ assert.equal(thick.chosenFor(720), "out-720-full", "the other viewer's link decided nothing here");
43
+ });
44
+
45
+ test("a repeat of a request is answered by whatever answered it, not decided again", () => {
46
+ const mine = make();
47
+ const first = mine.generation;
48
+
49
+ mine.give(first, 720, 9, "out-a");
50
+
51
+ assert.equal(mine.givenFor(first, 720, 9), "out-a");
52
+ assert.equal(mine.givenFor(first, 720, 10), "", "a segment nothing answered says so");
53
+ assert.equal(mine.givenFor(first + 1, 720, 9), "", "and a later generation is not the same question");
54
+ });
55
+
56
+ test("a request carries its own generation, so a late one is not read against the current", () => {
57
+ const mine = make();
58
+ const made = mine.generation;
59
+
60
+ mine.statedGeneration(1, 1_000);
61
+
62
+ assert.equal(mine.generation, 1);
63
+ assert.notEqual(mine.generation, made);
64
+ assert.equal(mine.accepts(made, 1_000 + WINDOW_MS), true, "inside the window it is still answered");
65
+ assert.equal(mine.accepts(made, 1_000 + WINDOW_MS + 1), false, "past it, a new request is not");
66
+ assert.equal(mine.accepts(mine.generation, 1_000 + 10 * WINDOW_MS), true, "the current one never runs out");
67
+ });
68
+
69
+ test("an accepted response keeps its output past its own generation's window", () => {
70
+ const mine = make();
71
+ const made = mine.generation;
72
+ mine.give(made, 720, 3, "out-a");
73
+ const token = mine.accept("out-a");
74
+
75
+ mine.statedGeneration(1, 1_000);
76
+ mine.choose(720, "out-b");
77
+
78
+ const longAfter = 1_000 + 10 * WINDOW_MS;
79
+ assert.equal(mine.accepts(made, longAfter), false, "no NEW request of that generation is taken");
80
+ assert.ok(
81
+ mine.heldKeys(longAfter).has("out-a"),
82
+ "but the output whose bytes are still going out is held — dropping it is what the second deadline exists to prevent"
83
+ );
84
+
85
+ mine.release(token);
86
+ assert.equal(mine.heldKeys(longAfter).has("out-a"), false, "and released once the response is done");
87
+ });
88
+
89
+ test("what was given inside a live generation holds its output, and is forgotten past the window", () => {
90
+ const mine = make();
91
+ const made = mine.generation;
92
+ mine.give(made, 720, 3, "out-a");
93
+ mine.statedGeneration(1, 1_000);
94
+
95
+ assert.ok(mine.heldKeys(1_000 + WINDOW_MS).has("out-a"));
96
+ assert.equal(
97
+ mine.heldKeys(1_000 + WINDOW_MS + 1).has("out-a"),
98
+ false,
99
+ "past the window nothing stands on it, so it may be let go"
100
+ );
101
+ assert.equal(
102
+ mine.givenFor(made, 720, 3),
103
+ "",
104
+ "and the generation is pruned rather than kept for the life of the viewing"
105
+ );
106
+ });
107
+
108
+ test("a viewer leaving holds nothing, response in flight included", () => {
109
+ const mine = make();
110
+ mine.choose(720, "out-a");
111
+ mine.accept("out-b");
112
+ mine.give(mine.generation, 720, 1, "out-c");
113
+
114
+ mine.clear();
115
+
116
+ assert.equal(mine.heldKeys().size, 0);
117
+ });
118
+
119
+ test("the page owns the number, so a lower one does not take the viewer back", () => {
120
+ const mine = make();
121
+ mine.statedGeneration(4, 1_000);
122
+
123
+ assert.equal(mine.statedGeneration(2, 2_000), 4, "a reordered or repeated message changes nothing");
124
+ assert.equal(mine.statedGeneration(4, 2_000), 4, "and neither does the one already current");
125
+ assert.equal(mine.generation, 4);
126
+ });
127
+
128
+ test("a request ahead of what the page has announced is still answered", () => {
129
+ const mine = make();
130
+
131
+ // The page raises its own number before it sends anything, so a segment
132
+ // request can overtake the seek that announces the new viewing.
133
+ assert.equal(mine.accepts(1), true, "being behind the page means this side has not heard yet");
134
+ assert.equal(mine.accepts(7), true);
135
+ });
136
+
137
+ test("a request that states no generation is answered", () => {
138
+ const mine = make();
139
+ mine.statedGeneration(3, 1_000);
140
+
141
+ assert.equal(mine.accepts(Number.NaN, 1_000 + 10 * WINDOW_MS), true);
142
+ assert.equal(mine.accepts(undefined, 1_000 + 10 * WINDOW_MS), true, "a transport with no loader of ours builds no such parameter");
143
+ });
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * @file What a viewer is watching, and what happens to it when they leave.
3
3
  *
4
- * The relation "this person watches this output" is indexed both ways — the
5
- * output holds its viewers, the viewer holds its outputs — and these checks
6
- * hold the three things that go wrong when the two indexes come apart.
4
+ * The relation "this person watches this output" is stored on the viewer, and
5
+ * these checks hold the operations that derive the opposite direction.
7
6
  *
8
7
  * 1. The viewer must never be dropped from the PICTURE. Their chosen
9
8
  * soundtrack, their position and their step are recorded there, and the
@@ -20,22 +19,24 @@
20
19
  * leaves a claim that nothing can reach.
21
20
  */
22
21
 
22
+ import { chooseOutput } from "../services/viewer/choices.js";
23
23
  import test from "node:test";
24
24
  import assert from "node:assert/strict";
25
25
  import { mkdtemp, rm } from "node:fs/promises";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
- import { HlsSessionManager, variantConsumerId } from "../services/hls-session-manager.js";
28
+ import { wireOutputs } from "../services/server/wire-outputs.js";
29
29
  import { Viewers } from "../services/viewer/Viewers.js";
30
- import { SourceFile } from "../services/source/SourceFile.js";
31
- import { Timeline } from "../services/output/Timeline.js";
32
- import { Output } from "../services/output/Output.js";
33
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
30
+ import { SourceFile } from "../services/media/SourceFile.js";
31
+ import { Timeline } from "../services/encode/output/Timeline.js";
32
+ import { Output } from "../services/encode/output/Output.js";
33
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
34
34
  import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
35
+ import { outputSpec } from "./helpers/output-spec.js";
35
36
 
36
- const BASE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
37
- const STEP_ID = "11111111-2222-3333-4444-555555555555";
38
- const AUDIO_ID = "99999999-8888-7777-6666-555555555555";
37
+ const BASE_ID = "aaaaaaaabbbbcccc";
38
+ const STEP_ID = "1111111122223333";
39
+ const AUDIO_ID = "9999999988887777";
39
40
  const VIEWER = "viewer-one";
40
41
  const SEGMENT_SECONDS = 4;
41
42
 
@@ -49,6 +50,13 @@ const SEGMENT_SECONDS = 4;
49
50
  function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, isStep = false }) {
50
51
  return {
51
52
  id,
53
+ spec: outputSpec({
54
+ sourceKey: file.sourceKey,
55
+ fileIndex: file.fileIndex,
56
+ transcodeVideo: !audioOnly && encodeHeight > 0,
57
+ audioOnly,
58
+ height: encodeHeight
59
+ }),
52
60
  dirPath,
53
61
  timeline: new Timeline({
54
62
  boundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
@@ -59,11 +67,9 @@ function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, i
59
67
  get inputFile() { return this.file; },
60
68
  get audioFile() { return this.file; },
61
69
  startedAt: Date.now(),
62
- createEntryMs: Date.now(),
63
70
  lastAccessedAt: Date.now(),
64
71
  ffmpeg: null,
65
72
  lastError: "",
66
- consumers: new Set(),
67
73
  segmentFormat: fmp4Format,
68
74
  transcodeVideo: !audioOnly,
69
75
  transcodeAudio: true,
@@ -88,7 +94,6 @@ function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, i
88
94
  seekTarget: null,
89
95
  waitEpoch: 0,
90
96
  runs: new Set(),
91
- viewers: new Map(),
92
97
  usesExplicitCuts: false,
93
98
  useSyntheticPlaylist: true,
94
99
  playlistText: "#EXTM3U\n",
@@ -102,12 +107,12 @@ function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, i
102
107
  * of one file, and one viewer watching all three — which is what an ordinary
103
108
  * session looks like once the viewer has picked a language and a quality.
104
109
  *
105
- * @returns {Promise<{ manager: HlsSessionManager, base: object, step: object,
110
+ * @returns {Promise<{ manager: object, base: object, step: object,
106
111
  * audio: object, dirPath: string, released: string[] }>}
107
112
  */
108
113
  async function pictureWithStepAndSoundtrack() {
109
114
  const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-outputs-"));
110
- const manager = new HlsSessionManager({
115
+ const manager = wireOutputs({
111
116
  enabled: true,
112
117
  ffmpegBin: "ffmpeg",
113
118
  localBindHost: "127.0.0.1",
@@ -118,15 +123,12 @@ async function pictureWithStepAndSoundtrack() {
118
123
  const base = fakeSession({ id: BASE_ID, dirPath, file, encodeHeight: 812 });
119
124
  const step = fakeSession({ id: STEP_ID, dirPath, file, encodeHeight: 540, isStep: true });
120
125
  const audio = fakeSession({ id: AUDIO_ID, dirPath, file, audioOnly: true });
121
- base.consumers = new Set([VIEWER]);
122
- // The family's own claim, which is how a picture holds what it made: the
123
- // browser never learns these two ids.
124
- step.consumers = new Set([variantConsumerId(BASE_ID)]);
125
- audio.consumers = new Set([variantConsumerId(BASE_ID)]);
126
+ // The browser never learns these two ids: what keeps them is who watches
127
+ // them, and nothing else.
126
128
  for (const session of [base, step, audio]) {
127
- manager.sessionsById.set(session.id, session);
129
+ manager.outputs.set(session.id, session);
128
130
  }
129
- file.stepHeights.set(540, 540);
131
+ manager.outputs.markStep(step);
130
132
  return { manager, base, step, audio, dirPath };
131
133
  }
132
134
 
@@ -137,12 +139,17 @@ test("a viewer who steps down, back to the picture's own height and down again k
137
139
  const viewer = manager.viewers.of(base, VIEWER);
138
140
  viewer.audio = { trackIndex: 1, transcode: true };
139
141
  viewer.position = { segment: 25, seconds: 100, at: Date.now() };
142
+ // The rule chose the step for them at 540p; the record is theirs.
143
+ chooseOutput(manager.viewers, VIEWER, 540, manager.outputs.get(STEP_ID).outputKey);
140
144
 
141
- await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4", VIEWER);
142
- await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4", VIEWER);
143
- await manager.resolveVariantFile(BASE_ID, 540, "segment-00027.mp4", VIEWER);
145
+ await manager.renditions.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4", VIEWER);
146
+ manager.renditions.viewerPlays(BASE_ID, VIEWER, 540, 100);
147
+ await manager.renditions.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4", VIEWER);
148
+ manager.renditions.viewerPlays(BASE_ID, VIEWER, 812, 104);
149
+ await manager.renditions.resolveVariantFile(BASE_ID, 540, "segment-00027.mp4", VIEWER);
150
+ manager.renditions.viewerPlays(BASE_ID, VIEWER, 540, 108);
144
151
 
145
- const known = base.viewers.get(VIEWER);
152
+ const known = manager.viewers.forOutput(base).get(VIEWER);
146
153
  assert.ok(known, "the picture is the one id the browser holds — a viewer is never dropped from it");
147
154
  assert.deepEqual(
148
155
  known.audio,
@@ -166,16 +173,16 @@ test("a viewer leaving is subtracted from every output, and one nobody is left w
166
173
  "one viewer, one object, and it knows all three outputs it is watching"
167
174
  );
168
175
 
169
- await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
176
+ await manager.lifecycle.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
170
177
 
171
- assert.equal(manager.sessionsById.has(BASE_ID), false, "the picture goes with its last consumer");
178
+ assert.equal(manager.outputs.has(BASE_ID), false, "the picture goes with its last consumer");
172
179
  assert.equal(
173
- manager.sessionsById.has(STEP_ID),
180
+ manager.outputs.has(STEP_ID),
174
181
  false,
175
182
  "and so does the quality step: nobody is watching it, and no one outside this class knows its id"
176
183
  );
177
184
  assert.equal(
178
- manager.sessionsById.has(AUDIO_ID),
185
+ manager.outputs.has(AUDIO_ID),
179
186
  false,
180
187
  "and the soundtrack, for the same reason — it used to sit for half an hour holding an encoder, a directory and a claim on the torrent"
181
188
  );
@@ -186,7 +193,6 @@ test("an output somebody else is still watching is kept when one viewer leaves",
186
193
  const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
187
194
  t.after(() => rm(dirPath, { recursive: true, force: true }));
188
195
  const second = "viewer-two";
189
- base.consumers.add(second);
190
196
  manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
191
197
  manager.viewers.of(step, VIEWER);
192
198
  manager.viewers.of(audio, VIEWER);
@@ -195,25 +201,25 @@ test("an output somebody else is still watching is kept when one viewer leaves",
195
201
  manager.viewers.of(base, second).position = { segment: 25, seconds: 100, at: Date.now() };
196
202
  manager.viewers.of(audio, second);
197
203
 
198
- await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the first viewer left");
204
+ await manager.lifecycle.releaseSessionConsumer(BASE_ID, VIEWER, "the first viewer left");
199
205
 
200
- assert.equal(manager.sessionsById.has(BASE_ID), true, "the picture stays: it still has a consumer");
206
+ assert.equal(manager.outputs.has(BASE_ID), true, "the picture stays: it still has a consumer");
201
207
  assert.equal(
202
- manager.sessionsById.has(AUDIO_ID),
208
+ manager.outputs.has(AUDIO_ID),
203
209
  true,
204
210
  "and the soundtrack stays, because having no listeners is what kills it and it has one"
205
211
  );
206
212
  assert.equal(
207
- base.viewers.has(VIEWER),
213
+ manager.viewers.forOutput(base).has(VIEWER),
208
214
  false,
209
215
  "the viewer who left is gone from the picture"
210
216
  );
211
217
  assert.equal(
212
- audio.viewers.has(VIEWER),
218
+ manager.viewers.forOutput(audio).has(VIEWER),
213
219
  false,
214
220
  "and from the soundtrack, which is the half of the relation the viewer holds"
215
221
  );
216
- assert.equal(step.viewers.size, 0, "the step they had is watched by nobody");
222
+ assert.equal(manager.viewers.forOutput(step).size, 0, "the step they had is watched by nobody");
217
223
  });
218
224
 
219
225
  test("nothing is left wanting production once the last viewer has left", async (t) => {
@@ -223,7 +229,7 @@ test("nothing is left wanting production once the last viewer has left", async (
223
229
  manager.viewers.of(step, VIEWER);
224
230
  manager.viewers.of(audio, VIEWER);
225
231
 
226
- await manager.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
232
+ await manager.lifecycle.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
227
233
 
228
234
  // NOTHING HAS TO BE RELEASED, and that is the point of the shape. A claim per
229
235
  // viewer per output used to be held in the encoding layer and taken back one
@@ -232,20 +238,20 @@ test("nothing is left wanting production once the last viewer has left", async (
232
238
  // with nobody's name in it, rebuilt from whoever is watching; a viewer who has
233
239
  // left is simply not in the next one.
234
240
  for (const id of [BASE_ID, STEP_ID, AUDIO_ID]) {
235
- const session = manager.sessionsById.get(id);
241
+ const session = manager.outputs.get(id);
236
242
  assert.equal(session, undefined, `${id.slice(0, 8)} is let go with its last viewer`);
237
243
  }
238
- manager.planEncodersNow();
244
+ manager.encodeRuns.planEncodersNow();
239
245
  for (const address of [base.outputKey, step.outputKey, audio.outputKey]) {
240
246
  assert.deepEqual(
241
- manager.encodeOrchestrator.demand.mapOn(address),
247
+ manager.encodeOrchestrator.wantedSegmentsOn(address),
242
248
  [],
243
249
  "and no output is left asking for an encoder"
244
250
  );
245
251
  }
246
252
  });
247
253
 
248
- test("the two indexes of one relation are written together", () => {
254
+ test("the reverse view is derived from the relation stored on the viewer", () => {
249
255
  const viewers = new Viewers();
250
256
  const picture = { id: "picture" };
251
257
  const soundtrack = { id: "soundtrack" };
@@ -254,24 +260,26 @@ test("the two indexes of one relation are written together", () => {
254
260
  viewers.of(soundtrack, VIEWER);
255
261
  assert.equal(viewers.of(soundtrack, VIEWER), viewer, "one person is one object, whatever they are watching");
256
262
  assert.deepEqual([...viewer.outputs].sort(), ["picture", "soundtrack"]);
257
- assert.equal(picture.viewers.get(VIEWER), viewer);
263
+ assert.equal(viewers.forOutput(picture).get(VIEWER), viewer);
258
264
 
259
265
  viewers.leaves(soundtrack, VIEWER);
260
- assert.deepEqual([...viewer.outputs], ["picture"], "both directions go together");
261
- assert.equal(soundtrack.viewers.has(VIEWER), false);
266
+ assert.deepEqual([...viewer.outputs], ["picture"], "the stored relation changes once");
267
+ assert.equal(viewers.forOutput(soundtrack).has(VIEWER), false);
262
268
  assert.equal(viewers.size, 1, "and the person is still known, because they are still watching something");
263
269
 
264
270
  viewers.leaves(picture, VIEWER);
265
271
  assert.equal(viewers.size, 0, "watching nothing, they are forgotten");
266
272
  });
267
273
 
268
- test("a viewer that cannot name itself belongs to the session that met it", () => {
274
+ test("a request that names nobody makes no viewer", () => {
275
+ // A nameless viewer used to belong to whichever output met it: the same
276
+ // person was a different object on each output, and two nameless people on
277
+ // one output were one object. Every viewer has a name now.
269
278
  const viewers = new Viewers();
270
279
  const oneFilm = { id: "one" };
271
- const another = { id: "another" };
272
280
 
273
- const first = viewers.of(oneFilm, "");
274
- const second = viewers.of(another, "");
275
- assert.notEqual(first, second, "two anonymous viewers of two films are not one person");
276
- assert.equal(viewers.size, 0, "and neither is in the registry, which is keyed by a name they do not have");
281
+ assert.throws(() => viewers.of(oneFilm, ""), TypeError);
282
+ assert.equal(viewers.size, 0, "nothing was registered");
283
+ assert.equal(viewers.forOutput(oneFilm).size, 0, "and nobody watches the output");
284
+ assert.equal(viewers.getForOutput(oneFilm, ""), null);
277
285
  });
@@ -17,12 +17,13 @@ import assert from "node:assert/strict";
17
17
  import { mkdtemp, rm } from "node:fs/promises";
18
18
  import os from "node:os";
19
19
  import path from "node:path";
20
- import { HlsSessionManager } from "../services/hls-session-manager.js";
21
- import { SourceFile } from "../services/source/SourceFile.js";
20
+ import { wireOutputs } from "../services/server/wire-outputs.js";
21
+ import { SourceFile } from "../services/media/SourceFile.js";
22
22
  import { Viewers } from "../services/viewer/Viewers.js";
23
+ import { outputSpec } from "./helpers/output-spec.js";
23
24
 
24
- const PICTURE = "aaaaaaaa-0000-4000-8000-000000000001";
25
- const SOUND = "aaaaaaaa-0000-4000-8000-000000000002";
25
+ const PICTURE = "aaaaaaaa00004000";
26
+ const SOUND = "aaaaaaaa00004001";
26
27
 
27
28
  /**
28
29
  * @param {string} id
@@ -32,13 +33,13 @@ const SOUND = "aaaaaaaa-0000-4000-8000-000000000002";
32
33
  function outputOn(id, dirPath) {
33
34
  return {
34
35
  id,
36
+ spec: outputSpec(),
35
37
  dirPath,
36
38
  state: "live",
37
39
  file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
38
40
  get inputFile() { return this.file; },
39
41
  get audioFile() { return this.file; },
40
- consumers: new Set(),
41
- viewers: new Map(),
42
+ claims: new Set(),
42
43
  lastAccessedAt: Date.now()
43
44
  };
44
45
  }
@@ -46,7 +47,7 @@ function outputOn(id, dirPath) {
46
47
  test("asking for a viewer of an output is that viewer watching it", () => {
47
48
  let changes = 0;
48
49
  const viewers = new Viewers({ onChange: () => { changes += 1; } });
49
- const output = { id: "out-1", viewers: new Map() };
50
+ const output = { id: "out-1" };
50
51
 
51
52
  const first = viewers.of(output, "someone");
52
53
  assert.equal(changes, 1, "a new relation is a change");
@@ -55,7 +56,7 @@ test("asking for a viewer of an output is that viewer watching it", () => {
55
56
  viewers.of(output, "someone");
56
57
  assert.equal(changes, 1, "asking again about the same output changes nothing");
57
58
 
58
- const second = { id: "out-2", viewers: new Map() };
59
+ const second = { id: "out-2" };
59
60
  viewers.of(second, "someone");
60
61
  assert.equal(changes, 2, "a second output is a change");
61
62
  assert.equal(viewers.get("someone")?.outputs.size, 2);
@@ -63,20 +64,18 @@ test("asking for a viewer of an output is that viewer watching it", () => {
63
64
 
64
65
  test("a connection closing takes the person off every output at once", () => {
65
66
  const viewers = new Viewers();
66
- const picture = { id: "picture", viewers: new Map() };
67
- const sound = { id: "sound", viewers: new Map() };
68
- const byId = new Map([["picture", picture], ["sound", sound]]);
69
-
67
+ const picture = { id: "picture" };
68
+ const sound = { id: "sound" };
70
69
  viewers.of(picture, "watcher");
71
70
  viewers.of(sound, "watcher");
72
71
  viewers.of(picture, "other");
73
72
 
74
- const left = viewers.hasGone("watcher", (id) => byId.get(id) ?? null);
73
+ const left = viewers.hasGone("watcher");
75
74
 
76
75
  assert.deepEqual(left.sort(), ["picture", "sound"], "both, not the one the browser holds an id for");
77
- assert.equal(picture.viewers.has("watcher"), false);
78
- assert.equal(sound.viewers.has("watcher"), false);
79
- assert.equal(picture.viewers.has("other"), true, "and nobody else goes with them");
76
+ assert.equal(viewers.forOutput(picture).has("watcher"), false);
77
+ assert.equal(viewers.forOutput(sound).has("watcher"), false);
78
+ assert.equal(viewers.forOutput(picture).has("other"), true, "and nobody else goes with them");
80
79
  assert.equal(viewers.get("watcher"), null, "the registry does not keep what it has let go");
81
80
  });
82
81
 
@@ -85,7 +84,7 @@ test("a viewer whose connection closed is let go of every output they were watch
85
84
  t.after(async () => {
86
85
  await rm(dirPath, { recursive: true, force: true });
87
86
  });
88
- const manager = new HlsSessionManager({
87
+ const manager = wireOutputs({
89
88
  enabled: true,
90
89
  ffmpegBin: "ffmpeg",
91
90
  localBindHost: "127.0.0.1",
@@ -94,26 +93,24 @@ test("a viewer whose connection closed is let go of every output they were watch
94
93
 
95
94
  const picture = outputOn(PICTURE, dirPath);
96
95
  const sound = outputOn(SOUND, dirPath);
97
- picture.consumers.add("watcher");
98
- sound.consumers.add("watcher");
99
- manager.sessionsById.set(PICTURE, picture);
100
- manager.sessionsById.set(SOUND, sound);
96
+ manager.outputs.set(PICTURE, picture);
97
+ manager.outputs.set(SOUND, sound);
101
98
  manager.viewers.of(picture, "watcher");
102
99
  manager.viewers.of(sound, "watcher");
103
100
 
104
- const released = await manager.viewerHasGone("watcher", "the connection closed");
101
+ const released = await manager.lifecycle.viewerHasGone("watcher", "the connection closed");
105
102
 
106
103
  assert.equal(released, 2, "the picture and the soundtrack, from one statement");
107
104
  assert.equal(manager.viewers.get("watcher"), null);
108
105
  });
109
106
 
110
107
  test("a viewer nobody knows costs nothing to let go of", async () => {
111
- const manager = new HlsSessionManager({
108
+ const manager = wireOutputs({
112
109
  enabled: true,
113
110
  ffmpegBin: "ffmpeg",
114
111
  localBindHost: "127.0.0.1",
115
112
  localPort: 9090
116
113
  });
117
- assert.equal(await manager.viewerHasGone("never-seen"), 0);
118
- assert.equal(await manager.viewerHasGone(""), 0);
114
+ assert.equal(await manager.lifecycle.viewerHasGone("never-seen"), 0);
115
+ assert.equal(await manager.lifecycle.viewerHasGone(""), 0);
119
116
  });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file Opening an output admits and places its viewer in one synchronous call.
3
+ */
4
+
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { EncodeAdmission } from "../services/encode/EncodeAdmission.js";
8
+ import { ViewerRequests } from "../services/server/ViewerRequests.js";
9
+
10
+ test("the first opening holds capacity before a concurrent opening is admitted", async () => {
11
+ const records = new Map();
12
+ const outputCosts = new Map([
13
+ ["source-a:0", 0.6],
14
+ ["source-b:0", 0.6]
15
+ ]);
16
+ const viewers = {
17
+ get: () => null,
18
+ forOutput(output) {
19
+ return new Set(records.get(output.outputKey)?.keys() ?? []);
20
+ },
21
+ of(output, consumerId) {
22
+ let viewersOnOutput = records.get(output.outputKey);
23
+ if (!viewersOnOutput) {
24
+ viewersOnOutput = new Map();
25
+ records.set(output.outputKey, viewersOnOutput);
26
+ }
27
+ let viewer = viewersOnOutput.get(consumerId);
28
+ if (!viewer) {
29
+ viewer = {
30
+ position: null,
31
+ linkReading: () => null,
32
+ noteVisiblePicture: () => {}
33
+ };
34
+ viewersOnOutput.set(consumerId, viewer);
35
+ }
36
+ return viewer;
37
+ },
38
+ watchedAddresses() {
39
+ return new Set(
40
+ [...records]
41
+ .filter(([, members]) => [...members.values()].some((viewer) => viewer.position !== null))
42
+ .map(([address]) => address)
43
+ );
44
+ }
45
+ };
46
+ const admission = new EncodeAdmission({
47
+ liveRunsByAddress: () => new Map(),
48
+ preparedAddresses: () => new Set(),
49
+ watchedAddresses: () => viewers.watchedAddresses(),
50
+ loadOf: (address) => {
51
+ const costSec = outputCosts.get(address);
52
+ return costSec === undefined ? null : { costSec, fileKey: address, fileCostSec: 0 };
53
+ },
54
+ availability: () => ({ share: 1, known: true }),
55
+ finished: () => false
56
+ });
57
+ const opening = {
58
+ async open({ sourceKey, fileIndex, claim }) {
59
+ const output = { id: `${sourceKey}:${fileIndex}`, outputKey: `${sourceKey}:${fileIndex}` };
60
+ const answer = claim(output);
61
+ if (!answer.admitted) {
62
+ const error = new Error(answer.reason);
63
+ error.code = "OUTPUT_NO_CAPACITY";
64
+ throw error;
65
+ }
66
+ return { output, existed: false, audio: null, verdict: null };
67
+ }
68
+ };
69
+ const requests = new ViewerRequests({
70
+ opening,
71
+ viewers,
72
+ admitsWatching: (output) => admission.admitsWatching(output.outputKey),
73
+ outputTimes: { segmentIndexForTime: (_output, seconds) => Math.floor(seconds / 4) },
74
+ planEncodersSoon: () => {},
75
+ outputs: { touch: () => {} },
76
+ noteServingVerdict: () => {},
77
+ waitUntilReady: async () => {}
78
+ });
79
+
80
+ const first = requests.createOrGetSession({ sourceKey: "source-a", fileIndex: 0, consumerId: "viewer-a" });
81
+ const second = requests.createOrGetSession({ sourceKey: "source-b", fileIndex: 0, consumerId: "viewer-b" });
82
+ const [firstResult, secondResult] = await Promise.allSettled([first, second]);
83
+
84
+ assert.equal(firstResult.status, "fulfilled");
85
+ assert.equal(secondResult.status, "rejected");
86
+ assert.match(secondResult.reason.message, /would make 0\.83x/);
87
+ assert.equal(viewers.forOutput({ outputKey: "source-a:0" }).has("viewer-a"), true);
88
+ assert.equal(viewers.forOutput({ outputKey: "source-b:0" }).has("viewer-b"), false);
89
+ });