@torrent-tv/proxy 2.85.1 → 2.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. package/CHANGELOG.md +2138 -1909
  2. package/CLAUDE.md +318 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/container-architecture.md +218 -213
  7. package/docs/download-architecture.md +7 -7
  8. package/docs/encode-architecture.md +287 -11
  9. package/docs/encode-run-state.md +2 -2
  10. package/docs/logs.md +97 -97
  11. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  12. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  13. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  14. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  15. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  18. package/openspec/changes/cold-start/design.md +1 -1
  19. package/openspec/changes/disk-cap/proposal.md +1 -1
  20. package/openspec/changes/proxy-observability/proposal.md +2 -2
  21. package/openspec/changes/track-selection/proposal.md +1 -1
  22. package/openspec/changes/transcode-quality/proposal.md +1 -1
  23. package/package.json +2 -2
  24. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  25. package/research/double-spawn-2026-09-10.md +171 -171
  26. package/routes/api/playback-plan/post.js +2 -2
  27. package/routes/api/sources/files/get.js +1 -1
  28. package/routes/api/sources/stats/get.js +1 -1
  29. package/routes/api/sources/warm/post.js +10 -3
  30. package/routes/api/subtitles/get.js +230 -230
  31. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  32. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  33. package/routes/api/transcode-sessions/post.js +160 -139
  34. package/routes/api/transcode-sessions/progress/get.js +3 -3
  35. package/routes/api/transcode-sessions/release/post.js +3 -3
  36. package/routes/api/transcode-sessions/seek/post.js +12 -3
  37. package/routes/stream/get.js +2 -2
  38. package/routes/transcode/audio-file/get.js +15 -6
  39. package/routes/transcode/audio-warm/get.js +8 -5
  40. package/routes/transcode/session-file/get.js +429 -268
  41. package/routes/transcode/variant-file/get.js +47 -7
  42. package/routes/transcode/variant-warm/get.js +18 -6
  43. package/scripts/render-run-graph.js +1 -1
  44. package/server.js +720 -465
  45. package/services/encode/CoverageMap.js +428 -428
  46. package/services/encode/CushionReport.js +345 -0
  47. package/services/encode/EncodeAdmission.js +332 -0
  48. package/services/{orchestrators → encode}/EncodeOrchestrator.js +285 -17
  49. package/services/encode/EncodeRun.js +16 -9
  50. package/services/encode/EncodeRuns.js +1219 -0
  51. package/services/encode/Encoder.js +18 -2
  52. package/services/encode/EncoderSelection.js +38 -0
  53. package/services/encode/LocalObservations.js +359 -0
  54. package/services/encode/NvencEncoder.js +5 -0
  55. package/services/encode/OutputOpening.js +745 -0
  56. package/services/encode/OutputTimes.js +579 -0
  57. package/services/encode/Renditions.js +2162 -0
  58. package/services/encode/RunProgress.js +84 -0
  59. package/services/encode/SoftwareEncoder.js +12 -7
  60. package/services/encode/args.js +402 -186
  61. package/services/encode/calibration.js +361 -0
  62. package/services/encode/contention.js +128 -128
  63. package/services/encode/encode-run-state.js +16 -20
  64. package/services/encode/fingerprint.js +322 -0
  65. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  66. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  67. package/services/encode/output/EncodedOutput.js +57 -0
  68. package/services/encode/output/Output.js +131 -0
  69. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  70. package/services/encode/output/OutputSpec.js +390 -0
  71. package/services/{output → encode/output}/Timeline.js +12 -37
  72. package/services/encode/output/index.js +20 -0
  73. package/services/encode/output/ladder.js +32 -0
  74. package/services/{output → encode/output}/playlists.js +3 -0
  75. package/services/encode/output-key-format.js +21 -0
  76. package/services/encode/quality/EncodeCost.js +1083 -0
  77. package/services/encode/quality/HostLoad.js +525 -0
  78. package/services/encode/quality/HostTimings.js +249 -0
  79. package/services/encode/quality/OutputQualityState.js +28 -0
  80. package/services/encode/quality/QualityController.js +931 -0
  81. package/services/encode/quality/QualityOffer.js +416 -0
  82. package/services/encode/quality/drain-threat.js +47 -0
  83. package/services/encode/quality/link-budget.js +254 -0
  84. package/services/encode/quality/output-format.js +266 -0
  85. package/services/encode/quality/serving-output.js +216 -0
  86. package/services/encode/quality/visible-rung.js +57 -0
  87. package/services/encode/run-command.js +23 -20
  88. package/services/encode/run-inputs.js +42 -0
  89. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  90. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  91. package/services/encode/segment-formats/init-compat.js +324 -0
  92. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  93. package/services/encode/throughput.js +97 -0
  94. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  95. package/services/media/KeyframeTables.js +316 -0
  96. package/services/{source → media}/SourceFile.js +7 -55
  97. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  98. package/services/media/SubtitleOrchestrator.js +167 -0
  99. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  100. package/services/{container → media/container}/AviContainer.js +266 -266
  101. package/services/{container → media/container}/Container.js +596 -601
  102. package/services/media/container/ContainerFactory.js +56 -0
  103. package/services/media/container/KeyframeTable.js +272 -0
  104. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  105. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  106. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  107. package/services/media/keyframe-probe.js +157 -0
  108. package/services/media/media-info-probe.js +93 -0
  109. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  110. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  111. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  112. package/services/server/OutputLifecycle.js +418 -0
  113. package/services/server/SegmentServing.js +1146 -0
  114. package/services/server/ViewerRequests.js +522 -0
  115. package/services/server/controllers/PlaybackController.js +36 -0
  116. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  117. package/services/server/wire-outputs.js +958 -0
  118. package/services/storage/Diagnostics.js +202 -0
  119. package/services/storage/MachineBudget.js +235 -0
  120. package/services/{piece-store → storage}/allowance.js +107 -107
  121. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  122. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  123. package/services/{disk → storage}/free.js +33 -0
  124. package/services/storage/machine-memory.js +56 -0
  125. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  126. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  127. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  128. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  129. package/services/storage/wire.js +175 -0
  130. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  131. package/services/{download → torrent/download}/registry.js +2 -2
  132. package/services/torrent/files.js +1 -1
  133. package/services/torrent/piece-store-of.js +29 -0
  134. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  135. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  136. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  137. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  138. package/services/torrent/worker/held-bytes.js +155 -0
  139. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  140. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  141. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  142. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  143. package/services/torrent/worker/resume-warm.js +107 -0
  144. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  145. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  146. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  147. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  148. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  149. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  150. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  151. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  152. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  153. package/services/viewer/Assignments.js +340 -0
  154. package/services/{priority → viewer}/PriorityMap.js +390 -390
  155. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  156. package/services/viewer/Viewer.js +657 -489
  157. package/services/viewer/Viewers.js +368 -257
  158. package/services/viewer/active-output.js +37 -54
  159. package/services/viewer/audio-start.js +46 -0
  160. package/services/viewer/buffer-trend.js +81 -0
  161. package/services/viewer/choices.js +647 -0
  162. package/services/viewer/positions.js +126 -120
  163. package/services/viewer/report-intake.js +46 -44
  164. package/test/asset-paths.test.js +28 -0
  165. package/test/audio-inventory.test.js +3 -3
  166. package/test/audio-track-bitrate.test.js +40 -0
  167. package/test/auto-quality-step.test.js +707 -518
  168. package/test/available-share.test.js +1 -1
  169. package/test/background-fill.test.js +1 -1
  170. package/test/client-log-file.test.js +110 -110
  171. package/test/completed-files.test.js +1 -1
  172. package/test/concurrent-cost.test.js +13 -3
  173. package/test/container-media-info.test.js +3 -3
  174. package/test/core-dumps.test.js +1 -1
  175. package/test/coverage-follows-the-disk.test.js +40 -8
  176. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  177. package/test/cut-times-timeline.test.js +2 -2
  178. package/test/cuts-follow-published-grid.test.js +9 -8
  179. package/test/data-channel-frame.test.js +1 -1
  180. package/test/dead-torrent-handle.test.js +1 -1
  181. package/test/declared-rates.test.js +2 -2
  182. package/test/declared-tracks.test.js +2 -2
  183. package/test/decode-cost-fit.test.js +1 -1
  184. package/test/decode-cost.test.js +34 -25
  185. package/test/delivery-probe.test.js +1 -1
  186. package/test/demand-register.test.js +4 -4
  187. package/test/dht-bootstrap.test.js +1 -1
  188. package/test/drain-threat.test.js +65 -0
  189. package/test/duplicate-add.test.js +1 -1
  190. package/test/encode-admission.test.js +146 -0
  191. package/test/encode-cost.test.js +199 -10
  192. package/test/encode-orchestrator.test.js +39 -1
  193. package/test/encode-plan-viewers.test.js +2 -2
  194. package/test/encode-run.test.js +2 -2
  195. package/test/encode-slope.test.js +1 -1
  196. package/test/encoded-output.test.js +39 -0
  197. package/test/encoder-readings.test.js +1 -1
  198. package/test/every-claimant-is-told-its-share.test.js +152 -0
  199. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  200. package/test/failed-start-breaker.test.js +81 -30
  201. package/test/fastest-wires.test.js +1 -1
  202. package/test/ffmpeg-stream-counts.test.js +1 -1
  203. package/test/file-edges.test.js +3 -3
  204. package/test/fmp4-self-contained.test.js +1 -1
  205. package/test/fmp4-timestamps.test.js +1 -1
  206. package/test/fmp4-track-completeness.test.js +1 -1
  207. package/test/format-keyed-outputs.test.js +78 -0
  208. package/test/health-metrics.test.js +3 -2
  209. package/test/held-request-width.test.js +22 -23
  210. package/test/helpers/encode-run.js +136 -136
  211. package/test/helpers/held-file.js +29 -0
  212. package/test/helpers/manager.js +57 -0
  213. package/test/helpers/output-spec.js +63 -0
  214. package/test/host-load.test.js +1 -1
  215. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  216. package/test/init-compat.test.js +219 -0
  217. package/test/input-lost-quiets-the-plan.test.js +1 -1
  218. package/test/input-unavailable.test.js +1 -1
  219. package/test/keeping-period.test.js +4 -4
  220. package/test/keyframe-index-accuracy.test.js +97 -45
  221. package/test/keyframe-table-budget.test.js +28 -33
  222. package/test/keyframes-belong-to-the-file.test.js +174 -57
  223. package/test/learned-median.test.js +1 -1
  224. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  225. package/test/limit-row-by-area.test.js +60 -0
  226. package/test/link-budget.test.js +111 -0
  227. package/test/link-probe.test.js +56 -56
  228. package/test/local-encoding-data.test.js +273 -0
  229. package/test/look-ahead-coverage.test.js +1 -1
  230. package/test/manager-surface.test.js +294 -0
  231. package/test/matroska-blocks.test.js +0 -0
  232. package/test/matroska-cues-track.test.js +1 -1
  233. package/test/memory-budget.test.js +2 -2
  234. package/test/mp4-composition-times.test.js +0 -0
  235. package/test/mp4-subtitles.test.js +1 -1
  236. package/test/one-authority.test.js +45 -41
  237. package/test/one-budget.test.js +149 -0
  238. package/test/one-output-one-session.test.js +204 -0
  239. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  240. package/test/one-serving-output-per-height.test.js +458 -0
  241. package/test/orchestrator-wired.test.js +46 -45
  242. package/test/output-catalog.test.js +39 -0
  243. package/test/output-shape.test.js +4 -51
  244. package/test/output-spec.test.js +251 -157
  245. package/test/output-speed.test.js +17 -15
  246. package/test/outputs-being-prepared.test.js +40 -0
  247. package/test/packet-witness-ring.test.js +2 -2
  248. package/test/packet-witness.test.js +1 -1
  249. package/test/piece-disk-store.test.js +2 -2
  250. package/test/piece-from-whole-file.test.js +1 -1
  251. package/test/piece-lru.test.js +1 -1
  252. package/test/piece-reader.test.js +2 -2
  253. package/test/piece-store-eviction.test.js +1 -1
  254. package/test/piece-store-never-refuses.test.js +1 -1
  255. package/test/piece-store-reservations.test.js +1 -1
  256. package/test/piece-store-slow-disk.test.js +1 -1
  257. package/test/piece-tail.test.js +1 -1
  258. package/test/plan-host-timings.test.js +1 -1
  259. package/test/plan-is-steady.test.js +225 -225
  260. package/test/priority-map-download.test.js +4 -4
  261. package/test/priority-map-emptied.test.js +204 -205
  262. package/test/priority-map-per-output.test.js +215 -209
  263. package/test/priority-map.test.js +1 -1
  264. package/test/probe-wedge-certainty.test.js +1 -1
  265. package/test/produced-copy-choice.test.js +15 -60
  266. package/test/quality-offer.test.js +162 -0
  267. package/test/quality-variants.test.js +1229 -1167
  268. package/test/rate-control-in-the-output.test.js +180 -0
  269. package/test/read-survives-withdrawal.test.js +2 -2
  270. package/test/read-window.test.js +10 -3
  271. package/test/resume-warm.test.js +1 -1
  272. package/test/run-inputs.test.js +80 -0
  273. package/test/run-position-follows-published-grid.test.js +13 -11
  274. package/test/run-progress.test.js +65 -0
  275. package/test/same-height-switch.test.js +664 -0
  276. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  277. package/test/seek-landing.test.js +13 -13
  278. package/test/seek-target-not-superseded.test.js +28 -29
  279. package/test/segment-closed-on-both-branches.test.js +96 -0
  280. package/test/segment-cut-times.test.js +1 -1
  281. package/test/segment-hold-supersede.test.js +28 -35
  282. package/test/segment-judged-by-its-own-header.test.js +221 -0
  283. package/test/segment-serve-wiring.test.js +130 -39
  284. package/test/segment-store-eviction.test.js +22 -7
  285. package/test/segment-store.test.js +98 -14
  286. package/test/segment-true-start.test.js +1 -1
  287. package/test/segments-are-shared.test.js +17 -16
  288. package/test/send-chunk-bytes.test.js +1 -1
  289. package/test/serving-existing-output.test.js +102 -0
  290. package/test/serving-output.test.js +102 -0
  291. package/test/shared-piece-store.test.js +1 -1
  292. package/test/source-file.test.js +1 -1
  293. package/test/stale-request-after-seek.test.js +17 -21
  294. package/test/startup-readings.test.js +10 -5
  295. package/test/subtitle-cue-framing.test.js +5 -5
  296. package/test/subtitle-cue-source.test.js +36 -38
  297. package/test/subtitle-cue-walk.test.js +11 -10
  298. package/test/subtitle-defaults.test.js +1 -1
  299. package/test/subtitle-language.test.js +4 -4
  300. package/test/subtitle-track-numbering.test.js +5 -4
  301. package/test/supply-margin.test.js +1 -1
  302. package/test/swarm-follows-readers.test.js +3 -3
  303. package/test/swarm-reach.test.js +1 -1
  304. package/test/swarm-selection.test.js +3 -3
  305. package/test/tail-duplication.test.js +1 -1
  306. package/test/the-channel-has-both-ends.test.js +91 -0
  307. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  308. package/test/thread-pool.test.js +3 -3
  309. package/test/timeline.test.js +1 -12
  310. package/test/torrent-cost.test.js +1 -1
  311. package/test/tracks-begin-together.test.js +38 -24
  312. package/test/tunnel-renewal.test.js +1 -1
  313. package/test/two-viewers-one-picture.test.js +120 -87
  314. package/test/upload-hurry.test.js +3 -3
  315. package/test/usrsctp-state.test.js +1 -1
  316. package/test/video-facts.test.js +1 -1
  317. package/test/viewer-assignments.test.js +143 -0
  318. package/test/viewer-outputs.test.js +60 -52
  319. package/test/viewer-presence.test.js +22 -25
  320. package/test/viewer-requests-admission.test.js +89 -0
  321. package/test/viewer-states.test.js +189 -172
  322. package/test/viewer-subtitle-subscription.test.js +1 -1
  323. package/test/viewer.test.js +139 -136
  324. package/test/viewing-generations.test.js +390 -0
  325. package/test/visible-rung.test.js +37 -0
  326. package/test/wait-ledger.test.js +1 -1
  327. package/test/webrtc-shim.test.js +3 -3
  328. package/test/wedge-certainty.test.js +2 -2
  329. package/test/withdraw-piece-claim.test.js +2 -2
  330. package/test/worker-channel.test.js +1 -31
  331. package/utils/client-log-file.js +151 -151
  332. package/scripts/read-container-index.mjs +0 -108
  333. package/services/container/ContainerFactory.js +0 -122
  334. package/services/controllers/PlaybackController.js +0 -33
  335. package/services/disk/DiskSpace.js +0 -150
  336. package/services/disk/wire.js +0 -61
  337. package/services/hls-session-manager.js +0 -9689
  338. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  339. package/services/output/Output.js +0 -94
  340. package/services/output/OutputSpec.js +0 -202
  341. package/services/output/index.js +0 -1
  342. package/services/output/ladder.js +0 -26
  343. package/services/produced-index.js +0 -222
  344. package/services/quality/EncodeCost.js +0 -555
  345. package/services/torrent-worker/container-tracks.js +0 -414
  346. package/test/produced-index.test.js +0 -142
  347. /package/{services → bin}/thread-pool.js +0 -0
  348. /package/services/{available-share.js → encode/available-share.js} +0 -0
  349. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  350. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  351. /package/services/{host-load.js → encode/host-load.js} +0 -0
  352. /package/services/{output → encode/output}/cut-grid.js +0 -0
  353. /package/services/{output → encode/output}/rates.js +0 -0
  354. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  355. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  356. /package/services/{container → media/container}/ebml-reader.js +0 -0
  357. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  358. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  359. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  360. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  361. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  362. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/index.js +0 -0
  364. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  365. /package/services/{disk → storage}/keep.js +0 -0
  366. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  367. /package/services/{disk → storage}/returns.js +0 -0
  368. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  369. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  370. /package/services/{demand → torrent/demand}/Window.js +0 -0
  371. /package/services/{demand → torrent/demand}/index.js +0 -0
  372. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  373. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  374. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  375. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  376. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  377. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  378. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  379. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  380. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  381. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -1,268 +1,429 @@
1
- import { logger } from "../../../utils/logger.js";
2
- import { bandOf, waits } from "../../../services/priority/WaitLedger.js";
3
-
4
- /**
5
- * How long a request for a not-yet-produced file is held before answering with
6
- * a retryable 503.
7
- *
8
- * MEASUREMENT MODE (2026-08-02): deliberately far above any plausible player
9
- * deadline, so OUR limit never fires first. Whatever ends the wait is then the
10
- * player's own behaviour — which is exactly what we need to observe. The
11
- * `[hold]` log line at the call site records, per request, whether the segment
12
- * arrived, whether we gave up, or whether the CLIENT aborted, and after how
13
- * long.
14
- *
15
- * The previous value (2 s) was chosen to dodge a reported iOS AVPlayer ~3.5 s
16
- * response-header deadline. That deadline never appears in our own logs (no
17
- * -12889 across six hours of production logs), and every reference project
18
- * holds rather than refusing: Jellyfin and hls-vod-too hold unbounded,
19
- * hls-media-server holds 10 s. The early refusal is what made the player probe
20
- * scattered positions, which then steered the encoder off target. Choose the
21
- * final value from what this measurement shows, not from a number read
22
- * elsewhere.
23
- */
24
- const SEGMENT_WAIT_MS = 60_000;
25
-
26
- /**
27
- * Serve HLS playlist and segment files from an active transcode session.
28
- *
29
- * Briefly waits for the requested file to appear, then answers with a
30
- * retryable 503 rather than holding the connection, so clients — in
31
- * particular iOS's native HLS player — never hit their own response
32
- * deadline while a segment is still being produced.
33
- *
34
- * GET /transcode/:sessionId/:fileName
35
- *
36
- * @param {import("fastify").FastifyRequest} req
37
- * @param {import("fastify").FastifyReply} reply
38
- * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
39
- * @returns {Promise<void>}
40
- */
41
- export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
42
- const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
43
- const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
44
- return serveSessionFile(req, reply, { hlsSessionManager, sessionId, fileName });
45
- }
46
-
47
- /**
48
- * Serve one playlist or segment from a named session.
49
- *
50
- * Split from the route above because the variant route
51
- * (`/transcode/:sessionId/v/:height/:fileName`) serves the same files from
52
- * another session of the same family, and must hold, log and answer them
53
- * identically — a switch of quality must not go through a different code path
54
- * from the stream it switches away from.
55
- *
56
- * @param {import("fastify").FastifyRequest} req
57
- * @param {import("fastify").FastifyReply} reply
58
- * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sessionId: string, fileName: string }} params
59
- * @returns {Promise<void>}
60
- */
61
- export async function serveSessionFile(req, reply, { hlsSessionManager, sessionId, fileName }) {
62
- // Which viewer is asking. One session serves everyone watching a copied
63
- // picture, so without it their positions collapse into one and a seek by the
64
- // viewer in front releases the requests held for the viewer behind. Absent on
65
- // a plain HTTP transport, where no loader of ours builds the URL, and then
66
- // the single shared position decides as it always did.
67
- const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
68
- // Hold the request only briefly, then answer "retry" instead of waiting for
69
- // the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
70
- // deadline on RESPONSE HEADERS and raises -12889 ("No response for media
71
- // file") when it passes — it then cancels in-flight requests, probes
72
- // neighbouring positions and can restart the stream from the beginning. That
73
- // is exactly the post-seek "player thrashing" seen in the field, because a
74
- // seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
75
- // to appear. Holding the connection for 30 s (as this did) guaranteed the
76
- // timeout on every seek. A short hold keeps the fast path intact (a ready or
77
- // nearly-ready segment is still served on the first request) while a slow one
78
- // gets a prompt retryable answer, which resets the player's own deadline.
79
- // hls.js is unaffected: it consumes the 503 through its retry policy, whose
80
- // budget the client widens to match (see hls-player.js fragLoadPolicy).
81
- // Instrumented wait. `clientAborted` flips when the player drops the
82
- // connection while we are still holding it — the single most informative
83
- // signal about its real patience, and observable only from this side.
84
- const holdStartedAt = Date.now();
85
- let clientAborted = false;
86
- const onClientAbort = () => { clientAborted = true; };
87
- req.raw.on("close", onClientAbort);
88
-
89
- const result = await waitForSessionFile(
90
- hlsSessionManager,
91
- sessionId,
92
- fileName,
93
- SEGMENT_WAIT_MS,
94
- consumerId
95
- );
96
-
97
- req.raw.off("close", onClientAbort);
98
- const heldMs = Date.now() - holdStartedAt;
99
- if (result.isPlaylist !== true) {
100
- const outcome = clientAborted
101
- ? "client-aborted"
102
- : result.kind === "ok" ? "served" : result.kind;
103
- // AGAINST THE RANK THE MAP GAVE IT. A wait is the only thing that says
104
- // whether the prioritisation is being used well, and this is the one place
105
- // in the proxy where a viewer is measurably waiting for a named segment.
106
- // Recorded even when the segment was served at once: a run of short waits
107
- // at the top rank is what "the urgent zone is being served first" looks
108
- // like, and without them the table would hold only the failures.
109
- const ranked = result.ranked ?? null;
110
- if (ranked) {
111
- waits.note(ranked.address, heldMs, ranked.rank, ranked.topRank);
112
- }
113
- logger.info(
114
- `[hold] ${fileName} ${outcome} after ${heldMs}ms` +
115
- (ranked ? ` (the map wants it ${bandOf(ranked.rank, ranked.topRank)}, rank ${ranked.rank} of ${ranked.topRank})` : "")
116
- );
117
- }
118
-
119
- if (result.kind === "not-found") {
120
- return reply.code(404).send({ error: "Transcode session file was not found." });
121
- }
122
- if (result.kind === "superseded") {
123
- // The viewer moved while this was being held, and this segment is not the
124
- // one they moved TO — that case is kept and waited out, see
125
- // `waitForSessionFile`. Answer at once so the player can ask for where it
126
- // is now; `Retry-After: 0` because there is nothing to wait for.
127
- //
128
- // Named in the log, because a refusal that says only "superseded" is what
129
- // made the 2026-08-18 freeze take a day to explain: the player was refused
130
- // the segment at its own seek target and nothing recorded which segment or
131
- // where the viewer was.
132
- logger.info(
133
- `[hold] ${fileName} refused: the viewer is at ` +
134
- `${hlsSessionManager.viewerPositionOf(sessionId, consumerId).toFixed(1)}s and this is not the segment there`
135
- );
136
- reply.header("Retry-After", "0");
137
- return reply.code(503).send({ error: "Superseded by a seek." });
138
- }
139
- if (result.kind === "warming-up") {
140
- // The segment is still being produced (e.g. just after a seek-restart).
141
- // Return a retryable 503 — never 202, which hls.js cannot consume as a
142
- // media segment — so the player retries the fetch shortly.
143
- reply.header("Retry-After", "1");
144
- // `Retry-After` tells the player to re-request THIS segment after a short
145
- // pause. Without it a bare 503 reads as "nothing here", and the player goes
146
- // looking elsewhere: because our synthetic VOD playlist lists every segment
147
- // of the file, it believes they all exist and SCANS them (field log: one
148
- // user seek produced probes at #617, #717, #732…). That scan is what used
149
- // to steer the encoder off the real target. Whether iOS's native player
150
- // honours the hint is not guaranteed — its behaviour is closed — but this
151
- // is the standard, correct way to say "wait, don't look elsewhere", and
152
- // hls.js already retries the same fragment regardless.
153
- reply.header("Retry-After", "1");
154
- // SAY WHY, to the viewer and not only to the log. The same refusal is
155
- // written into `proxy.log` with the rank the priority map gave this
156
- // segment; the page had no access to that and told the viewer it did not
157
- // know why. Field 2026-09-14: `rank 1 of 100` — nothing was making it —
158
- // printed sixty seconds before the page gave up saying the opposite.
159
- return reply.code(503).send({
160
- error: "Transcode segment is still being produced.",
161
- reason: warmingReason(result.ranked ?? null)
162
- });
163
- }
164
- if (result.kind === "failed") {
165
- return reply.code(500).send({ error: result.message });
166
- }
167
-
168
- if (result.isPlaylist) {
169
- reply.header("Cache-Control", "no-store");
170
- } else {
171
- reply.header("Cache-Control", "public, max-age=60");
172
- }
173
- reply.header("Content-Type", result.contentType);
174
- return reply.send(result.stream);
175
- }
176
-
177
- /**
178
- * Poll `hlsSessionManager.getFileStream()` until the file is available,
179
- * the session fails, or the timeout elapses.
180
- *
181
- * @param {import("../../../services/hls-session-manager.js").HlsSessionManager} hlsSessionManager
182
- * @param {string} sessionId
183
- * @param {string} fileName
184
- * @param {number} timeoutMs
185
- * @param {string} [consumerId] - Which viewer is asking, where the transport
186
- * carries it. Their own position is what decides whether a held request has
187
- * been made pointless by a seek — a session can have several viewers, and
188
- * the seek epoch belongs to all of them.
189
- * @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
190
- */
191
- export async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs, consumerId = "") {
192
- const startedAt = Date.now();
193
- // One sequence number for THIS request, reused by every poll below, so the
194
- // session can tell a newly-arrived request apart from an old one polling
195
- // again — see HlsSessionManager#ensureEncodingFor for the encoder ping-pong
196
- // this prevents when one seek-bar scrub fires several segment requests.
197
- const requestSeq = hlsSessionManager.nextRequestSeq(sessionId);
198
- // The viewer's position when this request was made. A seek makes every held
199
- // request stale — it asks for a segment nobody is going to watch — and hls.js
200
- // keeps only ONE fragment load outstanding, so holding on blocks the request
201
- // the player actually needs now. Measured: 57 s of a 58 s backward seek was
202
- // this wait, and the segment the viewer wanted took 15 ms once it was asked
203
- // for.
204
- let seekEpoch = hlsSessionManager.seekEpoch(sessionId);
205
- /** @type {{ address: string, rank: number, topRank: number } | null} */
206
- let lastRanked = null;
207
- while (Date.now() - startedAt < timeoutMs) {
208
- const result = await hlsSessionManager.getFileStream(sessionId, fileName, {
209
- requestSeq,
210
- consumerId
211
- });
212
- if (result.kind !== "warming-up") {
213
- return result;
214
- }
215
- if (hlsSessionManager.seekEpoch(sessionId) !== seekEpoch) {
216
- // A seek moved the epoch. Whether THIS request is stale depends on which
217
- // segment it asks for: the one the viewer has just landed on races the
218
- // seek notification and would otherwise be refused at the exact moment it
219
- // is needed — measured 2026-08-18, two 503s within 80 ms on the segment
220
- // at the seek target, after which the player never asked for it again.
221
- if (!hlsSessionManager.requestStillWanted(sessionId, fileName, consumerId)) {
222
- return { kind: "superseded" };
223
- }
224
- logger.info(
225
- `[hold] ${fileName} kept across a seek: it is the segment the viewer now needs`
226
- );
227
- seekEpoch = hlsSessionManager.seekEpoch(sessionId);
228
- }
229
- // The rank the map last gave this segment, so a request that runs out of
230
- // patience is still counted against what it was promised. Kept across the
231
- // polls because the timeout path has no result of its own.
232
- lastRanked = result.ranked ?? lastRanked;
233
- await delay(300);
234
- }
235
- return { kind: "warming-up", ranked: lastRanked };
236
- }
237
-
238
- /**
239
- * Resolve after a given number of milliseconds.
240
- *
241
- * @param {number} ms
242
- * @returns {Promise<void>}
243
- */
244
- function delay(ms) {
245
- return new Promise((resolve) => {
246
- setTimeout(resolve, ms);
247
- });
248
- }
249
-
250
- /**
251
- * What to tell the VIEWER when a segment is not there yet.
252
- *
253
- * In their terms — the film, and what is being done about it — never in ours: a
254
- * viewer cannot read a log, does not know what a rank is, and is not helped by
255
- * being told which of our parts is waiting for which other.
256
- *
257
- * @param {{ rank: number, topRank: number } | null} ranked - What the priority
258
- * map thought of this segment while the request was held.
259
- * @returns {string}
260
- */
261
- function warmingReason(ranked) {
262
- if (ranked && ranked.rank < ranked.topRank) {
263
- // The map is working somewhere else in the film. That is the answer the
264
- // failure of 2026-09-14 needed and nobody was given.
265
- return "This part of the film is not being prepared yet — the proxy is working further along.";
266
- }
267
- return "This part of the film is still being prepared.";
268
- }
1
+ import { logger } from "../../../utils/logger.js";
2
+ import { bandOf, waits } from "../../../services/viewer/WaitLedger.js";
3
+
4
+ /**
5
+ * How long a request for a file not yet produced is held: AS LONG AS THE
6
+ * REQUESTER SAYS IT WILL WAIT, and no longer than it stays.
7
+ *
8
+ * The patience is the requester's. It used to be a figure of this route's own,
9
+ * 60 s, beside the page's own 60 s — two chosen numbers that happened to agree,
10
+ * so the "retry" this route sent at its deadline reached a page that had given
11
+ * up at the same instant. The page now states how long it will hold the answer
12
+ * open (`X-Hold-Ms`, its own deadline less the round trip it has measured), and
13
+ * this route answers by then. A requester that states nothing is held until it
14
+ * closes the connection, which every HTTP client does when it stops waiting.
15
+ */
16
+ const HOLD_HEADER = "x-hold-ms";
17
+
18
+ /**
19
+ * Serve HLS playlist and segment files from an active transcode session.
20
+ *
21
+ * Briefly waits for the requested file to appear, then answers with a
22
+ * retryable 503 rather than holding the connection, so clients — in
23
+ * particular iOS's native HLS player — never hit their own response
24
+ * deadline while a segment is still being produced.
25
+ *
26
+ * GET /transcode/:sessionId/:fileName
27
+ *
28
+ * @param {import("fastify").FastifyRequest} req
29
+ * @param {import("fastify").FastifyReply} reply
30
+ * @param {{ serving: object, viewerRequests: object }} deps
31
+ * @returns {Promise<void>}
32
+ */
33
+ export async function handleTranscodeSessionFileGet(req, reply, { serving, viewerRequests }) {
34
+ const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
35
+ const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
36
+ if (refusedAsStale(req, reply, viewerRequests, fileName)) {
37
+ return reply;
38
+ }
39
+ // This route names its output outright, so the answer is recorded rather
40
+ // than chosen — under the height the output is named after, which is where
41
+ // the step route answers with the picture itself.
42
+ viewerRequests.noteAnsweredDirectly(
43
+ sessionId,
44
+ consumerOf(req),
45
+ statedGenerationOf(req),
46
+ fileName
47
+ );
48
+ return serveSessionFile(req, reply, { serving, viewerRequests, sessionId, fileName });
49
+ }
50
+
51
+ /**
52
+ * The answer given when no output suits this viewer: nothing this proxy holds
53
+ * or could produce is admitted by their link. Machine-readable, because the
54
+ * page has to explain it to the viewer and offer what they can do; the
55
+ * figures are the ones the decision was made on.
56
+ *
57
+ * 409 and not a 5xx: nothing is wrong with the proxy, and repeating the same
58
+ * request against the same link gives the same answer.
59
+ *
60
+ * @param {import("fastify").FastifyReply} reply
61
+ * @param {{ reason: string, figures: object | null, wantedKey?: string }} details
62
+ * @returns {import("fastify").FastifyReply}
63
+ */
64
+ export function replyOutputUnavailable(reply, details) {
65
+ return reply.code(409).send({
66
+ error: `No output suits this viewer: ${details?.reason ?? "unknown"}.`,
67
+ outcome: "output-unavailable",
68
+ reason: details?.reason ?? "",
69
+ figures: details?.figures ?? null
70
+ });
71
+ }
72
+
73
+ /**
74
+ * The answer given when this machine cannot take the output asked for: nothing
75
+ * it has shown it can encode fits, or it has no place for one more encoder.
76
+ * The page answers it by asking the rest of the pool before anything plays.
77
+ *
78
+ * @param {import("fastify").FastifyReply} reply
79
+ * @param {{ reason: string, figures?: object | null }} details
80
+ * @returns {import("fastify").FastifyReply}
81
+ */
82
+ export function replyNoCapacity(reply, details) {
83
+ return reply.code(409).send({
84
+ error: `This proxy cannot take this video now: ${details?.reason ?? "unknown"}.`,
85
+ outcome: "no-capacity",
86
+ reason: details?.reason ?? "",
87
+ figures: details?.figures ?? null
88
+ });
89
+ }
90
+
91
+ /**
92
+ * The answer given when an address this viewer was already given cannot be
93
+ * given again without risking a piece under a header it may not match. The
94
+ * page answers it by starting a new viewing where the picture is — which is
95
+ * why it is not a "retry": the same request would be answered the same way.
96
+ *
97
+ * @param {import("fastify").FastifyReply} reply
98
+ * @param {{ reason: string }} details
99
+ * @returns {import("fastify").FastifyReply}
100
+ */
101
+ export function replyAssignmentLost(reply, details) {
102
+ return reply.code(409).send({
103
+ error: `This part was answered by an output that has gone: ${details?.reason ?? "unknown"}.`,
104
+ outcome: "assignment-lost",
105
+ reason: details?.reason ?? ""
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Which viewer is asking, or an empty string where the transport names nobody.
111
+ *
112
+ * @param {import("fastify").FastifyRequest} req
113
+ * @returns {string}
114
+ */
115
+ export function consumerOf(req) {
116
+ return typeof req.query?.consumer === "string" ? req.query.consumer : "";
117
+ }
118
+
119
+ /**
120
+ * The generation the request says it was made in, or NaN when it says none.
121
+ *
122
+ * STAMPED BY THE PAGE when the request was SENT (`webrtc-hls-loader.js`), not
123
+ * when it arrived: a request made before a seek and delivered after it still
124
+ * says it belongs to the viewing that was left. Only a stated non-negative
125
+ * integer counts; anything else is "nothing stated".
126
+ *
127
+ * @param {import("fastify").FastifyRequest} req
128
+ * @returns {number}
129
+ */
130
+ export function statedGenerationOf(req) {
131
+ const raw = req.query?.generation;
132
+ if (typeof raw !== "string" || !/^\d+$/.test(raw)) {
133
+ return Number.NaN;
134
+ }
135
+ return Number(raw);
136
+ }
137
+
138
+ /**
139
+ * Answer at once, and do nothing else, when the request belongs to a viewing
140
+ * its viewer has left and whose window for new requests has passed.
141
+ *
142
+ * BEFORE ANY OTHER STEP OF THE ROUTE. Resolving a quality step or a soundtrack
143
+ * can create an output and registers the viewer on it; a stale request that got
144
+ * that far would leave both behind and be refused only afterwards. The answer
145
+ * is the same one a request made pointless by a seek already gets — 503 with
146
+ * `Retry-After: 0` — because it is the same situation seen from the other end.
147
+ *
148
+ * @param {import("fastify").FastifyRequest} req
149
+ * @param {import("fastify").FastifyReply} reply
150
+ * @param {{ acceptsRequest: Function }} viewerRequests
151
+ * @param {string} fileName - For the log line.
152
+ * @returns {boolean} Whether the request was answered here.
153
+ */
154
+ export function refusedAsStale(req, reply, viewerRequests, fileName) {
155
+ const consumerId = consumerOf(req);
156
+ const stated = statedGenerationOf(req);
157
+ if (viewerRequests.acceptsRequest(consumerId, stated)) {
158
+ return false;
159
+ }
160
+ logger.info(
161
+ `[hold] ${fileName} refused: made in generation ${stated} of ${consumerId}, ` +
162
+ `a viewing they have left and whose window for new requests has passed`
163
+ );
164
+ reply.header("Retry-After", "0");
165
+ reply.code(503).send({ error: "Made for a viewing that has been left." });
166
+ return true;
167
+ }
168
+
169
+ /**
170
+ * Serve one playlist or segment from a named session.
171
+ *
172
+ * Split from the route above because the variant route
173
+ * (`/transcode/:sessionId/v/:height/:fileName`) serves the same files from
174
+ * another session of the same family, and must hold, log and answer them
175
+ * identically — a switch of quality must not go through a different code path
176
+ * from the stream it switches away from.
177
+ *
178
+ * @param {import("fastify").FastifyRequest} req
179
+ * @param {import("fastify").FastifyReply} reply
180
+ * @param {{ serving: object, viewerRequests: object, sessionId: string, fileName: string }} params
181
+ * @returns {Promise<void>}
182
+ */
183
+ export async function serveSessionFile(req, reply, { serving, viewerRequests, sessionId, fileName }) {
184
+ // Which viewer is asking. One session serves everyone watching a copied
185
+ // picture, so without it their positions collapse into one and a seek by the
186
+ // viewer in front releases the requests held for the viewer behind. Absent on
187
+ // a plain HTTP transport, where no loader of ours builds the URL, and then
188
+ // the single shared position decides as it always did.
189
+ const consumerId = consumerOf(req);
190
+ // Hold the request only briefly, then answer "retry" instead of waiting for
191
+ // the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
192
+ // deadline on RESPONSE HEADERS and raises -12889 ("No response for media
193
+ // file") when it passes — it then cancels in-flight requests, probes
194
+ // neighbouring positions and can restart the stream from the beginning. That
195
+ // is exactly the post-seek "player thrashing" seen in the field, because a
196
+ // seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
197
+ // to appear. Holding the connection for 30 s (as this did) guaranteed the
198
+ // timeout on every seek. A short hold keeps the fast path intact (a ready or
199
+ // nearly-ready segment is still served on the first request) while a slow one
200
+ // gets a prompt retryable answer, which resets the player's own deadline.
201
+ // hls.js is unaffected: it consumes the 503 through its retry policy, whose
202
+ // budget the client widens to match (see hls-player.js fragLoadPolicy).
203
+ // Instrumented wait. `clientAborted` flips when the player drops the
204
+ // connection while we are still holding it — the single most informative
205
+ // signal about its real patience, and observable only from this side.
206
+ const holdStartedAt = Date.now();
207
+ let clientAborted = false;
208
+ let onClientAbort = () => {};
209
+ const clientGone = new Promise((resolve) => {
210
+ onClientAbort = () => {
211
+ clientAborted = true;
212
+ resolve();
213
+ };
214
+ });
215
+ req.raw.on("close", onClientAbort);
216
+ const statedHoldMs = Number(req.headers?.[HOLD_HEADER]);
217
+
218
+ const result = await waitForSessionFile(serving, sessionId, fileName, {
219
+ holdMs: Number.isFinite(statedHoldMs) && statedHoldMs > 0 ? statedHoldMs : Number.POSITIVE_INFINITY,
220
+ until: clientGone,
221
+ consumerId
222
+ });
223
+
224
+ req.raw.off("close", onClientAbort);
225
+ const heldMs = Date.now() - holdStartedAt;
226
+ if (result.isPlaylist !== true) {
227
+ const outcome = clientAborted
228
+ ? "client-aborted"
229
+ : result.kind === "ok" ? "served" : result.kind;
230
+ // AGAINST THE RANK THE MAP GAVE IT. A wait is the only thing that says
231
+ // whether the prioritisation is being used well, and this is the one place
232
+ // in the proxy where a viewer is measurably waiting for a named segment.
233
+ // Recorded even when the segment was served at once: a run of short waits
234
+ // at the top rank is what "the urgent zone is being served first" looks
235
+ // like, and without them the table would hold only the failures.
236
+ const ranked = result.ranked ?? null;
237
+ if (ranked) {
238
+ waits.note(ranked.address, heldMs, ranked.rank, ranked.topRank);
239
+ }
240
+ logger.info(
241
+ `[hold] ${fileName} ${outcome} after ${heldMs}ms` +
242
+ (ranked ? ` (the map wants it ${bandOf(ranked.rank, ranked.topRank)}, rank ${ranked.rank} of ${ranked.topRank})` : "")
243
+ );
244
+ }
245
+
246
+ if (result.kind === "not-found") {
247
+ return reply.code(404).send({ error: "Transcode session file was not found." });
248
+ }
249
+ if (result.kind === "superseded") {
250
+ // The viewer moved while this was being held, and this segment is not the
251
+ // one they moved TO — that case is kept and waited out, see
252
+ // `waitForSessionFile`. Answer at once so the player can ask for where it
253
+ // is now; `Retry-After: 0` because there is nothing to wait for.
254
+ //
255
+ // Named in the log, because a refusal that says only "superseded" is what
256
+ // made the 2026-08-18 freeze take a day to explain: the player was refused
257
+ // the segment at its own seek target and nothing recorded which segment or
258
+ // where the viewer was.
259
+ logger.info(
260
+ `[hold] ${fileName} refused: the viewer is at ` +
261
+ `${viewerRequests.viewerPositionOf(sessionId, consumerId).toFixed(1)}s and this is not the segment there`
262
+ );
263
+ reply.header("Retry-After", "0");
264
+ return reply.code(503).send({ error: "Superseded by a seek." });
265
+ }
266
+ if (result.kind === "warming-up") {
267
+ // The segment is still being produced (e.g. just after a seek-restart).
268
+ // Return a retryable 503 — never 202, which hls.js cannot consume as a
269
+ // media segment — so the player retries the fetch shortly.
270
+ reply.header("Retry-After", "1");
271
+ // `Retry-After` tells the player to re-request THIS segment after a short
272
+ // pause. Without it a bare 503 reads as "nothing here", and the player goes
273
+ // looking elsewhere: because our synthetic VOD playlist lists every segment
274
+ // of the file, it believes they all exist and SCANS them (field log: one
275
+ // user seek produced probes at #617, #717, #732…). That scan is what used
276
+ // to steer the encoder off the real target. Whether iOS's native player
277
+ // honours the hint is not guaranteed — its behaviour is closed — but this
278
+ // is the standard, correct way to say "wait, don't look elsewhere", and
279
+ // hls.js already retries the same fragment regardless.
280
+ reply.header("Retry-After", "1");
281
+ // SAY WHY, to the viewer and not only to the log. The same refusal is
282
+ // written into `proxy.log` with the rank the priority map gave this
283
+ // segment; the page had no access to that and told the viewer it did not
284
+ // know why. Field 2026-09-14: `rank 1 of 100` — nothing was making it —
285
+ // printed sixty seconds before the page gave up saying the opposite.
286
+ return reply.code(503).send({
287
+ error: "Transcode segment is still being produced.",
288
+ reason: warmingReason(result.ranked ?? null)
289
+ });
290
+ }
291
+ if (result.kind === "failed") {
292
+ return reply.code(500).send({ error: result.message });
293
+ }
294
+
295
+ if (result.isPlaylist) {
296
+ reply.header("Cache-Control", "no-store");
297
+ } else {
298
+ reply.header("Cache-Control", "public, max-age=60");
299
+ holdWhileSending(reply, result.stream, viewerRequests.holdResponse(sessionId, consumerId));
300
+ }
301
+ reply.header("Content-Type", result.contentType);
302
+ return reply.send(result.stream);
303
+ }
304
+
305
+ /**
306
+ * Keep the output this response comes from held until the response is over.
307
+ *
308
+ * Over means any of `finish`, `close` or `error` of the response, or `error`
309
+ * of the stream being sent — whichever comes first, and usually more than one
310
+ * of them comes: a response that finishes also closes. `release` is safe to
311
+ * call any number of times, which is what makes listening to all of them
312
+ * correct rather than a leak or a double release.
313
+ *
314
+ * A response already over before it began (the requester went during the
315
+ * hold) is released at once: none of its events will fire again.
316
+ *
317
+ * @param {import("fastify").FastifyReply} reply
318
+ * @param {import("node:stream").Readable | undefined} stream
319
+ * @param {() => void} release
320
+ * @returns {void}
321
+ */
322
+ function holdWhileSending(reply, stream, release) {
323
+ const response = reply.raw;
324
+ if (!response || response.destroyed || response.writableEnded) {
325
+ release();
326
+ return;
327
+ }
328
+ response.once("finish", release);
329
+ response.once("close", release);
330
+ response.once("error", release);
331
+ stream?.once?.("error", release);
332
+ }
333
+
334
+ /**
335
+ * Ask `serving.getFileStream()` again each time the file may have become
336
+ * available — a segment's publication, or a waited-on invalidation — until it
337
+ * is, the session fails, the requester's stated wait runs out, or the
338
+ * requester goes.
339
+ *
340
+ * @param {object} serving - `services/server/SegmentServing.js`
341
+ * @param {string} sessionId
342
+ * @param {string} fileName
343
+ * @param {object} hold
344
+ * @param {number} hold.holdMs - How long the requester will wait; may be
345
+ * infinite when it stated nothing.
346
+ * @param {Promise<void>} [hold.until] - Settles when the requester has gone.
347
+ * @param {string} [hold.consumerId] - Which viewer is asking, where the
348
+ * transport carries it. Their own position is what decides whether a held
349
+ * request has been made pointless by a seek — a session can have several
350
+ * viewers, and the seek epoch belongs to all of them.
351
+ * @returns {Promise<Awaited<ReturnType<import("../../../services/server/SegmentServing.js").SegmentServing["getFileStream"]>>>}
352
+ */
353
+ export async function waitForSessionFile(serving, sessionId, fileName, { holdMs, until = null, consumerId = "" }) {
354
+ const startedAt = Date.now();
355
+ let gone = false;
356
+ const requesterGone = until ? until.then(() => { gone = true; }) : new Promise(() => {});
357
+ // The viewer's position when this request was made. A seek makes every held
358
+ // request stale — it asks for a segment nobody is going to watch — and hls.js
359
+ // keeps only ONE fragment load outstanding, so holding on blocks the request
360
+ // the player actually needs now. Measured: 57 s of a 58 s backward seek was
361
+ // this wait, and the segment the viewer wanted took 15 ms once it was asked
362
+ // for.
363
+ let seekEpoch = serving.seekEpoch(sessionId);
364
+ /** @type {{ address: string, rank: number, topRank: number } | null} */
365
+ let lastRanked = null;
366
+ while (!gone && Date.now() - startedAt < holdMs) {
367
+ const result = await serving.getFileStream(sessionId, fileName, { consumerId });
368
+ if (result.kind !== "warming-up") {
369
+ return result;
370
+ }
371
+ if (serving.seekEpoch(sessionId) !== seekEpoch) {
372
+ // A seek moved the epoch. Whether THIS request is stale depends on which
373
+ // segment it asks for: the one the viewer has just landed on races the
374
+ // seek notification and would otherwise be refused at the exact moment it
375
+ // is needed — measured 2026-08-18, two 503s within 80 ms on the segment
376
+ // at the seek target, after which the player never asked for it again.
377
+ if (!serving.requestStillWanted(sessionId, fileName, consumerId)) {
378
+ return { kind: "superseded" };
379
+ }
380
+ logger.info(
381
+ `[hold] ${fileName} kept across a seek: it is the segment the viewer now needs`
382
+ );
383
+ seekEpoch = serving.seekEpoch(sessionId);
384
+ }
385
+ // The rank the map last gave this segment, so a request that runs out of
386
+ // patience is still counted against what it was promised. Kept across the
387
+ // polls because the timeout path has no result of its own.
388
+ lastRanked = result.ranked ?? lastRanked;
389
+ const remaining = Math.max(0, holdMs - (Date.now() - startedAt));
390
+ const waitedForSegment = await serving.waitForSegment(sessionId, fileName, remaining, until);
391
+ if (!waitedForSegment && !gone) {
392
+ // Playlists and init files are not segment publications.
393
+ await Promise.race([delay(Math.min(300, remaining)), requesterGone]);
394
+ }
395
+ }
396
+ return { kind: "warming-up", ranked: lastRanked };
397
+ }
398
+
399
+ /**
400
+ * Resolve after a given number of milliseconds.
401
+ *
402
+ * @param {number} ms
403
+ * @returns {Promise<void>}
404
+ */
405
+ function delay(ms) {
406
+ return new Promise((resolve) => {
407
+ setTimeout(resolve, ms);
408
+ });
409
+ }
410
+
411
+ /**
412
+ * What to tell the VIEWER when a segment is not there yet.
413
+ *
414
+ * In their terms — the film, and what is being done about it — never in ours: a
415
+ * viewer cannot read a log, does not know what a rank is, and is not helped by
416
+ * being told which of our parts is waiting for which other.
417
+ *
418
+ * @param {{ rank: number, topRank: number } | null} ranked - What the priority
419
+ * map thought of this segment while the request was held.
420
+ * @returns {string}
421
+ */
422
+ function warmingReason(ranked) {
423
+ if (ranked && ranked.rank < ranked.topRank) {
424
+ // The map is working somewhere else in the film. That is the answer the
425
+ // failure of 2026-09-14 needed and nobody was given.
426
+ return "This part of the film is not being prepared yet — the proxy is working further along.";
427
+ }
428
+ return "This part of the film is still being prepared.";
429
+ }