@torrent-tv/proxy 2.88.4 → 2.88.6

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 (489) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2269 -2258
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -0
  19. package/assets/calibration/audio/aac.m4a +0 -0
  20. package/assets/calibration/audio/ac3.ac3 +0 -0
  21. package/assets/calibration/audio/alac.m4a +0 -0
  22. package/assets/calibration/audio/dts.dts +0 -0
  23. package/assets/calibration/audio/eac3.eac3 +0 -0
  24. package/assets/calibration/audio/flac.flac +0 -0
  25. package/assets/calibration/audio/manifest.json +98 -0
  26. package/assets/calibration/audio/mp2.mp2 +0 -0
  27. package/assets/calibration/audio/mp3.mp3 +0 -0
  28. package/assets/calibration/audio/opus.opus +0 -0
  29. package/assets/calibration/audio/pcm.wav +0 -0
  30. package/assets/calibration/audio/truehd.thd +0 -0
  31. package/assets/calibration/audio/vorbis.ogg +0 -0
  32. package/assets/diagnostics/sctpstate.gdb +31 -31
  33. package/bin/cli.js +684 -684
  34. package/bin/thread-pool.js +30 -30
  35. package/biome.json +412 -411
  36. package/docs/browser-proxy-contract.md +155 -152
  37. package/docs/container-architecture.md +218 -218
  38. package/docs/download-architecture.md +261 -261
  39. package/docs/encode-architecture.md +729 -729
  40. package/docs/encode-run-state.md +96 -96
  41. package/docs/logs.md +97 -97
  42. package/docs/playback-start-forecast.md +56 -0
  43. package/docs/socks5-egress-research.md +114 -114
  44. package/docs/storage-architecture.md +267 -267
  45. package/knip.json +14 -14
  46. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  47. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  48. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  49. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  50. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  51. package/openspec/changes/binary-distribution/proposal.md +53 -53
  52. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  53. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  54. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  55. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  56. package/openspec/changes/cold-start/design.md +98 -98
  57. package/openspec/changes/cold-start/proposal.md +62 -62
  58. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  59. package/openspec/changes/cold-start/tasks.md +48 -48
  60. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  61. package/openspec/changes/disk-cap/proposal.md +37 -37
  62. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  63. package/openspec/changes/disk-cap/tasks.md +19 -19
  64. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  65. package/openspec/changes/proxy-observability/design.md +38 -38
  66. package/openspec/changes/proxy-observability/proposal.md +49 -49
  67. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  68. package/openspec/changes/proxy-observability/tasks.md +21 -21
  69. package/openspec/changes/seek-debounce/design.md +118 -118
  70. package/openspec/changes/seek-debounce/proposal.md +59 -59
  71. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  72. package/openspec/changes/seek-debounce/tasks.md +33 -33
  73. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  74. package/openspec/changes/subtitle-language/proposal.md +50 -50
  75. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  76. package/openspec/changes/subtitle-language/tasks.md +26 -26
  77. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  78. package/openspec/changes/track-selection/design.md +47 -47
  79. package/openspec/changes/track-selection/proposal.md +45 -45
  80. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  81. package/openspec/changes/track-selection/tasks.md +20 -20
  82. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  83. package/openspec/changes/transcode-quality/proposal.md +51 -51
  84. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  85. package/openspec/changes/transcode-quality/tasks.md +63 -63
  86. package/openspec/config.yaml +38 -38
  87. package/package.json +1 -1
  88. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  89. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  90. package/research/double-spawn-2026-09-10.md +171 -171
  91. package/research/handover-2026-09-13.md +209 -209
  92. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  93. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  94. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  95. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  96. package/routes/api/client-logs/post.js +86 -86
  97. package/routes/api/delivery-sink/get.js +72 -72
  98. package/routes/api/link-probe/get.js +51 -51
  99. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  100. package/routes/api/playback-plan/post.js +53 -53
  101. package/routes/api/sources/post.js +36 -36
  102. package/routes/api/sources/stats/get.js +132 -132
  103. package/routes/api/sources/warm/post.js +217 -217
  104. package/routes/api/subtitles/get.js +230 -230
  105. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  106. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  107. package/routes/api/transcode-sessions/post.js +178 -178
  108. package/routes/api/transcode-sessions/progress/get.js +27 -27
  109. package/routes/api/transcode-sessions/release/post.js +41 -41
  110. package/routes/api/transcode-sessions/seek/post.js +63 -63
  111. package/routes/health/get.js +13 -13
  112. package/routes/healthz/get.js +13 -13
  113. package/routes/stream/get.js +380 -380
  114. package/routes/transcode/audio-file/get.js +64 -64
  115. package/routes/transcode/audio-warm/get.js +95 -95
  116. package/routes/transcode/session-file/get.js +52 -52
  117. package/routes/transcode/variant-file/get.js +95 -95
  118. package/routes/transcode/variant-warm/get.js +84 -84
  119. package/scripts/build-audio-samples.mjs +31 -0
  120. package/scripts/render-run-graph.js +173 -173
  121. package/server.js +730 -725
  122. package/services/encode/CoverageMap.js +428 -428
  123. package/services/encode/CushionReport.js +345 -345
  124. package/services/encode/EncodeAdmission.js +376 -376
  125. package/services/encode/EncodeOrchestrator.js +1217 -1217
  126. package/services/encode/EncodePlan.js +1366 -1366
  127. package/services/encode/EncodeRun.js +813 -813
  128. package/services/encode/EncodeRuns.js +1239 -1235
  129. package/services/encode/Encoder.js +142 -142
  130. package/services/encode/EncoderSelection.js +38 -38
  131. package/services/encode/LocalObservations.js +359 -359
  132. package/services/encode/NvencEncoder.js +50 -50
  133. package/services/encode/OutputOpening.js +783 -783
  134. package/services/encode/OutputTimes.js +579 -579
  135. package/services/encode/QsvEncoder.js +58 -58
  136. package/services/encode/Renditions.js +2282 -2282
  137. package/services/encode/RunProgress.js +84 -84
  138. package/services/encode/SegmentDemand.js +95 -95
  139. package/services/encode/SoftwareEncoder.js +121 -121
  140. package/services/encode/V4l2m2mEncoder.js +53 -53
  141. package/services/encode/VaapiEncoder.js +66 -66
  142. package/services/encode/args.js +418 -418
  143. package/services/encode/audio-calibration.js +67 -0
  144. package/services/encode/audio-work-rate.js +15 -0
  145. package/services/encode/available-share.js +89 -89
  146. package/services/encode/calibration.js +361 -361
  147. package/services/encode/contention.js +128 -128
  148. package/services/encode/decode-cost-fit.js +363 -363
  149. package/services/encode/encode-exit.js +114 -114
  150. package/services/encode/encode-run-state.js +551 -551
  151. package/services/encode/encoder-readings.js +45 -45
  152. package/services/encode/fingerprint.js +322 -322
  153. package/services/encode/host-load.js +174 -174
  154. package/services/encode/hwaccel.js +1894 -1894
  155. package/services/encode/index.js +9 -9
  156. package/services/encode/learned-median.js +90 -90
  157. package/services/encode/output/EncodedOutput.js +57 -57
  158. package/services/encode/output/Output.js +131 -131
  159. package/services/encode/output/OutputCatalog.js +414 -414
  160. package/services/encode/output/OutputSpec.js +390 -390
  161. package/services/encode/output/Timeline.js +308 -308
  162. package/services/encode/output/cut-grid.js +107 -107
  163. package/services/encode/output/index.js +24 -20
  164. package/services/encode/output/ladder.js +32 -32
  165. package/services/encode/output/playlists.js +284 -284
  166. package/services/encode/output/rates.js +125 -125
  167. package/services/encode/output-key-format.js +21 -21
  168. package/services/encode/quality/EncodeCost.js +1167 -1150
  169. package/services/encode/quality/HostLoad.js +525 -525
  170. package/services/encode/quality/HostTimings.js +249 -249
  171. package/services/encode/quality/OutputQualityState.js +28 -28
  172. package/services/encode/quality/QualityController.js +935 -935
  173. package/services/encode/quality/QualityOffer.js +416 -416
  174. package/services/encode/quality/drain-threat.js +47 -47
  175. package/services/encode/quality/link-budget.js +349 -349
  176. package/services/encode/quality/output-format.js +319 -319
  177. package/services/encode/quality/serving-output.js +216 -216
  178. package/services/encode/quality/visible-rung.js +57 -57
  179. package/services/encode/run-budget.js +97 -97
  180. package/services/encode/run-command.js +755 -754
  181. package/services/encode/run-costs.js +155 -155
  182. package/services/encode/run-inputs.js +42 -42
  183. package/services/encode/segment-formats/fmp4.js +458 -456
  184. package/services/encode/segment-formats/index.js +85 -85
  185. package/services/encode/segment-formats/init-compat.js +324 -324
  186. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  187. package/services/encode/segment-formats/mpegts.js +134 -134
  188. package/services/encode/start-stop-cost.js +178 -178
  189. package/services/encode/throughput.js +97 -97
  190. package/services/encode/torrent-cost.js +88 -88
  191. package/services/identity/installation-id.js +38 -38
  192. package/services/media/ContainerOrchestrator.js +149 -149
  193. package/services/media/KeyframeTables.js +316 -316
  194. package/services/media/SourceFile.js +298 -298
  195. package/services/media/SubtitleCues.js +520 -520
  196. package/services/media/SubtitleOrchestrator.js +167 -167
  197. package/services/media/audio-inventory.js +299 -298
  198. package/services/media/container/AviContainer.js +266 -266
  199. package/services/media/container/Container.js +609 -609
  200. package/services/media/container/ContainerFactory.js +56 -56
  201. package/services/media/container/KeyframeTable.js +272 -272
  202. package/services/media/container/MatroskaContainer.js +1494 -1494
  203. package/services/media/container/Mp4Container.js +1344 -1344
  204. package/services/media/container/ebml-reader.js +159 -159
  205. package/services/media/ffmpeg-banner.js +260 -260
  206. package/services/media/keyframe-probe.js +157 -157
  207. package/services/media/media-info-probe.js +93 -93
  208. package/services/media/playback-planner.js +931 -931
  209. package/services/media/tracks/AudioTrack.js +143 -143
  210. package/services/media/tracks/ContainerTrack.js +75 -75
  211. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  212. package/services/media/tracks/SubtitleTrack.js +38 -38
  213. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  214. package/services/media/tracks/VideoTrack.js +33 -33
  215. package/services/media/tracks/audio-bound.js +233 -233
  216. package/services/media/tracks/index.js +15 -15
  217. package/services/media/tracks/language-detect.js +174 -174
  218. package/services/server/OutputLifecycle.js +418 -418
  219. package/services/server/SegmentServing.js +1150 -1150
  220. package/services/server/ViewerRequests.js +786 -760
  221. package/services/server/WaitLedger.js +142 -142
  222. package/services/server/controllers/PlaybackController.js +40 -40
  223. package/services/server/controllers/SubtitleController.js +109 -109
  224. package/services/server/http-responses.js +60 -60
  225. package/services/server/playback-readiness.js +516 -708
  226. package/services/server/transcode-session-files.js +325 -325
  227. package/services/server/wire-outputs.js +1020 -995
  228. package/services/storage/Diagnostics.js +202 -202
  229. package/services/storage/MachineBudget.js +235 -235
  230. package/services/storage/allowance.js +107 -107
  231. package/services/storage/core-dumps.js +106 -106
  232. package/services/storage/files/CompletedFiles.js +391 -391
  233. package/services/storage/files/piece-from-whole-file.js +118 -118
  234. package/services/storage/free.js +71 -71
  235. package/services/storage/keep.js +48 -48
  236. package/services/storage/machine-memory.js +56 -56
  237. package/services/storage/memory-report.js +575 -575
  238. package/services/storage/piece-store/piece-disk-store.js +539 -539
  239. package/services/storage/piece-store/piece-lru.js +495 -495
  240. package/services/storage/piece-store/shared-piece-store.js +1937 -1931
  241. package/services/storage/returns.js +103 -103
  242. package/services/storage/segment-store/SegmentStore.js +1255 -1240
  243. package/services/storage/wire.js +175 -175
  244. package/services/torrent/demand/DemandRegister.js +202 -202
  245. package/services/torrent/demand/Urgency.js +137 -137
  246. package/services/torrent/demand/Window.js +118 -118
  247. package/services/torrent/demand/index.js +8 -8
  248. package/services/torrent/demand/pieces.js +140 -140
  249. package/services/torrent/download/SwarmSelection.js +386 -386
  250. package/services/torrent/download/registry.js +102 -102
  251. package/services/torrent/download/withdraw-claim.js +80 -80
  252. package/services/torrent/files.js +455 -455
  253. package/services/torrent/naming.js +619 -619
  254. package/services/torrent/piece-store-of.js +29 -29
  255. package/services/torrent/supply-margin.js +262 -262
  256. package/services/torrent/torrent-pool.js +2688 -2675
  257. package/services/torrent/worker/background-fill.js +213 -213
  258. package/services/torrent/worker/channel.js +271 -271
  259. package/services/torrent/worker/client.js +765 -765
  260. package/services/torrent/worker/fastest-wires.js +342 -342
  261. package/services/torrent/worker/handle-state.js +23 -23
  262. package/services/torrent/worker/held-bytes.js +155 -155
  263. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  264. package/services/torrent/worker/piece-reader.js +1078 -1078
  265. package/services/torrent/worker/pool-adapter.js +371 -371
  266. package/services/torrent/worker/protocol.js +184 -184
  267. package/services/torrent/worker/resume-warm.js +107 -107
  268. package/services/torrent/worker/webrtc-shim.js +219 -219
  269. package/services/torrent/worker/worker.js +1129 -1129
  270. package/services/transport/data-channel-handler.js +1369 -1369
  271. package/services/transport/delivery-probe.js +842 -842
  272. package/services/transport/health-collector.js +53 -53
  273. package/services/transport/nat-classifier.js +231 -231
  274. package/services/transport/packet-witness.js +808 -808
  275. package/services/transport/port-mapper.js +235 -235
  276. package/services/transport/registry-api.js +81 -81
  277. package/services/transport/tunnel-client.js +447 -447
  278. package/services/transport/usrsctp-state.js +175 -175
  279. package/services/transport/webrtc-manager.js +484 -484
  280. package/services/viewer/Assignments.js +340 -340
  281. package/services/viewer/PriorityMap.js +390 -390
  282. package/services/viewer/PriorityOrchestrator.js +325 -325
  283. package/services/viewer/Viewer.js +691 -691
  284. package/services/viewer/Viewers.js +368 -368
  285. package/services/viewer/active-output.js +37 -37
  286. package/services/viewer/audio-start.js +46 -46
  287. package/services/viewer/buffer-trend.js +81 -81
  288. package/services/viewer/choices.js +647 -647
  289. package/services/viewer/positions.js +126 -126
  290. package/services/viewer/report-intake.js +46 -46
  291. package/store/source-registry.js +71 -71
  292. package/test/asset-paths.test.js +28 -28
  293. package/test/audio-calibration.test.js +23 -0
  294. package/test/audio-inventory.test.js +212 -212
  295. package/test/audio-track-bitrate.test.js +40 -40
  296. package/test/auto-quality-step.test.js +707 -707
  297. package/test/available-share.test.js +76 -76
  298. package/test/background-fill.test.js +190 -190
  299. package/test/client-log-file.test.js +110 -110
  300. package/test/completed-files.test.js +115 -115
  301. package/test/concurrent-cost.test.js +148 -148
  302. package/test/container-media-info.test.js +228 -228
  303. package/test/contention.test.js +76 -76
  304. package/test/core-dumps.test.js +57 -57
  305. package/test/coverage-follows-the-disk.test.js +228 -228
  306. package/test/coverage-map.test.js +195 -195
  307. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  308. package/test/cut-times-timeline.test.js +65 -65
  309. package/test/cuts-follow-published-grid.test.js +109 -109
  310. package/test/data-channel-frame.test.js +63 -63
  311. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  312. package/test/dead-torrent-handle.test.js +52 -52
  313. package/test/declared-rates.test.js +116 -116
  314. package/test/declared-tracks.test.js +43 -43
  315. package/test/decode-cost-fit.test.js +202 -202
  316. package/test/decode-cost.test.js +462 -462
  317. package/test/delivery-probe.test.js +393 -393
  318. package/test/demand-register.test.js +206 -206
  319. package/test/dht-bootstrap.test.js +72 -72
  320. package/test/drain-threat.test.js +65 -65
  321. package/test/duplicate-add.test.js +48 -48
  322. package/test/encode-admission.test.js +146 -146
  323. package/test/encode-cost.test.js +283 -283
  324. package/test/encode-exit.test.js +111 -111
  325. package/test/encode-orchestrator.test.js +419 -419
  326. package/test/encode-plan-viewers.test.js +718 -718
  327. package/test/encode-plan.test.js +545 -545
  328. package/test/encode-run-state.test.js +314 -314
  329. package/test/encode-run.test.js +162 -162
  330. package/test/encode-slope.test.js +46 -46
  331. package/test/encoded-output.test.js +39 -39
  332. package/test/encoder-kinds.test.js +122 -122
  333. package/test/encoder-readings.test.js +46 -46
  334. package/test/every-claimant-is-told-its-share.test.js +152 -152
  335. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  336. package/test/failed-start-breaker.test.js +202 -202
  337. package/test/fastest-wires.test.js +143 -143
  338. package/test/ffmpeg-stream-counts.test.js +78 -78
  339. package/test/file-edges.test.js +191 -191
  340. package/test/fmp4-presentation-clock.test.js +162 -147
  341. package/test/fmp4-self-contained.test.js +80 -80
  342. package/test/fmp4-timestamps.test.js +140 -140
  343. package/test/fmp4-track-completeness.test.js +41 -41
  344. package/test/format-keyed-outputs.test.js +78 -78
  345. package/test/health-metrics.test.js +38 -38
  346. package/test/held-request-width.test.js +157 -157
  347. package/test/helpers/encode-run.js +136 -136
  348. package/test/helpers/held-file.js +29 -29
  349. package/test/helpers/manager.js +57 -57
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +102 -102
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +112 -112
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +292 -292
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/memory-budget.test.js +390 -390
  372. package/test/move-cost.test.js +216 -216
  373. package/test/mp4-subtitles.test.js +173 -173
  374. package/test/one-authority.test.js +285 -285
  375. package/test/one-budget.test.js +149 -149
  376. package/test/one-output-one-session.test.js +273 -270
  377. package/test/one-resource-divided.test.js +185 -185
  378. package/test/one-serving-output-per-height.test.js +458 -458
  379. package/test/orchestrator-wired.test.js +206 -206
  380. package/test/output-catalog.test.js +39 -39
  381. package/test/output-shape.test.js +21 -21
  382. package/test/output-spec.test.js +251 -251
  383. package/test/output-speed.test.js +88 -88
  384. package/test/outputs-being-prepared.test.js +40 -40
  385. package/test/packet-witness-ring.test.js +236 -236
  386. package/test/packet-witness.test.js +148 -148
  387. package/test/piece-disk-store.test.js +401 -401
  388. package/test/piece-from-whole-file.test.js +129 -129
  389. package/test/piece-lru.test.js +319 -319
  390. package/test/piece-reader.test.js +162 -162
  391. package/test/piece-selection-offset.test.js +85 -85
  392. package/test/piece-store-eviction.test.js +595 -595
  393. package/test/piece-store-never-refuses.test.js +153 -153
  394. package/test/piece-store-reservations.test.js +403 -403
  395. package/test/piece-store-slow-disk.test.js +159 -159
  396. package/test/piece-tail.test.js +97 -97
  397. package/test/plan-host-timings.test.js +68 -68
  398. package/test/plan-is-steady.test.js +225 -225
  399. package/test/playback-readiness.test.js +483 -453
  400. package/test/playback-start-regressions.test.js +99 -99
  401. package/test/priority-map-download.test.js +174 -174
  402. package/test/priority-map-emptied.test.js +204 -204
  403. package/test/priority-map-per-output.test.js +215 -215
  404. package/test/priority-map.test.js +163 -163
  405. package/test/probe-wedge-certainty.test.js +60 -60
  406. package/test/produced-copy-choice.test.js +213 -213
  407. package/test/published-timeline.test.js +83 -83
  408. package/test/quality-offer.test.js +162 -162
  409. package/test/quality-variants.test.js +1257 -1257
  410. package/test/rate-control-in-the-output.test.js +180 -180
  411. package/test/read-survives-withdrawal.test.js +164 -164
  412. package/test/read-window.test.js +365 -365
  413. package/test/resume-warm.test.js +39 -39
  414. package/test/run-graph-drift.test.js +37 -37
  415. package/test/run-inputs.test.js +80 -80
  416. package/test/run-intervals.test.js +100 -100
  417. package/test/run-position-follows-published-grid.test.js +89 -89
  418. package/test/run-progress.test.js +65 -65
  419. package/test/same-height-switch.test.js +664 -664
  420. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  421. package/test/seek-landing.test.js +109 -109
  422. package/test/seek-target-not-superseded.test.js +195 -195
  423. package/test/segment-closed-on-both-branches.test.js +96 -96
  424. package/test/segment-cut-times.test.js +63 -63
  425. package/test/segment-demand.test.js +80 -80
  426. package/test/segment-hold-supersede.test.js +91 -91
  427. package/test/segment-judged-by-its-own-header.test.js +221 -221
  428. package/test/segment-serve-wiring.test.js +475 -475
  429. package/test/segment-store-eviction.test.js +247 -247
  430. package/test/segment-store.test.js +352 -323
  431. package/test/segment-true-start.test.js +86 -86
  432. package/test/segments-are-shared.test.js +176 -176
  433. package/test/send-chunk-bytes.test.js +86 -86
  434. package/test/serving-existing-output.test.js +105 -105
  435. package/test/serving-output.test.js +102 -102
  436. package/test/shared-piece-store.test.js +310 -310
  437. package/test/sidecar-naming.test.js +142 -142
  438. package/test/soundtrack-rate-bound.test.js +289 -289
  439. package/test/source-file.test.js +133 -133
  440. package/test/source-registry.test.js +18 -18
  441. package/test/split-a-stretch.test.js +139 -139
  442. package/test/stale-request-after-seek.test.js +158 -158
  443. package/test/startup-readings.test.js +124 -124
  444. package/test/stream-route.test.js +209 -209
  445. package/test/subtitle-cue-framing.test.js +200 -200
  446. package/test/subtitle-cue-source.test.js +102 -102
  447. package/test/subtitle-cue-walk.test.js +370 -370
  448. package/test/subtitle-cursor.test.js +63 -63
  449. package/test/subtitle-defaults.test.js +118 -118
  450. package/test/subtitle-language.test.js +252 -252
  451. package/test/subtitle-track-numbering.test.js +386 -386
  452. package/test/supply-margin.test.js +177 -177
  453. package/test/swarm-follows-readers.test.js +174 -174
  454. package/test/swarm-reach.test.js +130 -130
  455. package/test/swarm-selection.test.js +220 -220
  456. package/test/tail-duplication.test.js +167 -167
  457. package/test/the-channel-has-both-ends.test.js +91 -91
  458. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  459. package/test/thread-pool.test.js +53 -53
  460. package/test/timeline.test.js +84 -84
  461. package/test/torrent-contents.test.js +235 -235
  462. package/test/torrent-cost.test.js +71 -71
  463. package/test/torrent-files.test.js +221 -221
  464. package/test/torrent-naming.test.js +255 -255
  465. package/test/tracks-begin-together.test.js +209 -209
  466. package/test/tunnel-renewal.test.js +167 -167
  467. package/test/two-viewers-one-picture.test.js +504 -504
  468. package/test/upload-hurry.test.js +186 -186
  469. package/test/usrsctp-state.test.js +131 -131
  470. package/test/video-facts.test.js +102 -102
  471. package/test/viewer-assignments.test.js +143 -143
  472. package/test/viewer-outputs.test.js +285 -285
  473. package/test/viewer-presence.test.js +116 -116
  474. package/test/viewer-requests-admission.test.js +89 -89
  475. package/test/viewer-states.test.js +189 -189
  476. package/test/viewer-subtitle-subscription.test.js +96 -96
  477. package/test/viewer.test.js +139 -139
  478. package/test/viewing-generations.test.js +390 -390
  479. package/test/visible-rung.test.js +37 -37
  480. package/test/wait-ledger.test.js +100 -100
  481. package/test/webrtc-shim.test.js +149 -149
  482. package/test/wedge-certainty.test.js +131 -131
  483. package/test/withdraw-piece-claim.test.js +212 -212
  484. package/test/worker-channel.test.js +158 -158
  485. package/utils/client-log-file.js +151 -151
  486. package/utils/logger.js +312 -312
  487. package/utils/parse-range.js +25 -25
  488. package/utils/perf.js +59 -59
  489. package/utils/torrent-source-key.js +38 -38
@@ -1,1240 +1,1255 @@
1
- /**
2
- * @file One store of produced segments for the whole proxy, addressed by what
3
- * the segments ARE rather than by who made them.
4
- *
5
- * Until now a segment lived under the id of the session whose encoder wrote it
6
- * — under that session's own id, in a directory of that run's own — and the index
7
- * over it was built per session, so a segment was visible only inside the
8
- * session that made it. Two viewers of one film got two sessions with
9
- * byte-identical output and neither could see the other's work (measured
10
- * 2026-09-03, `research/two-viewers-one-file-2026-09-03.md`).
11
- *
12
- * Here the address is the output's own parameters. Which viewer asked never
13
- * enters it, and neither does which encoder produced the bytes: a segment is
14
- * the same segment whoever made it.
15
- *
16
- * **The directory carries its own identity.** Its NAME is a digest, because the
17
- * key contains characters a path may not; the key itself is written inside it,
18
- * in `key.txt`. That is what lets a new process, started after this one was
19
- * killed, work out what it is looking at — without it, everything on disk after
20
- * a kill is unidentifiable and can only be thrown away.
21
- *
22
- * **What proves a segment is closed: its NAME.** A piece being written is called
23
- * something else — `making-40-00057.mp4`, tagged with the run writing it — and
24
- * takes its served name only when the
25
- * encoder has said it is closed, which it does on a channel of its own
26
- * (`-segment_list pipe:3`). The `hls` branch needs nothing extra: its muxer
27
- * writes through a temporary name of its own, so its files appear under their
28
- * final name whole. One rule for both, and true whether or not this process is
29
- * alive: **a file under its served name is complete.**
30
- *
31
- * It was "a segment is closed when the NEXT number exists". That is true of one
32
- * writer walking forward and false the moment two runs share an output, because
33
- * the next file is then written by another process while this one is still open
34
- * — and two runs on one output is not a rare state, it is what the plan gives an
35
- * output whenever it places a second encoder. Field 2026-09-08:
36
- * `segment-00057.mp4` served at 2 268 361 bytes and then at 4 510 940, exactly
37
- * half; the browser appended the half and refused the whole for the rest of the
38
- * session, `bufferAppendError` fourteen times with the picture frozen at
39
- * 319.66 s. `segment-00055.mp4` the same, 211 957 against 2 620 617.
40
- */
41
-
42
- import { createHash } from "node:crypto";
43
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from "node:fs";
44
- import { rmSync } from "node:fs";
45
- import os from "node:os";
46
- import path from "node:path";
47
-
48
- /** Where every output's segments live. One root for the process. */
49
- export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
50
-
51
- /** The file inside each directory that says which output it holds. */
52
- const KEY_FILE = "key.txt";
53
-
54
- /**
55
- * The directory name for an output key.
56
- *
57
- * A digest rather than the key itself: the key carries `:` and `/`, which a
58
- * path may not, and sanitising them would make two different keys collide.
59
- * Sixteen hex characters is enough that a collision is not a thing that
60
- * happens, and short enough to read in a log line.
61
- *
62
- * @param {string} key
63
- * @returns {string}
64
- */
65
- export function directoryNameFor(key) {
66
- return createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
67
- }
68
-
69
- /**
70
- * What one output's directory holds, as last read.
71
- *
72
- * @typedef {object} HeldContents
73
- * @property {number} readAt - The directory's modification time when it was read.
74
- * @property {Map<number, string>} byNumber - Segment number to full path.
75
- * @property {number} bytes - What those files weigh.
76
- * @property {number} unproven - The highest number, whose closure nothing
77
- * proves, or -1 when the directory holds no segments.
78
- */
79
-
80
- export class SegmentStore {
81
- /** @type {string} */
82
- #root;
83
-
84
- /** Output key → what its directory holds. @type {Map<string, HeldContents>} */
85
- #held = new Map();
86
- #mediaRanges = new Map();
87
-
88
- /** Output key → how to read its file names. @type {Map<string, object>} */
89
- #formats = new Map();
90
-
91
- /** Output key → when it was last asked for. @type {Map<string, number>} */
92
- #touched = new Map();
93
-
94
- /**
95
- * Output key → the init segment served for it, once one has been read whole.
96
- *
97
- * A property of the output's produced material, so it lives with that
98
- * material and goes when it goes. The player fetches it once and never again,
99
- * so the first whole one is kept and every later request gets the same bytes,
100
- * even though a re-encoding run rewrites the file on disk.
101
- *
102
- * @type {Map<string, Buffer>}
103
- */
104
- #inits = new Map();
105
-
106
- /** Told of every segment published, whatever it is. @type {Set<(key: string, index: number) => void>} */
107
- #publishedListeners = new Set();
108
-
109
- /** Segment publication waiters by output key and segment number. @type {Map<string, Map<number, Set<Function>>>} */
110
- #waiters = new Map();
111
-
112
- /** Output key → the watch on its directory. @type {Map<string, import("node:fs").FSWatcher>} */
113
- #watchers = new Map();
114
-
115
- /** @type {{ info: Function, warn: Function }} */
116
- #logger;
117
-
118
- /** @type {() => number} */
119
- #now;
120
-
121
- /**
122
- * @param {object} [params]
123
- * @param {string} [params.root] - Where the directories live.
124
- * @param {{ info: Function, warn: Function }} [params.logger]
125
- * @param {() => number} [params.now]
126
- */
127
- constructor({ root = DEFAULT_STORE_ROOT, logger = null, now = Date.now } = {}) {
128
- this.#root = root;
129
- this.#logger = logger ?? { info: () => {}, warn: () => {} };
130
- this.#now = now;
131
- }
132
-
133
- /** @returns {string} */
134
- get root() {
135
- return this.#root;
136
- }
137
-
138
- /**
139
- * Where this output's segments live, without making anything.
140
- *
141
- * Separate from {@link directoryFor} because a session works out its path
142
- * long before it is sure it will exist: a probe or a keyframe read between
143
- * the two can still fail, and a directory made in advance of that is a
144
- * leftover nothing tracks — proxy 2.9.101 failed on every request and its
145
- * abandoned directories were the only trace on disk.
146
- *
147
- * @param {string} key - `OutputSpec.toKey()`.
148
- * @returns {string}
149
- */
150
- pathFor(key) {
151
- return path.join(this.#root, directoryNameFor(key));
152
- }
153
-
154
- /**
155
- * The directory this output's segments live in, made if it is not there.
156
- *
157
- * @param {string} key - `OutputSpec.toKey()`.
158
- * @returns {string}
159
- */
160
- directoryFor(key) {
161
- const dir = path.join(this.#root, directoryNameFor(key));
162
- if (!existsSync(dir)) {
163
- mkdirSync(dir, { recursive: true });
164
- // What this directory is, for whoever finds it after this process has
165
- // been killed. Without it the sweep can only throw everything away.
166
- writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
167
- }
168
- this.#touched.set(key, this.#now());
169
- // Production is about to begin here, and a piece closing in this directory
170
- // is what every held request is waiting for.
171
- this.#watch(key);
172
- return dir;
173
- }
174
-
175
- /**
176
- * Say how this output's files are named, so the store can read its directory.
177
- *
178
- * @param {string} key
179
- * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
180
- */
181
- useFormat(key, segmentFormat) {
182
- if (segmentFormat && typeof segmentFormat.isSegmentFileName === "function") {
183
- this.#formats.set(key, segmentFormat);
184
- }
185
- }
186
-
187
- /**
188
- * Re-read this output's directory if it has moved since last time.
189
- *
190
- * A directory's modification time changes when an entry is added or removed,
191
- * so a quiet request costs one `stat` rather than a listing.
192
- *
193
- * @param {string} key
194
- * @returns {HeldContents}
195
- */
196
- refresh(key) {
197
- const format = this.#formats.get(key);
198
- const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1, largest: { index: -1, size: 0 } };
199
- if (!format) {
200
- return this.#held.get(key) ?? empty;
201
- }
202
- const dir = path.join(this.#root, directoryNameFor(key));
203
- let mtime = 0;
204
- try {
205
- mtime = statSync(dir).mtimeMs;
206
- } catch {
207
- this.#held.delete(key);
208
- return empty;
209
- }
210
- const known = this.#held.get(key);
211
- if (known && known.readAt === mtime) {
212
- return known;
213
- }
214
- const byNumber = new Map();
215
- const sizes = new Map();
216
- let bytes = 0;
217
- let highest = -1;
218
- // The biggest piece and which number it is. What reads it is the figure the
219
- // master playlist declares: `BANDWIDTH` is the PEAK a link must carry, and
220
- // the peak of a variable-bitrate source is nothing like its average — the
221
- // field file of 2026-09-08 ran at 17.1 Mbit/s with a piece at 73. Which
222
- // number it is matters because pieces are not all the same length, and only
223
- // whoever holds the cut table can turn bytes into bits per second.
224
- let largest = { index: -1, size: 0 };
225
- try {
226
- for (const name of readdirSync(dir)) {
227
- if (!format.isSegmentFileName(name)) {
228
- continue;
229
- }
230
- const index = format.segmentIndexFromName(name);
231
- if (!Number.isInteger(index) || index < 0) {
232
- continue;
233
- }
234
- const full = path.join(dir, name);
235
- let size = 0;
236
- try {
237
- size = statSync(full).size;
238
- } catch {
239
- continue;
240
- }
241
- // A file of no bytes is not a segment, whatever its name says. It is
242
- // what a run killed the instant after opening its next piece leaves,
243
- // and taking it for a segment once convinced the look-ahead that a
244
- // number had been produced and kept the encoder stopped for it.
245
- if (size <= 0) {
246
- continue;
247
- }
248
- byNumber.set(index, full);
249
- sizes.set(index, size);
250
- bytes += size;
251
- if (index > highest) {
252
- highest = index;
253
- }
254
- if (size > largest.size) {
255
- largest = { index, size };
256
- }
257
- }
258
- } catch {
259
- this.#held.delete(key);
260
- return empty;
261
- }
262
- const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest };
263
- this.#held.set(key, contents);
264
- return contents;
265
- }
266
-
267
- /**
268
- * The segment numbers this output holds that are finished.
269
- *
270
- * ONE PROOF, AND IT IS THE PIECE'S OWN NAME. A piece being written is called
271
- * `making-40-00042.mp4`; it is renamed to `segment-00042.mp4` when its writer
272
- * says it has closed it, and on the `hls` branch — which has no such channel —
273
- * the muxer's own `+temp_file` does the same rename for the same reason. So a
274
- * file under the served name is complete, whoever made it and whenever.
275
- *
276
- * WHAT THIS REPLACED, because the difference is what a viewer felt. Closure
277
- * used to be inferred from the NEXT number existing, which is sound for one
278
- * writer walking forward and false the moment two runs share an output — and
279
- * one-piece intervals guarantee that. Field 2026-09-08:
280
- * `segment-00057.mp4` was served at 2 268 361 bytes and then at 4 510 940, the
281
- * browser appended the truncated body, and `bufferAppendError` repeated to the
282
- * end of the log with the picture frozen at 319.66 s. It also left the last
283
- * piece of every run unprovable for ever, since nothing follows it.
284
- *
285
- * @param {string} key
286
- * @returns {number[]}
287
- */
288
- provenNumbers(key) {
289
- return [...this.refresh(key).byNumber.keys()].sort((left, right) => left - right);
290
- }
291
-
292
- /**
293
- * How many numbered files this output holds, closed or not.
294
- *
295
- * What the disk has, against what has been proven closed: the two are printed
296
- * side by side, so "the files are there and nobody reported them" reads
297
- * differently from "there is nothing there".
298
- *
299
- * @param {string} key
300
- * @returns {number}
301
- */
302
- filesHeld(key) {
303
- return this.refresh(key).byNumber.size;
304
- }
305
-
306
- /**
307
- * The size in bytes of every finished piece of this output, by number.
308
- *
309
- * Read by whoever keeps what an output's segments carried (roadmap item 97,
310
- * step 14). A reading, not a request: it does not mark the output as read,
311
- * so it does not keep an output nobody is watching.
312
- *
313
- * @param {string} key
314
- * @returns {Map<number, number>}
315
- */
316
- sizesOf(key) {
317
- return new Map(this.refresh(key).sizes ?? []);
318
- }
319
-
320
- /**
321
- * The biggest piece this output has made, and which number it is.
322
- *
323
- * Read by whoever declares the variant's peak rate. Bytes alone cannot say
324
- * it — pieces are not all the same length — so the number comes with them and
325
- * whoever holds the cut table does the division.
326
- *
327
- * @param {string} key
328
- * @returns {{ index: number, size: number }} An index of `-1` while nothing
329
- * has been made, which is a statement and not a zero.
330
- */
331
- largestPiece(key) {
332
- return this.refresh(key).largest ?? { index: -1, size: 0 };
333
- }
334
-
335
-
336
- /**
337
- * Every output whose pieces this store knows how to read: those in use and
338
- * those a previous life of the process left.
339
- *
340
- * @returns {string[]}
341
- */
342
- addresses() {
343
- return [...this.#formats.keys()];
344
- }
345
-
346
- /**
347
- * Whether this piece is finished, and may therefore be served.
348
- *
349
- * Its NAME is the proof, and there is no second one: a piece being written is
350
- * called something else until whoever writes it says it is closed. That holds
351
- * for a piece a live run is rewriting — the file standing there was closed by
352
- * somebody, and it is replaced whole or not at all — and for a piece left by an
353
- * earlier life of this process, which the startup sweep answers the same way.
354
- *
355
- * @param {string} key
356
- * @param {number} index
357
- * @returns {boolean}
358
- */
359
- isClosed(key, index) {
360
- return this.refresh(key).byNumber.has(index);
361
- }
362
-
363
- /**
364
- * Remove the pieces an output was in the middle of writing.
365
- *
366
- * They are under working names, so they were never servable and nothing has
367
- * to be un-proven — this is disk, not correctness. A process killed by the
368
- * kernel leaves one per live run, and the kernel takes this process often
369
- * enough for that to matter.
370
- *
371
- * @param {string} key
372
- * @param {string} dir
373
- * @param {{ makingTagOf?: (name: string) => string | null }} format
374
- * @param {string | null} [tag] - One run's own tag, or null for every run's.
375
- * @returns {number} How many were removed.
376
- */
377
- #sweepUnfinished(key, dir, format, tag = null) {
378
- let removed = 0;
379
- let names = [];
380
- try {
381
- names = readdirSync(dir);
382
- } catch {
383
- return 0;
384
- }
385
- for (const name of names) {
386
- const wroteIt = format?.makingTagOf?.(name) ?? null;
387
- if (wroteIt === null || (tag !== null && wroteIt !== tag)) {
388
- continue;
389
- }
390
- try {
391
- rmSync(path.join(dir, name), { force: true });
392
- removed += 1;
393
- } catch {
394
- // Then it stays, costing disk and nothing else.
395
- }
396
- }
397
- if (removed > 0) {
398
- this.#held.delete(key);
399
- }
400
- return removed;
401
- }
402
-
403
- /**
404
- * The encoder has closed a piece: give it the name it is served under.
405
- *
406
- * One rename inside the output's own directory — one filesystem operation, and
407
- * atomic there. Before it the file is not a segment and no request can reach
408
- * it; after it, its existence IS the proof that it is whole, and that is one
409
- * rule for every branch whether or not our own process is alive.
410
- *
411
- * It replaced a rule that served half a segment: a piece was taken as finished
412
- * when the NEXT file existed. That is true of one writer walking forward and
413
- * false the moment two runs share an output, because the next file is then
414
- * written by another process while this one is still open — and two runs on one
415
- * output is not a rare state, it is what the plan gives an output whenever it
416
- * places a second encoder.
417
- *
418
- * Field 2026-09-08: `segment-00057.mp4` was served at 2 268 361 bytes and then
419
- * at 4 510 940 — exactly half of it. The browser appended the half and refused
420
- * the whole for the rest of the session, `bufferAppendError` fourteen times
421
- * over with the picture frozen at 319.66 s. `segment-00055.mp4` went the same
422
- * way, 211 957 against 2 620 617.
423
- *
424
- * @param {string} key
425
- * @param {string} makingName - What the encoder called it while writing.
426
- * @param {{ servedNameOf?: (name: string) => string | null }} format
427
- * @returns {string | null} The served name, or null where nothing was renamed.
428
- */
429
- publish(key, makingName, format) {
430
- const served = format?.servedNameOf?.(makingName) ?? null;
431
- if (!served) {
432
- return null;
433
- }
434
- const dir = path.join(this.#root, directoryNameFor(key));
435
- const index = format?.segmentIndexFromName?.(served) ?? -1;
436
- let mediaRanges = null;
437
- if (format?.readMediaRanges && index >= 0) {
438
- try {
439
- mediaRanges = format.readMediaRanges(readFileSync(path.join(dir, makingName)));
440
- if (mediaRanges.length === 0) throw new Error("The closed file contains no playable media intervals.");
441
- } catch (error) {
442
- this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
443
- return null;
444
- }
445
- }
446
- try {
447
- renameSync(path.join(dir, makingName), path.join(dir, served));
448
- } catch (error) {
449
- // The file may already be gone — a process killed between closing the
450
- // piece and this line. Said rather than swallowed: a piece the encoder
451
- // reported and the disk does not have is worth knowing about.
452
- this.#logger?.warn?.(
453
- `segment store: could not publish ${makingName} of ${key.slice(0, 60)}: ` +
454
- `${error instanceof Error ? error.message : String(error)}`
455
- );
456
- return null;
457
- }
458
- // What the directory holds has changed, so the memory of it is stale.
459
- this.#held.delete(key);
460
- if (index >= 0) {
461
- if (mediaRanges) {
462
- const byIndex = this.#mediaRanges.get(key) ?? new Map();
463
- byIndex.set(index, mediaRanges);
464
- this.#mediaRanges.set(key, byIndex);
465
- }
466
- this.announce(key, index);
467
- for (const listener of this.#publishedListeners) {
468
- listener(key, index);
469
- }
470
- }
471
- return served;
472
- }
473
-
474
- mediaRangesOf(key, index, { startSeconds = 0 } = {}) {
475
- const known = this.#mediaRanges.get(key)?.get(index);
476
- if (known) return known;
477
- const format = this.#formats.get(key);
478
- if (!format?.readMediaRanges) return undefined;
479
- const filePath = this.pathOf(key, index);
480
- if (!filePath) return undefined;
481
- try {
482
- let initBytes = this.initOf(key);
483
- if (!initBytes && format.initFileName) {
484
- try {
485
- initBytes = readFileSync(path.join(this.pathFor(key), format.initFileName));
486
- } catch {
487
- // A header not yet produced is not an empty media interval.
488
- }
489
- }
490
- const ranges = format.readMediaRanges(readFileSync(filePath), { initBytes, startSeconds });
491
- if (!ranges) return undefined;
492
- const byIndex = this.#mediaRanges.get(key) ?? new Map();
493
- byIndex.set(index, ranges);
494
- this.#mediaRanges.set(key, byIndex);
495
- return ranges;
496
- } catch {
497
- return [];
498
- }
499
- }
500
-
501
- /**
502
- * The init segment kept for this output, or null until one was read whole.
503
- *
504
- * @param {string} key
505
- * @returns {Buffer | null}
506
- */
507
- initOf(key) {
508
- return this.#inits.get(key) ?? null;
509
- }
510
-
511
- /**
512
- * Keep the init segment served for this output. The first whole one stands;
513
- * an empty read is not an init (ffmpeg creates the file before it writes the
514
- * header into it) and is refused.
515
- *
516
- * @param {string} key
517
- * @param {Buffer | null} bytes
518
- * @returns {Buffer | null} The init that now stands for this output.
519
- */
520
- keepInit(key, bytes) {
521
- if (!this.#inits.has(key) && bytes && bytes.length > 0) {
522
- this.#inits.set(key, bytes);
523
- }
524
- return this.initOf(key);
525
- }
526
-
527
- /**
528
- * Be told of every segment published: what the store holds has grown, which
529
- * is when whether it still fits its share is worth asking.
530
- *
531
- * @param {(key: string, index: number) => void} listener
532
- * @returns {() => void} Stop being told.
533
- */
534
- onPublished(listener) {
535
- this.#publishedListeners.add(listener);
536
- return () => this.#publishedListeners.delete(listener);
537
- }
538
-
539
- /**
540
- * Watch this output's directory, so a piece closed by ANY writer is an event.
541
- *
542
- * WHY IT EXISTS, and it is one fault rather than a precaution. A piece is
543
- * finished in one of two ways, and only one of them said so. The `segment`
544
- * muxer writes under a working name and reports the closure on a channel of
545
- * its own (`publish`); the `hls` muxer writes through a temporary name of its
546
- * own (`+temp_file`) and reports nothing, so the file simply appears under the
547
- * name it is served as. That branch is taken by every re-encoded output on the
548
- * even grid — the ordinary quality step — and for those `waitFor` could only
549
- * end on its deadline: the segment was on disk and the request that wanted it
550
- * went on waiting, up to a full minute, and was then answered 503.
551
- *
552
- * So the store watches what it owns. The rename into place moves the
553
- * directory's modification time, the kernel says so, and both branches reach
554
- * one statement: this number is now present. Nothing polls.
555
- *
556
- * Idempotent, and started from the two places that need it — the directory
557
- * being made for a run, and a wait beginning on an output this process has
558
- * adopted rather than made.
559
- *
560
- * WHOEVER REMOVES ONE OF THESE DIRECTORIES GOES THROUGH `drop`, which stops
561
- * the watch first. On Linux, where this runs, an open watch does not prevent
562
- * a directory being removed; on Windows it does, and `rmSync` then retries
563
- * until it gives up — measured, a check that removed a store's directory
564
- * behind its back took 110 s instead of 3.
565
- *
566
- * @param {string} key
567
- * @returns {void}
568
- */
569
- #watch(key) {
570
- if (this.#watchers.has(key)) {
571
- return;
572
- }
573
- const dir = path.join(this.#root, directoryNameFor(key));
574
- // Nothing has been made for this output yet. A wait that begins first will
575
- // ask again — `directoryFor` is what a run calls before it writes — and
576
- // asking the kernel to watch what is not there throws, once for every wait
577
- // that begins meanwhile.
578
- if (!existsSync(dir)) {
579
- return;
580
- }
581
- try {
582
- const watcher = watch(dir, { persistent: false }, (_event, name) => {
583
- this.#noticed(key, typeof name === "string" ? name : null);
584
- });
585
- // A directory that goes away takes its watch with it rather than leaving
586
- // an error nobody reads.
587
- watcher.on("error", () => this.#unwatch(key));
588
- this.#watchers.set(key, watcher);
589
- } catch (error) {
590
- // Watching is how a wait ends early, not how a segment is found: every
591
- // reader still reads the disk. Said rather than swallowed, because a
592
- // store that cannot watch holds each request for its whole deadline, and
593
- // that is worth knowing before the field says it.
594
- this.#logger?.warn?.(
595
- `segment store: cannot watch ${key.slice(0, 60)}: ` +
596
- `${error instanceof Error ? error.message : String(error)}`
597
- );
598
- }
599
- }
600
-
601
- /**
602
- * Stop watching one output's directory.
603
- *
604
- * @param {string} key
605
- * @returns {void}
606
- */
607
- #unwatch(key) {
608
- const watcher = this.#watchers.get(key);
609
- if (!watcher) {
610
- return;
611
- }
612
- this.#watchers.delete(key);
613
- try {
614
- watcher.close();
615
- } catch {
616
- // Already closed with its directory.
617
- }
618
- }
619
-
620
- /**
621
- * The directory moved: say what is now there.
622
- *
623
- * A REMOVAL MOVES IT TOO, so the name is checked against the disk before
624
- * anything is announced — otherwise clearing up after a dead run would read
625
- * as that run's pieces arriving.
626
- *
627
- * @param {string} key
628
- * @param {string | null} name - What changed, where the platform says.
629
- * @returns {void}
630
- */
631
- #noticed(key, name) {
632
- // WHAT WAS READ OF THIS DIRECTORY IS STALE. Dropped rather than re-read:
633
- // re-reading HERE is quadratic in the pieces of a film — one listing per
634
- // file written, of a directory that grows with every file, measured at 110 s
635
- // against 3 s over 482 pieces — while dropping costs one listing to whoever
636
- // asks next, which is bounded by the questions and not by the writes.
637
- //
638
- // IT IS NOT ENOUGH TO LET `refresh` NOTICE BY ITSELF. It re-reads when the
639
- // directory's modification time has moved, and that time is stated in
640
- // milliseconds: a file written in the same millisecond as the last reading
641
- // leaves it unmoved, and the piece is then reported absent although it is
642
- // there. That is the hazard `refresh` documents and `publish` has always
643
- // guarded against by dropping the memory.
644
- this.#held.delete(key);
645
- const format = this.#formats.get(key);
646
- const dir = path.join(this.#root, directoryNameFor(key));
647
- const index = name && format?.isSegmentFileName?.(name)
648
- ? (format.segmentIndexFromName?.(name) ?? -1)
649
- : -1;
650
- if (index >= 0) {
651
- // A REMOVAL MOVES THE DIRECTORY TOO, so the one file is asked about
652
- // before anything is announced — otherwise clearing up after a dead run
653
- // would read as that run's pieces arriving.
654
- if (!existsSync(path.join(dir, name))) {
655
- return;
656
- }
657
- this.#announceArrival(key, index);
658
- return;
659
- }
660
- if (name) {
661
- return;
662
- }
663
- // The platform named nothing. Then whoever is waiting asks about their own
664
- // piece, which is the same question with the same answer and no guessing.
665
- const byIndex = this.#waiters.get(key);
666
- if (!byIndex || !format?.segmentFileName) {
667
- return;
668
- }
669
- for (const waiting of [...byIndex.keys()]) {
670
- if (existsSync(path.join(dir, format.segmentFileName(waiting)))) {
671
- this.#announceArrival(key, waiting);
672
- }
673
- }
674
- }
675
-
676
- /**
677
- * One statement that a number is now present here.
678
- *
679
- * @param {string} key
680
- * @param {number} index
681
- * @returns {void}
682
- */
683
- #announceArrival(key, index) {
684
- this.announce(key, index);
685
- for (const listener of this.#publishedListeners) {
686
- listener(key, index);
687
- }
688
- }
689
-
690
- /**
691
- * Announce that a segment is now present. This stores no readiness state;
692
- * the file under its served name remains the only fact.
693
- *
694
- * @param {string} key
695
- * @param {number} index
696
- */
697
- announce(key, index) {
698
- const byIndex = this.#waiters.get(key);
699
- const waiting = byIndex?.get(index);
700
- if (!waiting) return;
701
- byIndex.delete(index);
702
- if (byIndex.size === 0) this.#waiters.delete(key);
703
- for (const resolve of waiting) resolve(true);
704
- }
705
-
706
- /**
707
- * How many requests are waiting on this output right now.
708
- *
709
- * The store's own state, and the one fact that says a wait EXISTS rather than
710
- * is about to: a check that ends a wait it has not yet registered proves
711
- * nothing, and passes just as well without the thing it is checking.
712
- *
713
- * @param {string} key
714
- * @returns {number}
715
- */
716
- waitingFor(key) {
717
- let waiting = 0;
718
- for (const holders of this.#waiters.get(key)?.values() ?? []) {
719
- waiting += holders.size;
720
- }
721
- return waiting;
722
- }
723
-
724
- /**
725
- * Wait for a segment publication event or one deadline. This does not poll
726
- * the filesystem.
727
- *
728
- * @param {string} key
729
- * @param {number} index
730
- * @param {number} timeoutMs - May be infinite: then only the publication or
731
- * `cancelled` ends the wait.
732
- * @param {Promise<unknown> | null} [cancelled] - Settles when whoever waits
733
- * has gone, so the waiter does not outlive them.
734
- * @returns {Promise<boolean>}
735
- */
736
- waitFor(key, index, timeoutMs, cancelled = null) {
737
- if (this.pathOf(key, index)) return Promise.resolve(true);
738
- // An output adopted from a previous process has a directory nobody made in
739
- // this one, so the wait itself is the first moment it is watched.
740
- this.#watch(key);
741
- return new Promise((resolve) => {
742
- const byIndex = this.#waiters.get(key) ?? new Map();
743
- const waiting = byIndex.get(index) ?? new Set();
744
- this.#waiters.set(key, byIndex);
745
- byIndex.set(index, waiting);
746
- let settled = false;
747
- const finish = (published) => {
748
- if (settled) return;
749
- settled = true;
750
- if (timer) clearTimeout(timer);
751
- waiting.delete(finish);
752
- // Removed only if they are still the sets this waiter joined: an
753
- // announcement takes the set out before calling its waiters, and a new
754
- // waiter may have registered a fresh one under the same number since.
755
- if (waiting.size === 0 && byIndex.get(index) === waiting) byIndex.delete(index);
756
- if (byIndex.size === 0 && this.#waiters.get(key) === byIndex) this.#waiters.delete(key);
757
- resolve(published);
758
- };
759
- waiting.add(finish);
760
- // No deadline is a wait that ends only on the publication: a timer given
761
- // an infinite delay fires at once, which would turn the wait into a poll.
762
- const timer = Number.isFinite(timeoutMs) ? setTimeout(() => finish(false), Math.max(0, timeoutMs)) : null;
763
- timer?.unref?.();
764
- cancelled?.then(() => finish(false));
765
- // Publication may have landed after the first path check and before this
766
- // waiter was registered. Recheck once after registration so that window
767
- // cannot turn a present segment into a full-deadline wait.
768
- if (this.pathOf(key, index)) finish(true);
769
- });
770
- }
771
-
772
- /**
773
- * Clear up after a run that has ended: remove what it left unfinished.
774
- *
775
- * Every file it left open carries its own tag, so this is a name match and
776
- * nothing else — no stretch to search, no bytes to judge, and no chance of
777
- * removing a piece somebody else closed.
778
- *
779
- * WHAT IT REPLACED, because the difference is the whole of the rename design.
780
- * It used to take the highest SERVED name inside the stretch the ended run was
781
- * given and judge whether its bytes looked usable — a guess, needed only
782
- * because an unfinished piece was indistinguishable from a finished one. Under
783
- * the naming rule it would now remove a complete segment: the highest served
784
- * name in a dead run's stretch is a piece it closed.
785
- *
786
- * @param {string} key
787
- * @param {number} startedAt - The run's first segment number, which is its tag.
788
- * @returns {number} How many unfinished pieces were removed.
789
- */
790
- clearUpAfter(key, startedAt) {
791
- const format = this.#formats.get(key);
792
- if (!format) {
793
- return 0;
794
- }
795
- const removed = this.#sweepUnfinished(
796
- key,
797
- this.directoryFor(key),
798
- format,
799
- String(Number.isInteger(startedAt) && startedAt > 0 ? startedAt : 0)
800
- );
801
- if (removed > 0) {
802
- this.#logger?.info?.(
803
- `segment store: cleared up ${removed} unfinished piece(s) of the run at ` +
804
- `#${startedAt} on ${key.slice(0, 60)}`
805
- );
806
- }
807
- return removed;
808
- }
809
-
810
-
811
- /**
812
- * Where the file of this name is, or null when this output does not hold it.
813
- *
814
- * By NAME rather than by number, because that is what a request carries. The
815
- * conversion is the store's own business: it is told how this output's files
816
- * are named (`useFormat`) and therefore needs nobody else to read one.
817
- *
818
- * @param {string} key
819
- * @param {string} fileName
820
- * @returns {string | null}
821
- */
822
- pathOfName(key, fileName) {
823
- const format = this.#formats.get(key);
824
- const index = format?.segmentIndexFromName?.(fileName) ?? -1;
825
- if (!Number.isInteger(index) || index < 0) {
826
- return null;
827
- }
828
- return this.pathOf(key, index);
829
- }
830
-
831
- /**
832
- * Forget what this output's directory was last seen to hold.
833
- *
834
- * What is held is re-read when the directory's modification time moves, which
835
- * is how a quiet request costs one `stat`. A caller that has just removed a
836
- * file and asks again in the same tick would otherwise be told it is still
837
- * there — the time has moved, but only by as much as the clock's own
838
- * granularity, and that is not something to depend on.
839
- *
840
- * @param {string} key
841
- * @returns {void}
842
- */
843
- forget(key) {
844
- this.#held.delete(key);
845
- }
846
-
847
- /**
848
- * Where a segment is, or null when this output does not hold it.
849
- *
850
- * @param {string} key
851
- * @param {number} index
852
- * @returns {string | null}
853
- */
854
- pathOf(key, index) {
855
- this.#touched.set(key, this.#now());
856
- return this.refresh(key).byNumber.get(index) ?? null;
857
- }
858
-
859
- /**
860
- * What every output in the store weighs.
861
- *
862
- * @returns {{ outputs: number, bytes: number }}
863
- */
864
- stats() {
865
- let bytes = 0;
866
- for (const key of this.#formats.keys()) {
867
- bytes += this.refresh(key).bytes;
868
- }
869
- return { outputs: this.#formats.size, bytes };
870
- }
871
-
872
- /**
873
- * Throw one output's segments away.
874
- *
875
- * @param {string} key
876
- * @param {string} because
877
- */
878
- drop(key, because) {
879
- const dir = path.join(this.#root, directoryNameFor(key));
880
- this.#unwatch(key);
881
- try {
882
- rmSync(dir, { recursive: true, force: true });
883
- } catch {
884
- // Already gone, or in use; the next sweep sees it either way.
885
- }
886
- this.#held.delete(key);
887
- this.#formats.delete(key);
888
- this.#mediaRanges.delete(key);
889
- this.#touched.delete(key);
890
- this.#inits.delete(key);
891
- this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
892
- }
893
-
894
- /**
895
- * When this output was last asked for, or null where it has never been.
896
- *
897
- * The one reading that makes the keeping period measurable rather than
898
- * guessed: a session opened on an output this answers for IS a return, and
899
- * this is its age.
900
- *
901
- * @param {string} key
902
- * @returns {number | null}
903
- */
904
- lastReadAt(key) {
905
- return this.#touched.get(key) ?? null;
906
- }
907
-
908
- /**
909
- * Throw away everything this store owns.
910
- *
911
- * For a clean exit. What is left on disk afterwards is by definition from a
912
- * kill, which is the case the startup sweep exists for — and without this the
913
- * sweep adopts, the exit leaves, and the next start adopts again, for ever.
914
- *
915
- * WHAT IT OWNS IS ITS DIRECTORIES, NOT THE ROOT. The root is a fixed path —
916
- * `os.tmpdir()/torrent-tv-hls` — so it belongs to the machine and not to any
917
- * one process, and this used to remove it whole. The comment that stood here
918
- * named the hazard exactly ("another process may share the root") and did it
919
- * anyway.
920
- *
921
- * Measured 2026-09-14, where a second process is ordinary: `node --test` runs
922
- * every test file in its own process in parallel, and four files call this
923
- * forty times between them. `segment-serve-wiring.test.js` then reported a
924
- * segment missing that it had written itself a moment earlier, 2 to 4 checks
925
- * per run, never the same ones — because another process had deleted the
926
- * directory under it. The same thing happens to two proxies on one machine:
927
- * a bare npm run beside the addon, or the wedge stand beside the product, and
928
- * one of them exiting takes the other's segments mid-serve.
929
- *
930
- * A store removes what it made. It does not remove the container it made it
931
- * in, and an empty root costs nothing.
932
- *
933
- * @param {string} because
934
- * @returns {number} How many outputs went.
935
- */
936
- dropAll(because) {
937
- let dropped = 0;
938
- for (const key of [...this.#formats.keys()]) {
939
- this.drop(key, because);
940
- dropped += 1;
941
- }
942
- // EVERY WATCH GOES, not only the watches of outputs that named their file
943
- // format. A directory is made before anything says how its files are named
944
- // — `directoryFor` is what a run calls, `useFormat` is a separate statement
945
- // — so a store can hold a watch on a key that is in no format list, and
946
- // dropping by that list left it open: on Windows the process then does not
947
- // exit, which is how this was found.
948
- for (const key of [...this.#watchers.keys()]) {
949
- this.#unwatch(key);
950
- }
951
- return dropped;
952
- }
953
-
954
- /**
955
- * Keep only what is still being read, and only as much of it as there is room
956
- * for.
957
- *
958
- * **Not tied to a session.** An output is worth keeping while somebody may
959
- * still ask for it, and a session ending says nothing about that: the viewer
960
- * who left may come back, and a viewer who never had a session here may open
961
- * the same film a minute later and find every segment already made. So the
962
- * only question asked is when this output was last READ, and the only bound
963
- * is the disk.
964
- *
965
- * The idle period is deliberately long. Its job is not to reclaim space —
966
- * that is the cap's — but to stop an output nobody has touched in hours from
967
- * sitting there for the life of the process.
968
- *
969
- * TWO RULES, ANSWERING TWO QUESTIONS. Kept apart because they were briefly
970
- * proposed as one and that was wrong: material nobody needs should not sit on
971
- * the owner's disk merely because there is room for it, and material everyone
972
- * needs must still go when there is no room. The first is time, the second is
973
- * space.
974
- *
975
- * WHAT GOES FIRST WHEN THERE IS NO ROOM is decided by where the viewers are,
976
- * not by when a directory was last read. Behind every viewer of an output is
977
- * material that has been played and will not be asked for again unless
978
- * somebody seeks back; ahead of the furthest viewer is material that will be
979
- * asked for, eventually. So the order is: outputs nobody is watching at all,
980
- * then what lies behind the earliest viewer, furthest behind first, then what
981
- * lies ahead of the furthest viewer, furthest ahead first. It is the priority
982
- * map's own order read from the other end.
983
- *
984
- * A segment a viewer is standing on is never a victim.
985
- *
986
- * @param {object} params
987
- * @param {number} params.idleMs - Untouched for longer than this, and it goes.
988
- * @param {number} params.maxBytes - The most the whole store may hold.
989
- * @param {(key: string) => number[]} [params.viewersAt] - Where the viewers of
990
- * an output stand, as segment numbers. An empty answer means nobody is
991
- * watching it, which is what makes its segments the first to go. Absent, the
992
- * store has nothing to order by and falls back to the oldest directory —
993
- * which is what it did before it could be told.
994
- * @returns {{ droppedIdle: number, droppedForRoom: number, segmentsRemoved: number, bytes: number }}
995
- */
996
- enforce({ idleMs, maxBytes, viewersAt = null }) {
997
- const now = this.#now();
998
- let droppedIdle = 0;
999
- for (const [key, touchedAt] of [...this.#touched]) {
1000
- if (now - touchedAt > idleMs) {
1001
- this.drop(key, `nothing has read it for ${Math.round((now - touchedAt) / 60000)} minutes`);
1002
- droppedIdle += 1;
1003
- }
1004
- }
1005
- let held = this.stats().bytes;
1006
- if (!Number.isFinite(maxBytes) || maxBytes <= 0 || held <= maxBytes) {
1007
- return { droppedIdle, droppedForRoom: 0, segmentsRemoved: 0, bytes: held };
1008
- }
1009
- if (typeof viewersAt !== "function") {
1010
- let droppedForRoom = 0;
1011
- const byAge = [...this.#touched.entries()].sort((left, right) => left[1] - right[1]);
1012
- for (const [key] of byAge) {
1013
- if (held <= maxBytes) {
1014
- break;
1015
- }
1016
- const size = this.refresh(key).bytes;
1017
- this.drop(key, `the store is over its ${(maxBytes / 1073741824).toFixed(1)}GB allowance`);
1018
- held -= size;
1019
- droppedForRoom += 1;
1020
- }
1021
- return { droppedIdle, droppedForRoom, segmentsRemoved: 0, bytes: held };
1022
- }
1023
-
1024
- let segmentsRemoved = 0;
1025
- for (const victim of this.#leastWantedFirst(viewersAt)) {
1026
- if (held <= maxBytes) {
1027
- break;
1028
- }
1029
- held -= this.#removeSegment(victim.key, victim.index);
1030
- segmentsRemoved += 1;
1031
- }
1032
- if (segmentsRemoved > 0) {
1033
- this.#logger.info(
1034
- `segment-store removed ${segmentsRemoved} segment(s) for room: ` +
1035
- `${megabytes(held)} of ${megabytes(maxBytes)} allowed`
1036
- );
1037
- }
1038
- return { droppedIdle, droppedForRoom: 0, segmentsRemoved, bytes: held };
1039
- }
1040
-
1041
- /**
1042
- * Every segment in the store, least wanted first.
1043
- *
1044
- * @param {(key: string) => number[]} viewersAt
1045
- * @returns {{ key: string, index: number }[]}
1046
- */
1047
- #leastWantedFirst(viewersAt) {
1048
- const candidates = [];
1049
- for (const key of this.#formats.keys()) {
1050
- const positions = (viewersAt(key) ?? []).filter((at) => Number.isInteger(at));
1051
- const earliest = positions.length > 0 ? Math.min(...positions) : null;
1052
- const furthest = positions.length > 0 ? Math.max(...positions) : null;
1053
- for (const index of this.refresh(key).byNumber.keys()) {
1054
- if (earliest === null) {
1055
- // Nobody is watching this output at all. Everything it holds is worth
1056
- // less than anything somebody is on their way to.
1057
- candidates.push({ key, index, rank: 0, distance: index });
1058
- continue;
1059
- }
1060
- if (positions.includes(index)) {
1061
- continue;
1062
- }
1063
- if (index < earliest) {
1064
- candidates.push({ key, index, rank: 1, distance: earliest - index });
1065
- } else {
1066
- candidates.push({ key, index, rank: 2, distance: index - /** @type {number} */ (furthest) });
1067
- }
1068
- }
1069
- }
1070
- return candidates
1071
- .sort((left, right) => (left.rank !== right.rank ? left.rank - right.rank : right.distance - left.distance))
1072
- .map(({ key, index }) => ({ key, index }));
1073
- }
1074
-
1075
- /**
1076
- * Take one segment off the disk.
1077
- *
1078
- * @param {string} key
1079
- * @param {number} index
1080
- * @returns {number} What it weighed.
1081
- */
1082
- #removeSegment(key, index) {
1083
- const full = this.refresh(key).byNumber.get(index);
1084
- if (!full) {
1085
- return 0;
1086
- }
1087
- let size = 0;
1088
- try {
1089
- size = statSync(full, { throwIfNoEntry: false })?.size ?? 0;
1090
- rmSync(full, { force: true });
1091
- } catch {
1092
- // Gone already, or refused. The next refresh reports what is really there.
1093
- }
1094
- this.#held.delete(key);
1095
- return size;
1096
- }
1097
-
1098
- /**
1099
- * What a previous life of this process left on the disk.
1100
- *
1101
- * This is the only record there is of a death nobody saw. The kernel kills
1102
- * this process often enough to matter — two kills in one viewing on
1103
- * 2026-09-02 — and when it does, no exit handler runs, nothing is cleared up,
1104
- * and memory is reclaimed while the disk is not: `/tmp` in the addon
1105
- * container is on the overlay filesystem, measured 2026-09-04, so the files
1106
- * survive the process and its restart.
1107
- *
1108
- * So the sweep reports rather than deletes quietly. What it finds is the
1109
- * evidence, and every directory it names is one abnormal ending that went
1110
- * unrecorded.
1111
- *
1112
- * @returns {{ directories: number, segments: number, bytes: number, unidentified: number, found: {key: string, dir: string, segments: number, bytes: number}[] }}
1113
- */
1114
- sweep() {
1115
- const found = [];
1116
- let unidentified = 0;
1117
- let names = [];
1118
- try {
1119
- names = readdirSync(this.#root);
1120
- } catch {
1121
- return { directories: 0, segments: 0, bytes: 0, unidentified: 0, found: [] };
1122
- }
1123
- for (const name of names) {
1124
- const dir = path.join(this.#root, name);
1125
- let key = "";
1126
- try {
1127
- if (!statSync(dir).isDirectory()) {
1128
- continue;
1129
- }
1130
- key = readFileSync(path.join(dir, KEY_FILE), "utf8").trim();
1131
- } catch {
1132
- key = "";
1133
- }
1134
- let segments = 0;
1135
- let bytes = 0;
1136
- try {
1137
- for (const entry of readdirSync(dir)) {
1138
- if (entry === KEY_FILE) {
1139
- continue;
1140
- }
1141
- try {
1142
- bytes += statSync(path.join(dir, entry)).size;
1143
- segments += 1;
1144
- } catch {
1145
- // Vanished between the listing and the question.
1146
- }
1147
- }
1148
- } catch {
1149
- continue;
1150
- }
1151
- if (!key) {
1152
- // A directory that cannot say what it holds is from before this layer,
1153
- // or its key file did not survive. Nothing can be served out of it,
1154
- // because nothing can match it to a request.
1155
- unidentified += 1;
1156
- }
1157
- found.push({ key, dir, segments, bytes });
1158
- }
1159
- const totals = found.reduce(
1160
- (sum, entry) => ({ segments: sum.segments + entry.segments, bytes: sum.bytes + entry.bytes }),
1161
- { segments: 0, bytes: 0 }
1162
- );
1163
- if (found.length > 0) {
1164
- this.#logger.info(
1165
- `segment-store startup sweep: ${found.length} directories left by a previous run, ` +
1166
- `${totals.segments} segments, ${(totals.bytes / 1048576).toFixed(1)}MB, ` +
1167
- `${unidentified} of them unidentifiable — each one is an encoder that ended ` +
1168
- "without anything recording why"
1169
- );
1170
- }
1171
- return {
1172
- directories: found.length,
1173
- segments: totals.segments,
1174
- bytes: totals.bytes,
1175
- unidentified,
1176
- found
1177
- };
1178
- }
1179
-
1180
- /**
1181
- * Take back what a previous life left: keep what is proven, remove the rest.
1182
- *
1183
- * Deliberately not "throw everything away". A killed process leaves material
1184
- * that is valid by construction — a copied segment's bytes depend only on the
1185
- * source — and re-encoding it costs the machine that is already known to be
1186
- * short of processor. What cannot be kept is a directory that cannot name
1187
- * itself, and the one file per directory whose closure nothing proves.
1188
- *
1189
- * @param {(key: string) => object | null} formatFor - How to read the file
1190
- * names of an output, given its key. Null when this proxy cannot serve that
1191
- * output at all, and then the directory goes.
1192
- * @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
1193
- */
1194
- adoptWhatSurvived(formatFor) {
1195
- const swept = this.sweep();
1196
- let adopted = 0;
1197
- let dropped = 0;
1198
- let unprovenRemoved = 0;
1199
- for (const entry of swept.found) {
1200
- const format = entry.key ? formatFor(entry.key) : null;
1201
- if (!format) {
1202
- try {
1203
- rmSync(entry.dir, { recursive: true, force: true });
1204
- } catch {
1205
- // Leave it; the next sweep reports it again.
1206
- }
1207
- dropped += 1;
1208
- this.#logger.info(
1209
- `segment-store discarded ${path.basename(entry.dir)}: ` +
1210
- (entry.key ? "this proxy cannot serve that output" : "it does not say what it holds")
1211
- );
1212
- continue;
1213
- }
1214
- this.#formats.set(entry.key, format);
1215
- // EVERY SEGMENT FOUND IS COMPLETE, because a piece is given its served
1216
- // name only once the encoder has said it is closed. So there is nothing to
1217
- // prove here and nothing to un-prove: what the directory holds under
1218
- // served names is what a killed process finished.
1219
- //
1220
- // What it may also hold is pieces it was in the middle of, under their
1221
- // working names, and those are swept — the file a run was writing when the
1222
- // kernel took the process is exactly this.
1223
- unprovenRemoved += this.#sweepUnfinished(entry.key, entry.dir, format);
1224
- adopted += 1;
1225
- this.#logger.info(
1226
- `segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
1227
- "segments a killed process had already finished"
1228
- );
1229
- }
1230
- return { adopted, dropped, unprovenRemoved };
1231
- }
1232
- }
1233
-
1234
- /**
1235
- * @param {number} bytes
1236
- * @returns {string}
1237
- */
1238
- function megabytes(bytes) {
1239
- return `${Math.round(Math.max(0, bytes) / (1024 * 1024))}MB`;
1240
- }
1
+ /**
2
+ * @file One store of produced segments for the whole proxy, addressed by what
3
+ * the segments ARE rather than by who made them.
4
+ *
5
+ * Until now a segment lived under the id of the session whose encoder wrote it
6
+ * — under that session's own id, in a directory of that run's own — and the index
7
+ * over it was built per session, so a segment was visible only inside the
8
+ * session that made it. Two viewers of one film got two sessions with
9
+ * byte-identical output and neither could see the other's work (measured
10
+ * 2026-09-03, `research/two-viewers-one-file-2026-09-03.md`).
11
+ *
12
+ * Here the address is the output's own parameters. Which viewer asked never
13
+ * enters it, and neither does which encoder produced the bytes: a segment is
14
+ * the same segment whoever made it.
15
+ *
16
+ * **The directory carries its own identity.** Its NAME is a digest, because the
17
+ * key contains characters a path may not; the key itself is written inside it,
18
+ * in `key.txt`. That is what lets a new process, started after this one was
19
+ * killed, work out what it is looking at — without it, everything on disk after
20
+ * a kill is unidentifiable and can only be thrown away.
21
+ *
22
+ * **What proves a segment is closed: its NAME.** A piece being written is called
23
+ * something else — `making-40-00057.mp4`, tagged with the run writing it — and
24
+ * takes its served name only when the
25
+ * encoder has said it is closed, which it does on a channel of its own
26
+ * (`-segment_list pipe:3`). The `hls` branch needs nothing extra: its muxer
27
+ * writes through a temporary name of its own, so its files appear under their
28
+ * final name whole. One rule for both, and true whether or not this process is
29
+ * alive: **a file under its served name is complete.**
30
+ *
31
+ * It was "a segment is closed when the NEXT number exists". That is true of one
32
+ * writer walking forward and false the moment two runs share an output, because
33
+ * the next file is then written by another process while this one is still open
34
+ * — and two runs on one output is not a rare state, it is what the plan gives an
35
+ * output whenever it places a second encoder. Field 2026-09-08:
36
+ * `segment-00057.mp4` served at 2 268 361 bytes and then at 4 510 940, exactly
37
+ * half; the browser appended the half and refused the whole for the rest of the
38
+ * session, `bufferAppendError` fourteen times with the picture frozen at
39
+ * 319.66 s. `segment-00055.mp4` the same, 211 957 against 2 620 617.
40
+ */
41
+
42
+ import { createHash } from "node:crypto";
43
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from "node:fs";
44
+ import { rmSync } from "node:fs";
45
+ import os from "node:os";
46
+ import path from "node:path";
47
+
48
+ /** Where every output's segments live. One root for the process. */
49
+ export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
50
+
51
+ /** The file inside each directory that says which output it holds. */
52
+ const KEY_FILE = "key.txt";
53
+
54
+ /**
55
+ * The directory name for an output key.
56
+ *
57
+ * A digest rather than the key itself: the key carries `:` and `/`, which a
58
+ * path may not, and sanitising them would make two different keys collide.
59
+ * Sixteen hex characters is enough that a collision is not a thing that
60
+ * happens, and short enough to read in a log line.
61
+ *
62
+ * @param {string} key
63
+ * @returns {string}
64
+ */
65
+ export function directoryNameFor(key) {
66
+ return createHash("sha256").update(String(key)).digest("hex").slice(0, 16);
67
+ }
68
+
69
+ /**
70
+ * What one output's directory holds, as last read.
71
+ *
72
+ * @typedef {object} HeldContents
73
+ * @property {number} readAt - The directory's modification time when it was read.
74
+ * @property {Map<number, string>} byNumber - Segment number to full path.
75
+ * @property {number} bytes - What those files weigh.
76
+ * @property {number} unproven - The highest number, whose closure nothing
77
+ * proves, or -1 when the directory holds no segments.
78
+ */
79
+
80
+ export class SegmentStore {
81
+ /** @type {string} */
82
+ #root;
83
+
84
+ /** Output key → what its directory holds. @type {Map<string, HeldContents>} */
85
+ #held = new Map();
86
+ #mediaRanges = new Map();
87
+
88
+ /** Output key → how to read its file names. @type {Map<string, object>} */
89
+ #formats = new Map();
90
+
91
+ /** Output key → when it was last asked for. @type {Map<string, number>} */
92
+ #touched = new Map();
93
+
94
+ /**
95
+ * Output key → the init segment served for it, once one has been read whole.
96
+ *
97
+ * A property of the output's produced material, so it lives with that
98
+ * material and goes when it goes. The player fetches it once and never again,
99
+ * so the first whole one is kept and every later request gets the same bytes,
100
+ * even though a re-encoding run rewrites the file on disk.
101
+ *
102
+ * @type {Map<string, Buffer>}
103
+ */
104
+ #inits = new Map();
105
+
106
+ /** Told of every segment published, whatever it is. @type {Set<(key: string, index: number) => void>} */
107
+ #publishedListeners = new Set();
108
+
109
+ /** Segment publication waiters by output key and segment number. @type {Map<string, Map<number, Set<Function>>>} */
110
+ #waiters = new Map();
111
+
112
+ /** Output key → the watch on its directory. @type {Map<string, import("node:fs").FSWatcher>} */
113
+ #watchers = new Map();
114
+
115
+ /** @type {{ info: Function, warn: Function }} */
116
+ #logger;
117
+
118
+ /** @type {() => number} */
119
+ #now;
120
+
121
+ /**
122
+ * @param {object} [params]
123
+ * @param {string} [params.root] - Where the directories live.
124
+ * @param {{ info: Function, warn: Function }} [params.logger]
125
+ * @param {() => number} [params.now]
126
+ */
127
+ constructor({ root = DEFAULT_STORE_ROOT, logger = null, now = Date.now } = {}) {
128
+ this.#root = root;
129
+ this.#logger = logger ?? { info: () => {}, warn: () => {} };
130
+ this.#now = now;
131
+ }
132
+
133
+ /** @returns {string} */
134
+ get root() {
135
+ return this.#root;
136
+ }
137
+
138
+ /**
139
+ * Where this output's segments live, without making anything.
140
+ *
141
+ * Separate from {@link directoryFor} because a session works out its path
142
+ * long before it is sure it will exist: a probe or a keyframe read between
143
+ * the two can still fail, and a directory made in advance of that is a
144
+ * leftover nothing tracks — proxy 2.9.101 failed on every request and its
145
+ * abandoned directories were the only trace on disk.
146
+ *
147
+ * @param {string} key - `OutputSpec.toKey()`.
148
+ * @returns {string}
149
+ */
150
+ pathFor(key) {
151
+ return path.join(this.#root, directoryNameFor(key));
152
+ }
153
+
154
+ /**
155
+ * The directory this output's segments live in, made if it is not there.
156
+ *
157
+ * @param {string} key - `OutputSpec.toKey()`.
158
+ * @returns {string}
159
+ */
160
+ directoryFor(key) {
161
+ const dir = path.join(this.#root, directoryNameFor(key));
162
+ if (!existsSync(dir)) {
163
+ mkdirSync(dir, { recursive: true });
164
+ // What this directory is, for whoever finds it after this process has
165
+ // been killed. Without it the sweep can only throw everything away.
166
+ writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
167
+ }
168
+ this.#touched.set(key, this.#now());
169
+ // Production is about to begin here, and a piece closing in this directory
170
+ // is what every held request is waiting for.
171
+ this.#watch(key);
172
+ return dir;
173
+ }
174
+
175
+ /**
176
+ * Say how this output's files are named, so the store can read its directory.
177
+ *
178
+ * @param {string} key
179
+ * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
180
+ */
181
+ useFormat(key, segmentFormat) {
182
+ if (segmentFormat && typeof segmentFormat.isSegmentFileName === "function") {
183
+ this.#formats.set(key, segmentFormat);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Re-read this output's directory if it has moved since last time.
189
+ *
190
+ * A directory's modification time changes when an entry is added or removed,
191
+ * so a quiet request costs one `stat` rather than a listing.
192
+ *
193
+ * @param {string} key
194
+ * @returns {HeldContents}
195
+ */
196
+ refresh(key) {
197
+ const format = this.#formats.get(key);
198
+ const empty = { readAt: 0, byNumber: new Map(), bytes: 0, unproven: -1, largest: { index: -1, size: 0 } };
199
+ if (!format) {
200
+ return this.#held.get(key) ?? empty;
201
+ }
202
+ const dir = path.join(this.#root, directoryNameFor(key));
203
+ let mtime = 0;
204
+ try {
205
+ mtime = statSync(dir).mtimeMs;
206
+ } catch {
207
+ this.#held.delete(key);
208
+ return empty;
209
+ }
210
+ const known = this.#held.get(key);
211
+ if (known && known.readAt === mtime) {
212
+ return known;
213
+ }
214
+ const byNumber = new Map();
215
+ const sizes = new Map();
216
+ let bytes = 0;
217
+ let highest = -1;
218
+ // The biggest piece and which number it is. What reads it is the figure the
219
+ // master playlist declares: `BANDWIDTH` is the PEAK a link must carry, and
220
+ // the peak of a variable-bitrate source is nothing like its average — the
221
+ // field file of 2026-09-08 ran at 17.1 Mbit/s with a piece at 73. Which
222
+ // number it is matters because pieces are not all the same length, and only
223
+ // whoever holds the cut table can turn bytes into bits per second.
224
+ let largest = { index: -1, size: 0 };
225
+ try {
226
+ for (const name of readdirSync(dir)) {
227
+ if (!format.isSegmentFileName(name)) {
228
+ continue;
229
+ }
230
+ const index = format.segmentIndexFromName(name);
231
+ if (!Number.isInteger(index) || index < 0) {
232
+ continue;
233
+ }
234
+ const full = path.join(dir, name);
235
+ let size = 0;
236
+ try {
237
+ size = statSync(full).size;
238
+ } catch {
239
+ continue;
240
+ }
241
+ // A file of no bytes is not a segment, whatever its name says. It is
242
+ // what a run killed the instant after opening its next piece leaves,
243
+ // and taking it for a segment once convinced the look-ahead that a
244
+ // number had been produced and kept the encoder stopped for it.
245
+ if (size <= 0) {
246
+ continue;
247
+ }
248
+ byNumber.set(index, full);
249
+ sizes.set(index, size);
250
+ bytes += size;
251
+ if (index > highest) {
252
+ highest = index;
253
+ }
254
+ if (size > largest.size) {
255
+ largest = { index, size };
256
+ }
257
+ }
258
+ } catch {
259
+ this.#held.delete(key);
260
+ return empty;
261
+ }
262
+ const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest };
263
+ this.#held.set(key, contents);
264
+ return contents;
265
+ }
266
+
267
+ /**
268
+ * The segment numbers this output holds that are finished.
269
+ *
270
+ * ONE PROOF, AND IT IS THE PIECE'S OWN NAME. A piece being written is called
271
+ * `making-40-00042.mp4`; it is renamed to `segment-00042.mp4` when its writer
272
+ * says it has closed it, and on the `hls` branch — which has no such channel —
273
+ * the muxer's own `+temp_file` does the same rename for the same reason. So a
274
+ * file under the served name is complete, whoever made it and whenever.
275
+ *
276
+ * WHAT THIS REPLACED, because the difference is what a viewer felt. Closure
277
+ * used to be inferred from the NEXT number existing, which is sound for one
278
+ * writer walking forward and false the moment two runs share an output — and
279
+ * one-piece intervals guarantee that. Field 2026-09-08:
280
+ * `segment-00057.mp4` was served at 2 268 361 bytes and then at 4 510 940, the
281
+ * browser appended the truncated body, and `bufferAppendError` repeated to the
282
+ * end of the log with the picture frozen at 319.66 s. It also left the last
283
+ * piece of every run unprovable for ever, since nothing follows it.
284
+ *
285
+ * @param {string} key
286
+ * @returns {number[]}
287
+ */
288
+ provenNumbers(key) {
289
+ return [...this.refresh(key).byNumber.keys()].sort((left, right) => left - right);
290
+ }
291
+
292
+ /**
293
+ * How many numbered files this output holds, closed or not.
294
+ *
295
+ * What the disk has, against what has been proven closed: the two are printed
296
+ * side by side, so "the files are there and nobody reported them" reads
297
+ * differently from "there is nothing there".
298
+ *
299
+ * @param {string} key
300
+ * @returns {number}
301
+ */
302
+ filesHeld(key) {
303
+ return this.refresh(key).byNumber.size;
304
+ }
305
+
306
+ /**
307
+ * The size in bytes of every finished piece of this output, by number.
308
+ *
309
+ * Read by whoever keeps what an output's segments carried (roadmap item 97,
310
+ * step 14). A reading, not a request: it does not mark the output as read,
311
+ * so it does not keep an output nobody is watching.
312
+ *
313
+ * @param {string} key
314
+ * @returns {Map<number, number>}
315
+ */
316
+ sizesOf(key) {
317
+ return new Map(this.refresh(key).sizes ?? []);
318
+ }
319
+
320
+ /**
321
+ * The biggest piece this output has made, and which number it is.
322
+ *
323
+ * Read by whoever declares the variant's peak rate. Bytes alone cannot say
324
+ * it — pieces are not all the same length — so the number comes with them and
325
+ * whoever holds the cut table does the division.
326
+ *
327
+ * @param {string} key
328
+ * @returns {{ index: number, size: number }} An index of `-1` while nothing
329
+ * has been made, which is a statement and not a zero.
330
+ */
331
+ largestPiece(key) {
332
+ return this.refresh(key).largest ?? { index: -1, size: 0 };
333
+ }
334
+
335
+
336
+ /**
337
+ * Every output whose pieces this store knows how to read: those in use and
338
+ * those a previous life of the process left.
339
+ *
340
+ * @returns {string[]}
341
+ */
342
+ addresses() {
343
+ return [...this.#formats.keys()];
344
+ }
345
+
346
+ /**
347
+ * Whether this piece is finished, and may therefore be served.
348
+ *
349
+ * Its NAME is the proof, and there is no second one: a piece being written is
350
+ * called something else until whoever writes it says it is closed. That holds
351
+ * for a piece a live run is rewriting — the file standing there was closed by
352
+ * somebody, and it is replaced whole or not at all — and for a piece left by an
353
+ * earlier life of this process, which the startup sweep answers the same way.
354
+ *
355
+ * @param {string} key
356
+ * @param {number} index
357
+ * @returns {boolean}
358
+ */
359
+ isClosed(key, index) {
360
+ return this.refresh(key).byNumber.has(index);
361
+ }
362
+
363
+ /**
364
+ * Remove the pieces an output was in the middle of writing.
365
+ *
366
+ * They are under working names, so they were never servable and nothing has
367
+ * to be un-proven — this is disk, not correctness. A process killed by the
368
+ * kernel leaves one per live run, and the kernel takes this process often
369
+ * enough for that to matter.
370
+ *
371
+ * @param {string} key
372
+ * @param {string} dir
373
+ * @param {{ makingTagOf?: (name: string) => string | null }} format
374
+ * @param {string | null} [tag] - One run's own tag, or null for every run's.
375
+ * @returns {number} How many were removed.
376
+ */
377
+ #sweepUnfinished(key, dir, format, tag = null) {
378
+ let removed = 0;
379
+ let names = [];
380
+ try {
381
+ names = readdirSync(dir);
382
+ } catch {
383
+ return 0;
384
+ }
385
+ for (const name of names) {
386
+ const wroteIt = format?.makingTagOf?.(name) ?? null;
387
+ if (wroteIt === null || (tag !== null && wroteIt !== tag)) {
388
+ continue;
389
+ }
390
+ try {
391
+ rmSync(path.join(dir, name), { force: true });
392
+ removed += 1;
393
+ } catch {
394
+ // Then it stays, costing disk and nothing else.
395
+ }
396
+ }
397
+ if (removed > 0) {
398
+ this.#held.delete(key);
399
+ }
400
+ return removed;
401
+ }
402
+
403
+ /**
404
+ * The encoder has closed a piece: give it the name it is served under.
405
+ *
406
+ * One rename inside the output's own directory — one filesystem operation, and
407
+ * atomic there. Before it the file is not a segment and no request can reach
408
+ * it; after it, its existence IS the proof that it is whole, and that is one
409
+ * rule for every branch whether or not our own process is alive.
410
+ *
411
+ * It replaced a rule that served half a segment: a piece was taken as finished
412
+ * when the NEXT file existed. That is true of one writer walking forward and
413
+ * false the moment two runs share an output, because the next file is then
414
+ * written by another process while this one is still open — and two runs on one
415
+ * output is not a rare state, it is what the plan gives an output whenever it
416
+ * places a second encoder.
417
+ *
418
+ * Field 2026-09-08: `segment-00057.mp4` was served at 2 268 361 bytes and then
419
+ * at 4 510 940 — exactly half of it. The browser appended the half and refused
420
+ * the whole for the rest of the session, `bufferAppendError` fourteen times
421
+ * over with the picture frozen at 319.66 s. `segment-00055.mp4` went the same
422
+ * way, 211 957 against 2 620 617.
423
+ *
424
+ * @param {string} key
425
+ * @param {string} makingName - What the encoder called it while writing.
426
+ * @param {{ servedNameOf?: (name: string) => string | null }} format
427
+ * @returns {string | null} The served name, or null where nothing was renamed.
428
+ */
429
+ publish(key, makingName, format, { endSeconds } = {}) {
430
+ const served = format?.servedNameOf?.(makingName) ?? null;
431
+ if (!served) {
432
+ return null;
433
+ }
434
+ const dir = path.join(this.#root, directoryNameFor(key));
435
+ const index = format?.segmentIndexFromName?.(served) ?? -1;
436
+ let mediaRanges = null;
437
+ if (format?.readMediaRanges && index >= 0) {
438
+ try {
439
+ mediaRanges = format.readMediaRanges(readFileSync(path.join(dir, makingName)));
440
+ if (mediaRanges.length === 0) throw new Error("The closed file contains no playable media intervals.");
441
+ const end = format.mediaCoverageEnd?.(mediaRanges);
442
+ if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
443
+ this.#logger?.warn?.(`segment store: refusing short segment ${index}: coverage=${end}s cut=${endSeconds}s`);
444
+ return null;
445
+ }
446
+ } catch (error) {
447
+ this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
448
+ return null;
449
+ }
450
+ }
451
+ try {
452
+ renameSync(path.join(dir, makingName), path.join(dir, served));
453
+ } catch (error) {
454
+ // The file may already be gone — a process killed between closing the
455
+ // piece and this line. Said rather than swallowed: a piece the encoder
456
+ // reported and the disk does not have is worth knowing about.
457
+ this.#logger?.warn?.(
458
+ `segment store: could not publish ${makingName} of ${key.slice(0, 60)}: ` +
459
+ `${error instanceof Error ? error.message : String(error)}`
460
+ );
461
+ return null;
462
+ }
463
+ // What the directory holds has changed, so the memory of it is stale.
464
+ this.#held.delete(key);
465
+ if (index >= 0) {
466
+ if (mediaRanges) {
467
+ const byIndex = this.#mediaRanges.get(key) ?? new Map();
468
+ byIndex.set(index, mediaRanges);
469
+ this.#mediaRanges.set(key, byIndex);
470
+ }
471
+ this.announce(key, index);
472
+ for (const listener of this.#publishedListeners) {
473
+ listener(key, index);
474
+ }
475
+ }
476
+ return served;
477
+ }
478
+
479
+ mediaRangesOf(key, index, { startSeconds = 0, endSeconds } = {}) {
480
+ const known = this.#mediaRanges.get(key)?.get(index);
481
+ const format = this.#formats.get(key);
482
+ if (!format?.readMediaRanges) return undefined;
483
+ const checked = (ranges) => {
484
+ const end = format.mediaCoverageEnd?.(ranges);
485
+ if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
486
+ this.#logger?.warn?.(`segment store: removing short cached segment ${index}: coverage=${end}s cut=${endSeconds}s`);
487
+ this.#removeSegment(key, index);
488
+ return undefined;
489
+ }
490
+ return ranges;
491
+ };
492
+ if (known) return checked(known);
493
+ const filePath = this.pathOf(key, index);
494
+ if (!filePath) return undefined;
495
+ try {
496
+ let initBytes = this.initOf(key);
497
+ if (!initBytes && format.initFileName) {
498
+ try {
499
+ initBytes = readFileSync(path.join(this.pathFor(key), format.initFileName));
500
+ } catch {
501
+ // A header not yet produced is not an empty media interval.
502
+ }
503
+ }
504
+ const ranges = format.readMediaRanges(readFileSync(filePath), { initBytes, startSeconds });
505
+ if (!ranges) return undefined;
506
+ const byIndex = this.#mediaRanges.get(key) ?? new Map();
507
+ byIndex.set(index, ranges);
508
+ this.#mediaRanges.set(key, byIndex);
509
+ return checked(ranges);
510
+ } catch {
511
+ return [];
512
+ }
513
+ }
514
+
515
+ /**
516
+ * The init segment kept for this output, or null until one was read whole.
517
+ *
518
+ * @param {string} key
519
+ * @returns {Buffer | null}
520
+ */
521
+ initOf(key) {
522
+ return this.#inits.get(key) ?? null;
523
+ }
524
+
525
+ /**
526
+ * Keep the init segment served for this output. The first whole one stands;
527
+ * an empty read is not an init (ffmpeg creates the file before it writes the
528
+ * header into it) and is refused.
529
+ *
530
+ * @param {string} key
531
+ * @param {Buffer | null} bytes
532
+ * @returns {Buffer | null} The init that now stands for this output.
533
+ */
534
+ keepInit(key, bytes) {
535
+ if (!this.#inits.has(key) && bytes && bytes.length > 0) {
536
+ this.#inits.set(key, bytes);
537
+ }
538
+ return this.initOf(key);
539
+ }
540
+
541
+ /**
542
+ * Be told of every segment published: what the store holds has grown, which
543
+ * is when whether it still fits its share is worth asking.
544
+ *
545
+ * @param {(key: string, index: number) => void} listener
546
+ * @returns {() => void} Stop being told.
547
+ */
548
+ onPublished(listener) {
549
+ this.#publishedListeners.add(listener);
550
+ return () => this.#publishedListeners.delete(listener);
551
+ }
552
+
553
+ /**
554
+ * Watch this output's directory, so a piece closed by ANY writer is an event.
555
+ *
556
+ * WHY IT EXISTS, and it is one fault rather than a precaution. A piece is
557
+ * finished in one of two ways, and only one of them said so. The `segment`
558
+ * muxer writes under a working name and reports the closure on a channel of
559
+ * its own (`publish`); the `hls` muxer writes through a temporary name of its
560
+ * own (`+temp_file`) and reports nothing, so the file simply appears under the
561
+ * name it is served as. That branch is taken by every re-encoded output on the
562
+ * even grid — the ordinary quality step — and for those `waitFor` could only
563
+ * end on its deadline: the segment was on disk and the request that wanted it
564
+ * went on waiting, up to a full minute, and was then answered 503.
565
+ *
566
+ * So the store watches what it owns. The rename into place moves the
567
+ * directory's modification time, the kernel says so, and both branches reach
568
+ * one statement: this number is now present. Nothing polls.
569
+ *
570
+ * Idempotent, and started from the two places that need it — the directory
571
+ * being made for a run, and a wait beginning on an output this process has
572
+ * adopted rather than made.
573
+ *
574
+ * WHOEVER REMOVES ONE OF THESE DIRECTORIES GOES THROUGH `drop`, which stops
575
+ * the watch first. On Linux, where this runs, an open watch does not prevent
576
+ * a directory being removed; on Windows it does, and `rmSync` then retries
577
+ * until it gives up — measured, a check that removed a store's directory
578
+ * behind its back took 110 s instead of 3.
579
+ *
580
+ * @param {string} key
581
+ * @returns {void}
582
+ */
583
+ #watch(key) {
584
+ if (this.#watchers.has(key)) {
585
+ return;
586
+ }
587
+ const dir = path.join(this.#root, directoryNameFor(key));
588
+ // Nothing has been made for this output yet. A wait that begins first will
589
+ // ask again — `directoryFor` is what a run calls before it writes — and
590
+ // asking the kernel to watch what is not there throws, once for every wait
591
+ // that begins meanwhile.
592
+ if (!existsSync(dir)) {
593
+ return;
594
+ }
595
+ try {
596
+ const watcher = watch(dir, { persistent: false }, (_event, name) => {
597
+ this.#noticed(key, typeof name === "string" ? name : null);
598
+ });
599
+ // A directory that goes away takes its watch with it rather than leaving
600
+ // an error nobody reads.
601
+ watcher.on("error", () => this.#unwatch(key));
602
+ this.#watchers.set(key, watcher);
603
+ } catch (error) {
604
+ // Watching is how a wait ends early, not how a segment is found: every
605
+ // reader still reads the disk. Said rather than swallowed, because a
606
+ // store that cannot watch holds each request for its whole deadline, and
607
+ // that is worth knowing before the field says it.
608
+ this.#logger?.warn?.(
609
+ `segment store: cannot watch ${key.slice(0, 60)}: ` +
610
+ `${error instanceof Error ? error.message : String(error)}`
611
+ );
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Stop watching one output's directory.
617
+ *
618
+ * @param {string} key
619
+ * @returns {void}
620
+ */
621
+ #unwatch(key) {
622
+ const watcher = this.#watchers.get(key);
623
+ if (!watcher) {
624
+ return;
625
+ }
626
+ this.#watchers.delete(key);
627
+ try {
628
+ watcher.close();
629
+ } catch {
630
+ // Already closed with its directory.
631
+ }
632
+ }
633
+
634
+ /**
635
+ * The directory moved: say what is now there.
636
+ *
637
+ * A REMOVAL MOVES IT TOO, so the name is checked against the disk before
638
+ * anything is announced — otherwise clearing up after a dead run would read
639
+ * as that run's pieces arriving.
640
+ *
641
+ * @param {string} key
642
+ * @param {string | null} name - What changed, where the platform says.
643
+ * @returns {void}
644
+ */
645
+ #noticed(key, name) {
646
+ // WHAT WAS READ OF THIS DIRECTORY IS STALE. Dropped rather than re-read:
647
+ // re-reading HERE is quadratic in the pieces of a film — one listing per
648
+ // file written, of a directory that grows with every file, measured at 110 s
649
+ // against 3 s over 482 pieces — while dropping costs one listing to whoever
650
+ // asks next, which is bounded by the questions and not by the writes.
651
+ //
652
+ // IT IS NOT ENOUGH TO LET `refresh` NOTICE BY ITSELF. It re-reads when the
653
+ // directory's modification time has moved, and that time is stated in
654
+ // milliseconds: a file written in the same millisecond as the last reading
655
+ // leaves it unmoved, and the piece is then reported absent although it is
656
+ // there. That is the hazard `refresh` documents and `publish` has always
657
+ // guarded against by dropping the memory.
658
+ this.#held.delete(key);
659
+ const format = this.#formats.get(key);
660
+ const dir = path.join(this.#root, directoryNameFor(key));
661
+ const index = name && format?.isSegmentFileName?.(name)
662
+ ? (format.segmentIndexFromName?.(name) ?? -1)
663
+ : -1;
664
+ if (index >= 0) {
665
+ // A REMOVAL MOVES THE DIRECTORY TOO, so the one file is asked about
666
+ // before anything is announced — otherwise clearing up after a dead run
667
+ // would read as that run's pieces arriving.
668
+ if (!existsSync(path.join(dir, name))) {
669
+ return;
670
+ }
671
+ this.#announceArrival(key, index);
672
+ return;
673
+ }
674
+ if (name) {
675
+ return;
676
+ }
677
+ // The platform named nothing. Then whoever is waiting asks about their own
678
+ // piece, which is the same question with the same answer and no guessing.
679
+ const byIndex = this.#waiters.get(key);
680
+ if (!byIndex || !format?.segmentFileName) {
681
+ return;
682
+ }
683
+ for (const waiting of [...byIndex.keys()]) {
684
+ if (existsSync(path.join(dir, format.segmentFileName(waiting)))) {
685
+ this.#announceArrival(key, waiting);
686
+ }
687
+ }
688
+ }
689
+
690
+ /**
691
+ * One statement that a number is now present here.
692
+ *
693
+ * @param {string} key
694
+ * @param {number} index
695
+ * @returns {void}
696
+ */
697
+ #announceArrival(key, index) {
698
+ this.announce(key, index);
699
+ for (const listener of this.#publishedListeners) {
700
+ listener(key, index);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Announce that a segment is now present. This stores no readiness state;
706
+ * the file under its served name remains the only fact.
707
+ *
708
+ * @param {string} key
709
+ * @param {number} index
710
+ */
711
+ announce(key, index) {
712
+ const byIndex = this.#waiters.get(key);
713
+ const waiting = byIndex?.get(index);
714
+ if (!waiting) return;
715
+ byIndex.delete(index);
716
+ if (byIndex.size === 0) this.#waiters.delete(key);
717
+ for (const resolve of waiting) resolve(true);
718
+ }
719
+
720
+ /**
721
+ * How many requests are waiting on this output right now.
722
+ *
723
+ * The store's own state, and the one fact that says a wait EXISTS rather than
724
+ * is about to: a check that ends a wait it has not yet registered proves
725
+ * nothing, and passes just as well without the thing it is checking.
726
+ *
727
+ * @param {string} key
728
+ * @returns {number}
729
+ */
730
+ waitingFor(key) {
731
+ let waiting = 0;
732
+ for (const holders of this.#waiters.get(key)?.values() ?? []) {
733
+ waiting += holders.size;
734
+ }
735
+ return waiting;
736
+ }
737
+
738
+ /**
739
+ * Wait for a segment publication event or one deadline. This does not poll
740
+ * the filesystem.
741
+ *
742
+ * @param {string} key
743
+ * @param {number} index
744
+ * @param {number} timeoutMs - May be infinite: then only the publication or
745
+ * `cancelled` ends the wait.
746
+ * @param {Promise<unknown> | null} [cancelled] - Settles when whoever waits
747
+ * has gone, so the waiter does not outlive them.
748
+ * @returns {Promise<boolean>}
749
+ */
750
+ waitFor(key, index, timeoutMs, cancelled = null) {
751
+ if (this.pathOf(key, index)) return Promise.resolve(true);
752
+ // An output adopted from a previous process has a directory nobody made in
753
+ // this one, so the wait itself is the first moment it is watched.
754
+ this.#watch(key);
755
+ return new Promise((resolve) => {
756
+ const byIndex = this.#waiters.get(key) ?? new Map();
757
+ const waiting = byIndex.get(index) ?? new Set();
758
+ this.#waiters.set(key, byIndex);
759
+ byIndex.set(index, waiting);
760
+ let settled = false;
761
+ const finish = (published) => {
762
+ if (settled) return;
763
+ settled = true;
764
+ if (timer) clearTimeout(timer);
765
+ waiting.delete(finish);
766
+ // Removed only if they are still the sets this waiter joined: an
767
+ // announcement takes the set out before calling its waiters, and a new
768
+ // waiter may have registered a fresh one under the same number since.
769
+ if (waiting.size === 0 && byIndex.get(index) === waiting) byIndex.delete(index);
770
+ if (byIndex.size === 0 && this.#waiters.get(key) === byIndex) this.#waiters.delete(key);
771
+ resolve(published);
772
+ };
773
+ waiting.add(finish);
774
+ // No deadline is a wait that ends only on the publication: a timer given
775
+ // an infinite delay fires at once, which would turn the wait into a poll.
776
+ const timer = Number.isFinite(timeoutMs) ? setTimeout(() => finish(false), Math.max(0, timeoutMs)) : null;
777
+ timer?.unref?.();
778
+ cancelled?.then(() => finish(false));
779
+ // Publication may have landed after the first path check and before this
780
+ // waiter was registered. Recheck once after registration so that window
781
+ // cannot turn a present segment into a full-deadline wait.
782
+ if (this.pathOf(key, index)) finish(true);
783
+ });
784
+ }
785
+
786
+ /**
787
+ * Clear up after a run that has ended: remove what it left unfinished.
788
+ *
789
+ * Every file it left open carries its own tag, so this is a name match and
790
+ * nothing else — no stretch to search, no bytes to judge, and no chance of
791
+ * removing a piece somebody else closed.
792
+ *
793
+ * WHAT IT REPLACED, because the difference is the whole of the rename design.
794
+ * It used to take the highest SERVED name inside the stretch the ended run was
795
+ * given and judge whether its bytes looked usable — a guess, needed only
796
+ * because an unfinished piece was indistinguishable from a finished one. Under
797
+ * the naming rule it would now remove a complete segment: the highest served
798
+ * name in a dead run's stretch is a piece it closed.
799
+ *
800
+ * @param {string} key
801
+ * @param {number} startedAt - The run's first segment number, which is its tag.
802
+ * @returns {number} How many unfinished pieces were removed.
803
+ */
804
+ clearUpAfter(key, startedAt) {
805
+ const format = this.#formats.get(key);
806
+ if (!format) {
807
+ return 0;
808
+ }
809
+ const removed = this.#sweepUnfinished(
810
+ key,
811
+ this.directoryFor(key),
812
+ format,
813
+ String(Number.isInteger(startedAt) && startedAt > 0 ? startedAt : 0)
814
+ );
815
+ if (removed > 0) {
816
+ this.#logger?.info?.(
817
+ `segment store: cleared up ${removed} unfinished piece(s) of the run at ` +
818
+ `#${startedAt} on ${key.slice(0, 60)}`
819
+ );
820
+ }
821
+ return removed;
822
+ }
823
+
824
+
825
+ /**
826
+ * Where the file of this name is, or null when this output does not hold it.
827
+ *
828
+ * By NAME rather than by number, because that is what a request carries. The
829
+ * conversion is the store's own business: it is told how this output's files
830
+ * are named (`useFormat`) and therefore needs nobody else to read one.
831
+ *
832
+ * @param {string} key
833
+ * @param {string} fileName
834
+ * @returns {string | null}
835
+ */
836
+ pathOfName(key, fileName) {
837
+ const format = this.#formats.get(key);
838
+ const index = format?.segmentIndexFromName?.(fileName) ?? -1;
839
+ if (!Number.isInteger(index) || index < 0) {
840
+ return null;
841
+ }
842
+ return this.pathOf(key, index);
843
+ }
844
+
845
+ /**
846
+ * Forget what this output's directory was last seen to hold.
847
+ *
848
+ * What is held is re-read when the directory's modification time moves, which
849
+ * is how a quiet request costs one `stat`. A caller that has just removed a
850
+ * file and asks again in the same tick would otherwise be told it is still
851
+ * there — the time has moved, but only by as much as the clock's own
852
+ * granularity, and that is not something to depend on.
853
+ *
854
+ * @param {string} key
855
+ * @returns {void}
856
+ */
857
+ forget(key) {
858
+ this.#held.delete(key);
859
+ }
860
+
861
+ /**
862
+ * Where a segment is, or null when this output does not hold it.
863
+ *
864
+ * @param {string} key
865
+ * @param {number} index
866
+ * @returns {string | null}
867
+ */
868
+ pathOf(key, index) {
869
+ this.#touched.set(key, this.#now());
870
+ return this.refresh(key).byNumber.get(index) ?? null;
871
+ }
872
+
873
+ /**
874
+ * What every output in the store weighs.
875
+ *
876
+ * @returns {{ outputs: number, bytes: number }}
877
+ */
878
+ stats() {
879
+ let bytes = 0;
880
+ for (const key of this.#formats.keys()) {
881
+ bytes += this.refresh(key).bytes;
882
+ }
883
+ return { outputs: this.#formats.size, bytes };
884
+ }
885
+
886
+ /**
887
+ * Throw one output's segments away.
888
+ *
889
+ * @param {string} key
890
+ * @param {string} because
891
+ */
892
+ drop(key, because) {
893
+ const dir = path.join(this.#root, directoryNameFor(key));
894
+ this.#unwatch(key);
895
+ try {
896
+ rmSync(dir, { recursive: true, force: true });
897
+ } catch {
898
+ // Already gone, or in use; the next sweep sees it either way.
899
+ }
900
+ this.#held.delete(key);
901
+ this.#formats.delete(key);
902
+ this.#mediaRanges.delete(key);
903
+ this.#touched.delete(key);
904
+ this.#inits.delete(key);
905
+ this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
906
+ }
907
+
908
+ /**
909
+ * When this output was last asked for, or null where it has never been.
910
+ *
911
+ * The one reading that makes the keeping period measurable rather than
912
+ * guessed: a session opened on an output this answers for IS a return, and
913
+ * this is its age.
914
+ *
915
+ * @param {string} key
916
+ * @returns {number | null}
917
+ */
918
+ lastReadAt(key) {
919
+ return this.#touched.get(key) ?? null;
920
+ }
921
+
922
+ /**
923
+ * Throw away everything this store owns.
924
+ *
925
+ * For a clean exit. What is left on disk afterwards is by definition from a
926
+ * kill, which is the case the startup sweep exists for — and without this the
927
+ * sweep adopts, the exit leaves, and the next start adopts again, for ever.
928
+ *
929
+ * WHAT IT OWNS IS ITS DIRECTORIES, NOT THE ROOT. The root is a fixed path —
930
+ * `os.tmpdir()/torrent-tv-hls` — so it belongs to the machine and not to any
931
+ * one process, and this used to remove it whole. The comment that stood here
932
+ * named the hazard exactly ("another process may share the root") and did it
933
+ * anyway.
934
+ *
935
+ * Measured 2026-09-14, where a second process is ordinary: `node --test` runs
936
+ * every test file in its own process in parallel, and four files call this
937
+ * forty times between them. `segment-serve-wiring.test.js` then reported a
938
+ * segment missing that it had written itself a moment earlier, 2 to 4 checks
939
+ * per run, never the same ones — because another process had deleted the
940
+ * directory under it. The same thing happens to two proxies on one machine:
941
+ * a bare npm run beside the addon, or the wedge stand beside the product, and
942
+ * one of them exiting takes the other's segments mid-serve.
943
+ *
944
+ * A store removes what it made. It does not remove the container it made it
945
+ * in, and an empty root costs nothing.
946
+ *
947
+ * @param {string} because
948
+ * @returns {number} How many outputs went.
949
+ */
950
+ dropAll(because) {
951
+ let dropped = 0;
952
+ for (const key of [...this.#formats.keys()]) {
953
+ this.drop(key, because);
954
+ dropped += 1;
955
+ }
956
+ // EVERY WATCH GOES, not only the watches of outputs that named their file
957
+ // format. A directory is made before anything says how its files are named
958
+ // — `directoryFor` is what a run calls, `useFormat` is a separate statement
959
+ // — so a store can hold a watch on a key that is in no format list, and
960
+ // dropping by that list left it open: on Windows the process then does not
961
+ // exit, which is how this was found.
962
+ for (const key of [...this.#watchers.keys()]) {
963
+ this.#unwatch(key);
964
+ }
965
+ return dropped;
966
+ }
967
+
968
+ /**
969
+ * Keep only what is still being read, and only as much of it as there is room
970
+ * for.
971
+ *
972
+ * **Not tied to a session.** An output is worth keeping while somebody may
973
+ * still ask for it, and a session ending says nothing about that: the viewer
974
+ * who left may come back, and a viewer who never had a session here may open
975
+ * the same film a minute later and find every segment already made. So the
976
+ * only question asked is when this output was last READ, and the only bound
977
+ * is the disk.
978
+ *
979
+ * The idle period is deliberately long. Its job is not to reclaim space —
980
+ * that is the cap's — but to stop an output nobody has touched in hours from
981
+ * sitting there for the life of the process.
982
+ *
983
+ * TWO RULES, ANSWERING TWO QUESTIONS. Kept apart because they were briefly
984
+ * proposed as one and that was wrong: material nobody needs should not sit on
985
+ * the owner's disk merely because there is room for it, and material everyone
986
+ * needs must still go when there is no room. The first is time, the second is
987
+ * space.
988
+ *
989
+ * WHAT GOES FIRST WHEN THERE IS NO ROOM is decided by where the viewers are,
990
+ * not by when a directory was last read. Behind every viewer of an output is
991
+ * material that has been played and will not be asked for again unless
992
+ * somebody seeks back; ahead of the furthest viewer is material that will be
993
+ * asked for, eventually. So the order is: outputs nobody is watching at all,
994
+ * then what lies behind the earliest viewer, furthest behind first, then what
995
+ * lies ahead of the furthest viewer, furthest ahead first. It is the priority
996
+ * map's own order read from the other end.
997
+ *
998
+ * A segment a viewer is standing on is never a victim.
999
+ *
1000
+ * @param {object} params
1001
+ * @param {number} params.idleMs - Untouched for longer than this, and it goes.
1002
+ * @param {number} params.maxBytes - The most the whole store may hold.
1003
+ * @param {(key: string) => number[]} [params.viewersAt] - Where the viewers of
1004
+ * an output stand, as segment numbers. An empty answer means nobody is
1005
+ * watching it, which is what makes its segments the first to go. Absent, the
1006
+ * store has nothing to order by and falls back to the oldest directory —
1007
+ * which is what it did before it could be told.
1008
+ * @returns {{ droppedIdle: number, droppedForRoom: number, segmentsRemoved: number, bytes: number }}
1009
+ */
1010
+ enforce({ idleMs, maxBytes, viewersAt = null }) {
1011
+ const now = this.#now();
1012
+ let droppedIdle = 0;
1013
+ for (const [key, touchedAt] of [...this.#touched]) {
1014
+ if (now - touchedAt > idleMs) {
1015
+ this.drop(key, `nothing has read it for ${Math.round((now - touchedAt) / 60000)} minutes`);
1016
+ droppedIdle += 1;
1017
+ }
1018
+ }
1019
+ let held = this.stats().bytes;
1020
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0 || held <= maxBytes) {
1021
+ return { droppedIdle, droppedForRoom: 0, segmentsRemoved: 0, bytes: held };
1022
+ }
1023
+ if (typeof viewersAt !== "function") {
1024
+ let droppedForRoom = 0;
1025
+ const byAge = [...this.#touched.entries()].sort((left, right) => left[1] - right[1]);
1026
+ for (const [key] of byAge) {
1027
+ if (held <= maxBytes) {
1028
+ break;
1029
+ }
1030
+ const size = this.refresh(key).bytes;
1031
+ this.drop(key, `the store is over its ${(maxBytes / 1073741824).toFixed(1)}GB allowance`);
1032
+ held -= size;
1033
+ droppedForRoom += 1;
1034
+ }
1035
+ return { droppedIdle, droppedForRoom, segmentsRemoved: 0, bytes: held };
1036
+ }
1037
+
1038
+ let segmentsRemoved = 0;
1039
+ for (const victim of this.#leastWantedFirst(viewersAt)) {
1040
+ if (held <= maxBytes) {
1041
+ break;
1042
+ }
1043
+ held -= this.#removeSegment(victim.key, victim.index);
1044
+ segmentsRemoved += 1;
1045
+ }
1046
+ if (segmentsRemoved > 0) {
1047
+ this.#logger.info(
1048
+ `segment-store removed ${segmentsRemoved} segment(s) for room: ` +
1049
+ `${megabytes(held)} of ${megabytes(maxBytes)} allowed`
1050
+ );
1051
+ }
1052
+ return { droppedIdle, droppedForRoom: 0, segmentsRemoved, bytes: held };
1053
+ }
1054
+
1055
+ /**
1056
+ * Every segment in the store, least wanted first.
1057
+ *
1058
+ * @param {(key: string) => number[]} viewersAt
1059
+ * @returns {{ key: string, index: number }[]}
1060
+ */
1061
+ #leastWantedFirst(viewersAt) {
1062
+ const candidates = [];
1063
+ for (const key of this.#formats.keys()) {
1064
+ const positions = (viewersAt(key) ?? []).filter((at) => Number.isInteger(at));
1065
+ const earliest = positions.length > 0 ? Math.min(...positions) : null;
1066
+ const furthest = positions.length > 0 ? Math.max(...positions) : null;
1067
+ for (const index of this.refresh(key).byNumber.keys()) {
1068
+ if (earliest === null) {
1069
+ // Nobody is watching this output at all. Everything it holds is worth
1070
+ // less than anything somebody is on their way to.
1071
+ candidates.push({ key, index, rank: 0, distance: index });
1072
+ continue;
1073
+ }
1074
+ if (positions.includes(index)) {
1075
+ continue;
1076
+ }
1077
+ if (index < earliest) {
1078
+ candidates.push({ key, index, rank: 1, distance: earliest - index });
1079
+ } else {
1080
+ candidates.push({ key, index, rank: 2, distance: index - /** @type {number} */ (furthest) });
1081
+ }
1082
+ }
1083
+ }
1084
+ return candidates
1085
+ .sort((left, right) => (left.rank !== right.rank ? left.rank - right.rank : right.distance - left.distance))
1086
+ .map(({ key, index }) => ({ key, index }));
1087
+ }
1088
+
1089
+ /**
1090
+ * Take one segment off the disk.
1091
+ *
1092
+ * @param {string} key
1093
+ * @param {number} index
1094
+ * @returns {number} What it weighed.
1095
+ */
1096
+ #removeSegment(key, index) {
1097
+ const full = this.refresh(key).byNumber.get(index);
1098
+ if (!full) {
1099
+ return 0;
1100
+ }
1101
+ let size = 0;
1102
+ try {
1103
+ size = statSync(full, { throwIfNoEntry: false })?.size ?? 0;
1104
+ rmSync(full, { force: true });
1105
+ } catch {
1106
+ // Gone already, or refused. The next refresh reports what is really there.
1107
+ }
1108
+ this.#held.delete(key);
1109
+ this.#mediaRanges.get(key)?.delete(index);
1110
+ return size;
1111
+ }
1112
+
1113
+ /**
1114
+ * What a previous life of this process left on the disk.
1115
+ *
1116
+ * This is the only record there is of a death nobody saw. The kernel kills
1117
+ * this process often enough to matter — two kills in one viewing on
1118
+ * 2026-09-02 — and when it does, no exit handler runs, nothing is cleared up,
1119
+ * and memory is reclaimed while the disk is not: `/tmp` in the addon
1120
+ * container is on the overlay filesystem, measured 2026-09-04, so the files
1121
+ * survive the process and its restart.
1122
+ *
1123
+ * So the sweep reports rather than deletes quietly. What it finds is the
1124
+ * evidence, and every directory it names is one abnormal ending that went
1125
+ * unrecorded.
1126
+ *
1127
+ * @returns {{ directories: number, segments: number, bytes: number, unidentified: number, found: {key: string, dir: string, segments: number, bytes: number}[] }}
1128
+ */
1129
+ sweep() {
1130
+ const found = [];
1131
+ let unidentified = 0;
1132
+ let names = [];
1133
+ try {
1134
+ names = readdirSync(this.#root);
1135
+ } catch {
1136
+ return { directories: 0, segments: 0, bytes: 0, unidentified: 0, found: [] };
1137
+ }
1138
+ for (const name of names) {
1139
+ const dir = path.join(this.#root, name);
1140
+ let key = "";
1141
+ try {
1142
+ if (!statSync(dir).isDirectory()) {
1143
+ continue;
1144
+ }
1145
+ key = readFileSync(path.join(dir, KEY_FILE), "utf8").trim();
1146
+ } catch {
1147
+ key = "";
1148
+ }
1149
+ let segments = 0;
1150
+ let bytes = 0;
1151
+ try {
1152
+ for (const entry of readdirSync(dir)) {
1153
+ if (entry === KEY_FILE) {
1154
+ continue;
1155
+ }
1156
+ try {
1157
+ bytes += statSync(path.join(dir, entry)).size;
1158
+ segments += 1;
1159
+ } catch {
1160
+ // Vanished between the listing and the question.
1161
+ }
1162
+ }
1163
+ } catch {
1164
+ continue;
1165
+ }
1166
+ if (!key) {
1167
+ // A directory that cannot say what it holds is from before this layer,
1168
+ // or its key file did not survive. Nothing can be served out of it,
1169
+ // because nothing can match it to a request.
1170
+ unidentified += 1;
1171
+ }
1172
+ found.push({ key, dir, segments, bytes });
1173
+ }
1174
+ const totals = found.reduce(
1175
+ (sum, entry) => ({ segments: sum.segments + entry.segments, bytes: sum.bytes + entry.bytes }),
1176
+ { segments: 0, bytes: 0 }
1177
+ );
1178
+ if (found.length > 0) {
1179
+ this.#logger.info(
1180
+ `segment-store startup sweep: ${found.length} directories left by a previous run, ` +
1181
+ `${totals.segments} segments, ${(totals.bytes / 1048576).toFixed(1)}MB, ` +
1182
+ `${unidentified} of them unidentifiable — each one is an encoder that ended ` +
1183
+ "without anything recording why"
1184
+ );
1185
+ }
1186
+ return {
1187
+ directories: found.length,
1188
+ segments: totals.segments,
1189
+ bytes: totals.bytes,
1190
+ unidentified,
1191
+ found
1192
+ };
1193
+ }
1194
+
1195
+ /**
1196
+ * Take back what a previous life left: keep what is proven, remove the rest.
1197
+ *
1198
+ * Deliberately not "throw everything away". A killed process leaves material
1199
+ * that is valid by construction — a copied segment's bytes depend only on the
1200
+ * source — and re-encoding it costs the machine that is already known to be
1201
+ * short of processor. What cannot be kept is a directory that cannot name
1202
+ * itself, and the one file per directory whose closure nothing proves.
1203
+ *
1204
+ * @param {(key: string) => object | null} formatFor - How to read the file
1205
+ * names of an output, given its key. Null when this proxy cannot serve that
1206
+ * output at all, and then the directory goes.
1207
+ * @returns {{ adopted: number, dropped: number, unprovenRemoved: number }}
1208
+ */
1209
+ adoptWhatSurvived(formatFor) {
1210
+ const swept = this.sweep();
1211
+ let adopted = 0;
1212
+ let dropped = 0;
1213
+ let unprovenRemoved = 0;
1214
+ for (const entry of swept.found) {
1215
+ const format = entry.key ? formatFor(entry.key) : null;
1216
+ if (!format) {
1217
+ try {
1218
+ rmSync(entry.dir, { recursive: true, force: true });
1219
+ } catch {
1220
+ // Leave it; the next sweep reports it again.
1221
+ }
1222
+ dropped += 1;
1223
+ this.#logger.info(
1224
+ `segment-store discarded ${path.basename(entry.dir)}: ` +
1225
+ (entry.key ? "this proxy cannot serve that output" : "it does not say what it holds")
1226
+ );
1227
+ continue;
1228
+ }
1229
+ this.#formats.set(entry.key, format);
1230
+ // EVERY SEGMENT FOUND IS COMPLETE, because a piece is given its served
1231
+ // name only once the encoder has said it is closed. So there is nothing to
1232
+ // prove here and nothing to un-prove: what the directory holds under
1233
+ // served names is what a killed process finished.
1234
+ //
1235
+ // What it may also hold is pieces it was in the middle of, under their
1236
+ // working names, and those are swept — the file a run was writing when the
1237
+ // kernel took the process is exactly this.
1238
+ unprovenRemoved += this.#sweepUnfinished(entry.key, entry.dir, format);
1239
+ adopted += 1;
1240
+ this.#logger.info(
1241
+ `segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
1242
+ "segments a killed process had already finished"
1243
+ );
1244
+ }
1245
+ return { adopted, dropped, unprovenRemoved };
1246
+ }
1247
+ }
1248
+
1249
+ /**
1250
+ * @param {number} bytes
1251
+ * @returns {string}
1252
+ */
1253
+ function megabytes(bytes) {
1254
+ return `${Math.round(Math.max(0, bytes) / (1024 * 1024))}MB`;
1255
+ }