@torrent-tv/proxy 2.86.0 → 2.87.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/CHANGELOG.md +2142 -1915
  2. package/CLAUDE.md +322 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/browser-proxy-contract.md +94 -0
  7. package/docs/container-architecture.md +218 -213
  8. package/docs/download-architecture.md +7 -7
  9. package/docs/encode-architecture.md +287 -11
  10. package/docs/encode-run-state.md +2 -2
  11. package/docs/logs.md +97 -97
  12. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  13. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  14. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  15. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  18. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  19. package/openspec/changes/cold-start/design.md +1 -1
  20. package/openspec/changes/disk-cap/proposal.md +1 -1
  21. package/openspec/changes/proxy-observability/proposal.md +2 -2
  22. package/openspec/changes/track-selection/proposal.md +1 -1
  23. package/openspec/changes/transcode-quality/proposal.md +1 -1
  24. package/package.json +2 -2
  25. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  26. package/research/double-spawn-2026-09-10.md +171 -171
  27. package/routes/api/playback-plan/post.js +2 -2
  28. package/routes/api/sources/files/get.js +1 -1
  29. package/routes/api/sources/stats/get.js +1 -1
  30. package/routes/api/sources/warm/post.js +10 -3
  31. package/routes/api/subtitles/get.js +230 -230
  32. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  33. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  34. package/routes/api/transcode-sessions/post.js +160 -139
  35. package/routes/api/transcode-sessions/progress/get.js +3 -3
  36. package/routes/api/transcode-sessions/release/post.js +3 -3
  37. package/routes/api/transcode-sessions/seek/post.js +12 -3
  38. package/routes/stream/get.js +2 -2
  39. package/routes/transcode/audio-file/get.js +15 -6
  40. package/routes/transcode/audio-warm/get.js +9 -6
  41. package/routes/transcode/session-file/get.js +52 -268
  42. package/routes/transcode/variant-file/get.js +46 -7
  43. package/routes/transcode/variant-warm/get.js +19 -6
  44. package/scripts/render-run-graph.js +1 -1
  45. package/server.js +720 -465
  46. package/services/encode/CoverageMap.js +428 -428
  47. package/services/encode/CushionReport.js +345 -0
  48. package/services/encode/EncodeAdmission.js +332 -0
  49. package/services/{orchestrators → encode}/EncodeOrchestrator.js +307 -29
  50. package/services/encode/EncodeRun.js +16 -9
  51. package/services/encode/EncodeRuns.js +1229 -0
  52. package/services/encode/Encoder.js +18 -2
  53. package/services/encode/EncoderSelection.js +38 -0
  54. package/services/encode/LocalObservations.js +359 -0
  55. package/services/encode/NvencEncoder.js +5 -0
  56. package/services/encode/OutputOpening.js +755 -0
  57. package/services/encode/OutputTimes.js +579 -0
  58. package/services/encode/Renditions.js +2162 -0
  59. package/services/encode/RunProgress.js +84 -0
  60. package/services/encode/SoftwareEncoder.js +12 -7
  61. package/services/encode/args.js +402 -186
  62. package/services/encode/calibration.js +361 -0
  63. package/services/encode/contention.js +128 -128
  64. package/services/encode/encode-run-state.js +16 -20
  65. package/services/encode/fingerprint.js +322 -0
  66. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  67. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  68. package/services/encode/output/EncodedOutput.js +57 -0
  69. package/services/encode/output/Output.js +131 -0
  70. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  71. package/services/encode/output/OutputSpec.js +390 -0
  72. package/services/{output → encode/output}/Timeline.js +12 -37
  73. package/services/encode/output/index.js +20 -0
  74. package/services/encode/output/ladder.js +32 -0
  75. package/services/{output → encode/output}/playlists.js +3 -0
  76. package/services/encode/output-key-format.js +21 -0
  77. package/services/encode/quality/EncodeCost.js +1083 -0
  78. package/services/encode/quality/HostLoad.js +525 -0
  79. package/services/encode/quality/HostTimings.js +249 -0
  80. package/services/encode/quality/OutputQualityState.js +28 -0
  81. package/services/encode/quality/QualityController.js +935 -0
  82. package/services/encode/quality/QualityOffer.js +416 -0
  83. package/services/encode/quality/drain-threat.js +47 -0
  84. package/services/encode/quality/link-budget.js +254 -0
  85. package/services/encode/quality/output-format.js +266 -0
  86. package/services/encode/quality/serving-output.js +216 -0
  87. package/services/encode/quality/visible-rung.js +57 -0
  88. package/services/encode/run-command.js +23 -20
  89. package/services/encode/run-inputs.js +42 -0
  90. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  91. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  92. package/services/encode/segment-formats/init-compat.js +324 -0
  93. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  94. package/services/encode/throughput.js +97 -0
  95. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  96. package/services/media/KeyframeTables.js +316 -0
  97. package/services/{source → media}/SourceFile.js +7 -55
  98. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  99. package/services/media/SubtitleOrchestrator.js +167 -0
  100. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  101. package/services/{container → media/container}/AviContainer.js +266 -266
  102. package/services/{container → media/container}/Container.js +596 -601
  103. package/services/media/container/ContainerFactory.js +56 -0
  104. package/services/media/container/KeyframeTable.js +272 -0
  105. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  106. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  107. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  108. package/services/media/keyframe-probe.js +157 -0
  109. package/services/media/media-info-probe.js +93 -0
  110. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  111. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  112. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  113. package/services/server/OutputLifecycle.js +418 -0
  114. package/services/server/SegmentServing.js +1146 -0
  115. package/services/server/ViewerRequests.js +522 -0
  116. package/services/server/controllers/PlaybackController.js +36 -0
  117. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  118. package/services/server/http-responses.js +60 -0
  119. package/services/server/transcode-session-files.js +325 -0
  120. package/services/server/wire-outputs.js +983 -0
  121. package/services/storage/Diagnostics.js +202 -0
  122. package/services/storage/MachineBudget.js +235 -0
  123. package/services/{piece-store → storage}/allowance.js +107 -107
  124. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  125. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  126. package/services/{disk → storage}/free.js +33 -0
  127. package/services/storage/machine-memory.js +56 -0
  128. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  129. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  130. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  131. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  132. package/services/storage/wire.js +175 -0
  133. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  134. package/services/{download → torrent/download}/registry.js +2 -2
  135. package/services/torrent/files.js +1 -1
  136. package/services/torrent/piece-store-of.js +29 -0
  137. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  138. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  139. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  140. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  141. package/services/torrent/worker/held-bytes.js +155 -0
  142. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  143. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  144. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  145. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  146. package/services/torrent/worker/resume-warm.js +107 -0
  147. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  148. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  149. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  150. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  151. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  152. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  153. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  154. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  155. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  156. package/services/viewer/Assignments.js +340 -0
  157. package/services/{priority → viewer}/PriorityMap.js +390 -390
  158. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  159. package/services/viewer/Viewer.js +657 -483
  160. package/services/viewer/Viewers.js +368 -257
  161. package/services/viewer/active-output.js +37 -54
  162. package/services/viewer/audio-start.js +4 -3
  163. package/services/viewer/buffer-trend.js +81 -0
  164. package/services/viewer/choices.js +647 -0
  165. package/services/viewer/positions.js +126 -120
  166. package/services/viewer/report-intake.js +46 -45
  167. package/test/asset-paths.test.js +28 -0
  168. package/test/audio-inventory.test.js +3 -3
  169. package/test/audio-track-bitrate.test.js +40 -0
  170. package/test/auto-quality-step.test.js +707 -515
  171. package/test/available-share.test.js +1 -1
  172. package/test/background-fill.test.js +1 -1
  173. package/test/client-log-file.test.js +110 -110
  174. package/test/completed-files.test.js +1 -1
  175. package/test/concurrent-cost.test.js +13 -3
  176. package/test/container-media-info.test.js +3 -3
  177. package/test/core-dumps.test.js +1 -1
  178. package/test/coverage-follows-the-disk.test.js +46 -9
  179. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  180. package/test/cut-times-timeline.test.js +2 -2
  181. package/test/cuts-follow-published-grid.test.js +9 -8
  182. package/test/data-channel-frame.test.js +1 -1
  183. package/test/dead-torrent-handle.test.js +1 -1
  184. package/test/declared-rates.test.js +2 -2
  185. package/test/declared-tracks.test.js +2 -2
  186. package/test/decode-cost-fit.test.js +1 -1
  187. package/test/decode-cost.test.js +34 -25
  188. package/test/delivery-probe.test.js +1 -1
  189. package/test/demand-register.test.js +4 -4
  190. package/test/dht-bootstrap.test.js +1 -1
  191. package/test/drain-threat.test.js +65 -0
  192. package/test/duplicate-add.test.js +1 -1
  193. package/test/encode-admission.test.js +146 -0
  194. package/test/encode-cost.test.js +199 -10
  195. package/test/encode-orchestrator.test.js +39 -1
  196. package/test/encode-plan-viewers.test.js +2 -2
  197. package/test/encode-run.test.js +2 -2
  198. package/test/encode-slope.test.js +1 -1
  199. package/test/encoded-output.test.js +39 -0
  200. package/test/encoder-readings.test.js +1 -1
  201. package/test/every-claimant-is-told-its-share.test.js +152 -0
  202. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  203. package/test/failed-start-breaker.test.js +81 -30
  204. package/test/fastest-wires.test.js +1 -1
  205. package/test/ffmpeg-stream-counts.test.js +1 -1
  206. package/test/file-edges.test.js +3 -3
  207. package/test/fmp4-self-contained.test.js +1 -1
  208. package/test/fmp4-timestamps.test.js +1 -1
  209. package/test/fmp4-track-completeness.test.js +1 -1
  210. package/test/format-keyed-outputs.test.js +78 -0
  211. package/test/health-metrics.test.js +3 -2
  212. package/test/held-request-width.test.js +22 -23
  213. package/test/helpers/encode-run.js +136 -136
  214. package/test/helpers/held-file.js +29 -0
  215. package/test/helpers/manager.js +57 -0
  216. package/test/helpers/output-spec.js +63 -0
  217. package/test/host-load.test.js +1 -1
  218. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  219. package/test/init-compat.test.js +219 -0
  220. package/test/input-lost-quiets-the-plan.test.js +1 -1
  221. package/test/input-unavailable.test.js +1 -1
  222. package/test/keeping-period.test.js +4 -4
  223. package/test/keyframe-index-accuracy.test.js +97 -45
  224. package/test/keyframe-table-budget.test.js +28 -33
  225. package/test/keyframes-belong-to-the-file.test.js +174 -57
  226. package/test/learned-median.test.js +1 -1
  227. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  228. package/test/limit-row-by-area.test.js +60 -0
  229. package/test/link-budget.test.js +111 -0
  230. package/test/link-probe.test.js +56 -56
  231. package/test/local-encoding-data.test.js +273 -0
  232. package/test/look-ahead-coverage.test.js +1 -1
  233. package/test/manager-surface.test.js +294 -0
  234. package/test/matroska-blocks.test.js +0 -0
  235. package/test/matroska-cues-track.test.js +1 -1
  236. package/test/memory-budget.test.js +2 -2
  237. package/test/mp4-composition-times.test.js +0 -0
  238. package/test/mp4-subtitles.test.js +1 -1
  239. package/test/one-authority.test.js +45 -41
  240. package/test/one-budget.test.js +149 -0
  241. package/test/one-output-one-session.test.js +204 -0
  242. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  243. package/test/one-serving-output-per-height.test.js +458 -0
  244. package/test/orchestrator-wired.test.js +46 -45
  245. package/test/output-catalog.test.js +39 -0
  246. package/test/output-shape.test.js +4 -51
  247. package/test/output-spec.test.js +251 -157
  248. package/test/output-speed.test.js +17 -15
  249. package/test/outputs-being-prepared.test.js +40 -0
  250. package/test/packet-witness-ring.test.js +2 -2
  251. package/test/packet-witness.test.js +1 -1
  252. package/test/piece-disk-store.test.js +2 -2
  253. package/test/piece-from-whole-file.test.js +1 -1
  254. package/test/piece-lru.test.js +1 -1
  255. package/test/piece-reader.test.js +2 -2
  256. package/test/piece-store-eviction.test.js +1 -1
  257. package/test/piece-store-never-refuses.test.js +1 -1
  258. package/test/piece-store-reservations.test.js +1 -1
  259. package/test/piece-store-slow-disk.test.js +1 -1
  260. package/test/piece-tail.test.js +1 -1
  261. package/test/plan-host-timings.test.js +1 -1
  262. package/test/plan-is-steady.test.js +225 -225
  263. package/test/priority-map-download.test.js +4 -4
  264. package/test/priority-map-emptied.test.js +204 -205
  265. package/test/priority-map-per-output.test.js +215 -209
  266. package/test/priority-map.test.js +1 -1
  267. package/test/probe-wedge-certainty.test.js +1 -1
  268. package/test/produced-copy-choice.test.js +15 -60
  269. package/test/quality-offer.test.js +162 -0
  270. package/test/quality-variants.test.js +1229 -1167
  271. package/test/rate-control-in-the-output.test.js +180 -0
  272. package/test/read-survives-withdrawal.test.js +2 -2
  273. package/test/read-window.test.js +10 -3
  274. package/test/resume-warm.test.js +1 -1
  275. package/test/run-inputs.test.js +80 -0
  276. package/test/run-position-follows-published-grid.test.js +13 -11
  277. package/test/run-progress.test.js +65 -0
  278. package/test/same-height-switch.test.js +664 -0
  279. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  280. package/test/seek-landing.test.js +13 -13
  281. package/test/seek-target-not-superseded.test.js +28 -29
  282. package/test/segment-closed-on-both-branches.test.js +96 -0
  283. package/test/segment-cut-times.test.js +1 -1
  284. package/test/segment-hold-supersede.test.js +28 -35
  285. package/test/segment-judged-by-its-own-header.test.js +221 -0
  286. package/test/segment-serve-wiring.test.js +130 -39
  287. package/test/segment-store-eviction.test.js +22 -7
  288. package/test/segment-store.test.js +98 -14
  289. package/test/segment-true-start.test.js +1 -1
  290. package/test/segments-are-shared.test.js +17 -16
  291. package/test/send-chunk-bytes.test.js +1 -1
  292. package/test/serving-existing-output.test.js +102 -0
  293. package/test/serving-output.test.js +102 -0
  294. package/test/shared-piece-store.test.js +1 -1
  295. package/test/source-file.test.js +1 -1
  296. package/test/stale-request-after-seek.test.js +17 -21
  297. package/test/startup-readings.test.js +10 -5
  298. package/test/subtitle-cue-framing.test.js +5 -5
  299. package/test/subtitle-cue-source.test.js +36 -38
  300. package/test/subtitle-cue-walk.test.js +11 -10
  301. package/test/subtitle-defaults.test.js +1 -1
  302. package/test/subtitle-language.test.js +4 -4
  303. package/test/subtitle-track-numbering.test.js +5 -4
  304. package/test/supply-margin.test.js +1 -1
  305. package/test/swarm-follows-readers.test.js +3 -3
  306. package/test/swarm-reach.test.js +1 -1
  307. package/test/swarm-selection.test.js +3 -3
  308. package/test/tail-duplication.test.js +1 -1
  309. package/test/the-channel-has-both-ends.test.js +91 -0
  310. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  311. package/test/thread-pool.test.js +3 -3
  312. package/test/timeline.test.js +1 -12
  313. package/test/torrent-cost.test.js +1 -1
  314. package/test/tracks-begin-together.test.js +38 -24
  315. package/test/tunnel-renewal.test.js +1 -1
  316. package/test/two-viewers-one-picture.test.js +120 -87
  317. package/test/upload-hurry.test.js +3 -3
  318. package/test/usrsctp-state.test.js +1 -1
  319. package/test/video-facts.test.js +1 -1
  320. package/test/viewer-assignments.test.js +143 -0
  321. package/test/viewer-outputs.test.js +60 -52
  322. package/test/viewer-presence.test.js +22 -25
  323. package/test/viewer-requests-admission.test.js +89 -0
  324. package/test/viewer-states.test.js +189 -202
  325. package/test/viewer-subtitle-subscription.test.js +1 -1
  326. package/test/viewer.test.js +139 -136
  327. package/test/viewing-generations.test.js +390 -0
  328. package/test/visible-rung.test.js +37 -0
  329. package/test/wait-ledger.test.js +1 -1
  330. package/test/webrtc-shim.test.js +3 -3
  331. package/test/wedge-certainty.test.js +2 -2
  332. package/test/withdraw-piece-claim.test.js +2 -2
  333. package/test/worker-channel.test.js +1 -31
  334. package/utils/client-log-file.js +151 -151
  335. package/scripts/read-container-index.mjs +0 -108
  336. package/services/container/ContainerFactory.js +0 -122
  337. package/services/controllers/PlaybackController.js +0 -33
  338. package/services/disk/DiskSpace.js +0 -150
  339. package/services/disk/wire.js +0 -61
  340. package/services/hls-session-manager.js +0 -9608
  341. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  342. package/services/output/Output.js +0 -94
  343. package/services/output/OutputSpec.js +0 -202
  344. package/services/output/index.js +0 -1
  345. package/services/output/ladder.js +0 -26
  346. package/services/produced-index.js +0 -222
  347. package/services/quality/EncodeCost.js +0 -555
  348. package/services/torrent-worker/container-tracks.js +0 -414
  349. package/services/viewer/link-readings.js +0 -56
  350. package/test/produced-index.test.js +0 -142
  351. /package/{services → bin}/thread-pool.js +0 -0
  352. /package/services/{available-share.js → encode/available-share.js} +0 -0
  353. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  354. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  355. /package/services/{host-load.js → encode/host-load.js} +0 -0
  356. /package/services/{output → encode/output}/cut-grid.js +0 -0
  357. /package/services/{output → encode/output}/rates.js +0 -0
  358. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  359. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  360. /package/services/{container → media/container}/ebml-reader.js +0 -0
  361. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  362. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  365. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  366. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  367. /package/services/{tracks → media/tracks}/index.js +0 -0
  368. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  369. /package/services/{disk → storage}/keep.js +0 -0
  370. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  371. /package/services/{disk → storage}/returns.js +0 -0
  372. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  373. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  374. /package/services/{demand → torrent/demand}/Window.js +0 -0
  375. /package/services/{demand → torrent/demand}/index.js +0 -0
  376. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  377. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  378. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  379. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  380. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  381. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  382. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  383. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  384. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  385. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -1,414 +0,0 @@
1
- /**
2
- * The tracks a file declares, read from the file itself on the thread that owns
3
- * the torrent.
4
- *
5
- * This exists because the container layer (`services/container/`) reads bytes,
6
- * and the bytes live here: the torrent client runs on this worker thread, so the
7
- * main thread cannot open a read stream on one of its files. The main thread
8
- * asks over the channel (`Command.CONTAINER_TRACKS`) and gets plain objects
9
- * back, because a class instance does not survive the boundary as a class.
10
- *
11
- * It reads whatever container the file is — the same `ContainerFactory` that
12
- * serves the picture serves a `.mka` beside it, since a `.mka` IS Matroska and
13
- * differs only in having no video track. That is the whole reason no new class
14
- * was needed for a soundtrack shipped as its own file.
15
- *
16
- * Unlike the subtitle walk, the reads here DO wait on the swarm. A sidecar file
17
- * has usually had nothing downloaded at all when this is first asked, and its
18
- * header is a few hundred kilobytes; the alternative is offering the viewer a
19
- * soundtrack with nothing known about it.
20
- */
21
-
22
- import { containerOrchestrator } from "../orchestrators/ContainerOrchestrator.js";
23
- import { logger } from "../../utils/logger.js";
24
-
25
- /**
26
- * How long one range read may take before it is given up.
27
- *
28
- * Not a measurement and nothing is derived from it: it is the point past which a
29
- * read of a file nobody is playing is presumed lost, so that one stream which
30
- * never ends cannot hold the answer — and with it the viewer's audio menu — for
31
- * the rest of the session.
32
- */
33
- const READ_ABANDON_MS = 60_000;
34
-
35
- /** Bytes of the head a container's track table lives in. */
36
- const HEAD_BYTES = 256 * 1024;
37
-
38
- /**
39
- * Tracks already read, by `sourceKey:fileIndex`. Only a non-empty reading is
40
- * kept: an empty one usually means the header has not arrived yet, and caching
41
- * that would hide the file's tracks for the life of the process.
42
- *
43
- * @type {Map<string, object[]>}
44
- */
45
- const byFile = new Map();
46
-
47
- /** @type {Map<string, Promise<object[]>>} */
48
- const inFlight = new Map();
49
-
50
- /**
51
- * Read a byte range of a file, fetching what is missing.
52
- *
53
- * @param {object} file
54
- * @param {number} start
55
- * @param {number} end - Inclusive.
56
- * @returns {Promise<Buffer | null>}
57
- */
58
- function readFetching(file, start, end) {
59
- return new Promise((resolve) => {
60
- const chunks = [];
61
- let stream;
62
- try {
63
- stream = file.createReadStream({ start, end });
64
- } catch {
65
- resolve(null);
66
- return;
67
- }
68
- let settled = false;
69
- /** @type {ReturnType<typeof setTimeout> | null} */
70
- let abandon = null;
71
- const settle = (value) => {
72
- if (settled) {
73
- return;
74
- }
75
- settled = true;
76
- if (abandon !== null) {
77
- clearTimeout(abandon);
78
- }
79
- if (value === null) {
80
- stream.destroy?.();
81
- }
82
- resolve(value);
83
- };
84
- abandon = setTimeout(() => {
85
- logger.info(
86
- `container-tracks: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
87
- `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
88
- );
89
- settle(null);
90
- }, READ_ABANDON_MS);
91
- abandon.unref?.();
92
- stream.on("data", (chunk) => chunks.push(chunk));
93
- stream.on("end", () => settle(Buffer.concat(chunks)));
94
- stream.on("error", () => settle(null));
95
- });
96
- }
97
-
98
- /**
99
- * One track as it crosses the thread boundary: the fields the inventory and the
100
- * master playlist read, and nothing that would not survive being cloned.
101
- *
102
- * @param {import("../tracks/index.js").ContainerTrack} track
103
- * @returns {object}
104
- */
105
- function plainTrack(track) {
106
- return {
107
- type: track.type,
108
- trackNumber: track.trackNumber,
109
- declaredIndex: track.declaredIndex,
110
- codecId: track.codecId,
111
- language: track.resolvedLanguage(),
112
- languageBcp47: track.languageBcp47,
113
- name: track.name,
114
- isEnabled: track.isEnabled,
115
- isDefault: track.isDefault,
116
- declaresDefault: track.declaresDefault,
117
- // Audio-only, per RFC 9559 — absent on other types, and read as false there
118
- // rather than being placed on the base track where they do not belong.
119
- isOriginal: track.isOriginal === true,
120
- isCommentary: track.isCommentary === true,
121
- isVisualImpaired: track.isVisualImpaired === true,
122
- channels: Number.isFinite(track.channels) ? track.channels : null,
123
- samplingFrequency: Number.isFinite(track.samplingFrequency) ? track.samplingFrequency : null
124
- };
125
- }
126
-
127
- /**
128
- * Every track one file declares, in container order.
129
- *
130
- * @param {object} torrent
131
- * @param {number} fileIndex
132
- * @param {string} sourceKey
133
- * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
134
- * @returns {Promise<object[]>}
135
- */
136
- export async function containerTracksOf(torrent, fileIndex, sourceKey, options = {}) {
137
- const key = `${sourceKey}:${fileIndex}`;
138
- const held = byFile.get(key);
139
- if (Array.isArray(held)) {
140
- return held;
141
- }
142
- const running = inFlight.get(key);
143
- if (running) {
144
- return running;
145
- }
146
- const work = readTracks(torrent, fileIndex, sourceKey, options)
147
- .then((tracks) => {
148
- if (tracks.length > 0) {
149
- byFile.set(key, tracks);
150
- }
151
- return tracks;
152
- })
153
- .finally(() => {
154
- inFlight.delete(key);
155
- });
156
- inFlight.set(key, work);
157
- return work;
158
- }
159
-
160
- /**
161
- * @param {object} torrent
162
- * @param {number} fileIndex
163
- * @param {string} sourceKey
164
- * @param {{ prefetchEdges?: () => Promise<unknown> }} options
165
- * @returns {Promise<object[]>}
166
- */
167
- async function readTracks(torrent, fileIndex, sourceKey, options) {
168
- const file = torrent?.files?.[fileIndex];
169
- if (!file || !Number.isFinite(file.length) || file.length <= 0) {
170
- return [];
171
- }
172
- if (typeof options.prefetchEdges === "function") {
173
- try {
174
- await options.prefetchEdges();
175
- } catch {
176
- // A prefetch that failed is not a reason to skip the read: the read
177
- // fetches what it needs itself, only more slowly.
178
- }
179
- }
180
- const readRange = async (start, end) =>
181
- readFetching(file, start, Math.min(end, file.length - 1));
182
- try {
183
- const tracks = await containerOrchestrator.getTracks({
184
- sourceKey,
185
- fileIndex,
186
- readRange,
187
- fileSize: file.length,
188
- label: String(file.name ?? "")
189
- });
190
- const plain = tracks.map(plainTrack);
191
- if (plain.length > 0) {
192
- logger.info(
193
- `container-tracks: "${String(file.name).slice(0, 40)}" declares ` +
194
- `${plain.filter((track) => track.type === "video").length} video, ` +
195
- `${plain.filter((track) => track.type === "audio").length} audio, ` +
196
- `${plain.filter((track) => track.type === "subtitle").length} subtitle track(s)`
197
- );
198
- }
199
- return plain;
200
- } catch (error) {
201
- logger.warn(
202
- `container-tracks: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
203
- );
204
- return [];
205
- }
206
- }
207
-
208
- /** Bytes of a file's head worth fetching before its track table is read. */
209
- export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
210
-
211
- /**
212
- * How much of the file to pull in under the viewer's resume position.
213
- *
214
- * One piece of a video torrent is 4-16 MB and a resume lands anywhere inside
215
- * one, so anything smaller would still leave the encoder waiting for the piece
216
- * it starts in. Eight megabytes covers that piece and usually the next.
217
- */
218
- const RESUME_REGION_BYTES = 8 * 1024 * 1024;
219
-
220
- /**
221
- * Where a position in seconds falls in a file, in bytes.
222
- *
223
- * Proportional, and therefore approximate on a variable bitrate — which is what
224
- * it is for: a prefetch that puts the swarm to work on roughly the right place
225
- * while the plan and the session are still being built. The encoder's own read
226
- * asks for the exact bytes a moment later and corrects it.
227
- *
228
- * A position past the end is clamped to the end rather than refused: a resume
229
- * position can outlive the file it was recorded against, and reading the last
230
- * bytes is harmless where reading past them is an error.
231
- *
232
- * @param {number} fileLength
233
- * @param {number} durationSeconds
234
- * @param {number} positionSeconds
235
- * @returns {number}
236
- */
237
- export function resumeByteOffset(fileLength, durationSeconds, positionSeconds) {
238
- if (!(fileLength > 0) || !(durationSeconds > 0) || !(positionSeconds > 0)) {
239
- return 0;
240
- }
241
- const within = Math.min(positionSeconds, durationSeconds);
242
- return Math.min(fileLength - 1, Math.floor((fileLength * within) / durationSeconds));
243
- }
244
-
245
- /**
246
- * Start fetching the region a viewer is about to resume at.
247
- *
248
- * Where that region IS can only be worked out from two numbers the file itself
249
- * holds — its length and its duration — so this belongs beside the container
250
- * read rather than in the route: the route knows a position in seconds and
251
- * nothing else. The conversion is proportional and therefore approximate on a
252
- * variable bitrate; it is a prefetch, and the encoder's own read corrects it.
253
- *
254
- * @param {object} torrent
255
- * @param {number} fileIndex
256
- * @param {string} sourceKey
257
- * @param {number} positionSeconds
258
- * @param {{ prefetchEdges?: () => Promise<unknown>, fetchRegion?: (start: number, bytes: number) => Promise<unknown> }} options
259
- * @returns {Promise<boolean>} Whether a region was asked for.
260
- */
261
- export async function warmResumePosition(torrent, fileIndex, sourceKey, positionSeconds, options = {}) {
262
- const file = torrent?.files?.[fileIndex];
263
- if (!file || !(positionSeconds > 0) || typeof options.fetchRegion !== "function") {
264
- return false;
265
- }
266
- const info = await containerMediaInfoOf(torrent, fileIndex, sourceKey, options);
267
- const duration = info?.durationSeconds;
268
- if (!Number.isFinite(duration) || duration <= 0) {
269
- logger.info(
270
- `warm ${sourceKey.slice(0, 8)}: "${String(file.name).slice(0, 40)}" does not declare its ` +
271
- "duration, so where the viewer's position falls in it cannot be worked out — " +
272
- "the region under it is left to the encoder's own read"
273
- );
274
- return false;
275
- }
276
- const at = resumeByteOffset(file.length, duration, positionSeconds);
277
- logger.info(
278
- `warm ${sourceKey.slice(0, 8)}: fetching ${(RESUME_REGION_BYTES / (1024 * 1024)).toFixed(0)}MB under the ` +
279
- `viewer's position ${positionSeconds.toFixed(1)}s of ${duration.toFixed(1)}s, which is ` +
280
- `${(at / (1024 * 1024)).toFixed(1)}MB into "${String(file.name).slice(0, 40)}"`
281
- );
282
- await options.fetchRegion(at, RESUME_REGION_BYTES);
283
- return true;
284
- }
285
-
286
- /**
287
- * Where one file's keyframes are, from the container's own table.
288
- *
289
- * A property of immutable bytes, like the duration and the track list, and read
290
- * here for the same reason they are: the container is cached per file, so the
291
- * table is read ONCE however many sessions ask for it, and two sessions created
292
- * in the same moment join one read instead of making two. That is the two-viewer
293
- * case exactly — measured 2026-09-03, two pictures of one file were created 13 ms
294
- * apart, and each read the table for itself over the proxy's own HTTP.
295
- *
296
- * It decides which branch a copy takes: a picture can only be cut where the
297
- * source already has a keyframe, so a file with no readable index is re-encoded
298
- * instead. That decision belongs to the FILE, and it is the same for everybody
299
- * watching it.
300
- *
301
- * @param {object} torrent
302
- * @param {number} fileIndex
303
- * @param {string} sourceKey
304
- * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
305
- * @returns {Promise<{ times: number[], tolerance: number } | null>}
306
- */
307
- export async function containerKeyframesOf(torrent, fileIndex, sourceKey, options = {}) {
308
- const file = torrent?.files?.[fileIndex];
309
- if (!file || !Number.isFinite(file.length) || file.length <= 0) {
310
- return null;
311
- }
312
- // Timed apart from the read below, and reported apart from it, because the
313
- // two are different quantities and reading them as one led to a wrong
314
- // conclusion on 2026-09-04: this wait is the SWARM delivering the head and
315
- // tail of the file, and it reached 60 s on a torrent with one peer, while the
316
- // read that follows it is parsing bytes already in hand and never passed
317
- // 8.4 s across four containers. A bound belongs on the first, not the second.
318
- const edgesStartedAt = Date.now();
319
- if (typeof options.prefetchEdges === "function") {
320
- try {
321
- await options.prefetchEdges();
322
- } catch {
323
- // A prefetch that failed is not a reason to skip the read: the read
324
- // fetches what it needs itself, only more slowly.
325
- }
326
- }
327
- const edgesMs = Date.now() - edgesStartedAt;
328
- const readRange = async (start, end) =>
329
- readFetching(file, start, Math.min(end, file.length - 1));
330
- const startedAt = Date.now();
331
- const params = {
332
- sourceKey,
333
- fileIndex,
334
- readRange,
335
- fileSize: file.length,
336
- label: String(file.name ?? "")
337
- };
338
- try {
339
- const index = await containerOrchestrator.getKeyframeIndex(params);
340
- // Which container answered, reported whether or not it produced anything:
341
- // how often an index disagrees with its own file is a question about the
342
- // CONTAINER, and a measurement that does not say which one cannot answer
343
- // it. Free — the container is the cached one that has just read the table.
344
- const format = (await containerOrchestrator.getContainer(params))?.formatName ?? "unrecognised";
345
- const times = Array.isArray(index?.times) && index.times.length > 0 ? index.times : null;
346
- logger.info(
347
- `container-keyframes: "${String(file.name).slice(0, 40)}" ` +
348
- (times
349
- ? `${times.length} keyframes from the ${format} index in ${Date.now() - startedAt}ms`
350
- : `has no readable index (${format}, ${Date.now() - startedAt}ms)`) +
351
- `, after ${edgesMs}ms waiting for the file's edges`
352
- );
353
- return {
354
- times,
355
- format,
356
- tolerance: Number.isFinite(index?.tolerance) ? index.tolerance : 0
357
- };
358
- } catch (error) {
359
- logger.warn(
360
- `container-keyframes: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
361
- );
362
- return null;
363
- }
364
- }
365
-
366
- /**
367
- * What one file declares about itself as a whole.
368
- *
369
- * @param {object} torrent
370
- * @param {number} fileIndex
371
- * @param {string} sourceKey
372
- * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
373
- * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
374
- */
375
- export async function containerMediaInfoOf(torrent, fileIndex, sourceKey, options = {}) {
376
- const file = torrent?.files?.[fileIndex];
377
- if (!file || !Number.isFinite(file.length) || file.length <= 0) {
378
- return null;
379
- }
380
- if (typeof options.prefetchEdges === "function") {
381
- try {
382
- await options.prefetchEdges();
383
- } catch {
384
- // A prefetch that failed is not a reason to skip the read: the read
385
- // fetches what it needs itself, only more slowly.
386
- }
387
- }
388
- const readRange = async (start, end) =>
389
- readFetching(file, start, Math.min(end, file.length - 1));
390
- try {
391
- const info = await containerOrchestrator.getMediaInfo({
392
- sourceKey,
393
- fileIndex,
394
- readRange,
395
- fileSize: file.length,
396
- label: String(file.name ?? "")
397
- });
398
- if (info) {
399
- logger.info(
400
- `container-info: "${String(file.name).slice(0, 40)}" is ${info.format}, ` +
401
- `${info.durationSeconds === null ? "duration not declared" : `${info.durationSeconds.toFixed(3)}s`}, ` +
402
- `${info.startTimeSeconds === null
403
- ? "start of its timeline not declared"
404
- : `its timeline starts at ${info.startTimeSeconds.toFixed(6)}s`}`
405
- );
406
- }
407
- return info;
408
- } catch (error) {
409
- logger.warn(
410
- `container-info: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
411
- );
412
- return null;
413
- }
414
- }
@@ -1,56 +0,0 @@
1
- /**
2
- * @file What the viewers of one output say about their links, as one reading.
3
- *
4
- * The budget asks one question — is anybody failing to keep up — so both terms
5
- * are the worst case: the slowest link and the emptiest buffer, which may
6
- * belong to different people. Taking the last report instead meant a session
7
- * with two viewers acted on whichever of them happened to report most recently.
8
- *
9
- * NOTHING HERE EXPIRES, and that is the correction of 2026-09-14. A reading
10
- * used to be discarded once it was older than a chosen thirty seconds, which
11
- * conflated two facts under one number: how fast the link is, and whether we
12
- * are still hearing from the page. The first persists — a link does not stop
13
- * being what it was measured to be because nobody measured it for a minute,
14
- * which is why the page itself keeps its last figure for ever. The second is
15
- * PRESENCE, and presence has its own owner: a viewer who has gone is removed
16
- * from the output, so what is walked here is only people who are still here.
17
- *
18
- * The reading is corrected continuously while material crosses the link, which
19
- * is the whole shape: measured once when the connection comes up, corrected by
20
- * every transfer after it.
21
- */
22
-
23
- import { viewersOf } from "./Viewer.js";
24
-
25
- /**
26
- * @param {object} output - The session whose viewers are asked.
27
- * @returns {{ linkMbps: number, bufferedAheadSec: number, at: number, viewers: number } | null}
28
- * Null when nobody present has ever measured their link, which every caller
29
- * already treats as "no opinion either way".
30
- */
31
- export function worstLinkReading(output) {
32
- let worst = null;
33
- for (const viewer of viewersOf(output).values()) {
34
- if (!viewer.isPresent()) {
35
- continue;
36
- }
37
- const reading = viewer.linkReading();
38
- if (reading === null) {
39
- continue;
40
- }
41
- if (worst === null) {
42
- worst = {
43
- linkMbps: reading.linkMbps,
44
- bufferedAheadSec: reading.bufferedAheadSec,
45
- at: reading.at,
46
- viewers: 1
47
- };
48
- continue;
49
- }
50
- worst.linkMbps = Math.min(worst.linkMbps, reading.linkMbps);
51
- worst.bufferedAheadSec = Math.min(worst.bufferedAheadSec, reading.bufferedAheadSec);
52
- worst.at = Math.max(worst.at, reading.at);
53
- worst.viewers += 1;
54
- }
55
- return worst;
56
- }
@@ -1,142 +0,0 @@
1
- /**
2
- * @file The session's one statement of what it has produced.
3
- *
4
- * Every check builds real directories and real files, because what is under
5
- * test is exactly the reading of them: which run's copy wins, what an empty
6
- * file counts as, and whether a directory that has not changed is read again.
7
- */
8
-
9
- import test from "node:test";
10
- import assert from "node:assert/strict";
11
- import path from "node:path";
12
- import os from "node:os";
13
- import { mkdtempSync, writeFileSync, rmSync, unlinkSync } from "node:fs";
14
-
15
- import { ProducedIndex } from "../services/produced-index.js";
16
-
17
- /** The naming the fMP4 format uses, reduced to what the index asks of it. */
18
- const segmentFormat = {
19
- isSegmentFileName: (name) => /^segment-\d{5}\.mp4$/.test(name),
20
- segmentIndexFromName: (name) => {
21
- const match = /^segment-(\d{5})\.mp4$/.exec(name);
22
- return match ? Number(match[1]) : -1;
23
- }
24
- };
25
-
26
- const nameOf = (index) => `segment-${String(index).padStart(5, "0")}.mp4`;
27
-
28
- function output() {
29
- const dirPath = mkdtempSync(path.join(os.tmpdir(), "produced-index-"));
30
- return {
31
- dirPath,
32
- write(index, contents) {
33
- writeFileSync(path.join(dirPath, nameOf(index)), contents);
34
- return path.join(dirPath, nameOf(index));
35
- },
36
- writeNamed(name, contents) {
37
- writeFileSync(path.join(dirPath, name), contents);
38
- return path.join(dirPath, name);
39
- },
40
- index: () => new ProducedIndex({ dirPath, segmentFormat }),
41
- remove: () => rmSync(dirPath, { recursive: true, force: true })
42
- };
43
- }
44
-
45
- test("a file with bytes in it is produced; one that was only opened is not", () => {
46
- const s = output();
47
- try {
48
- s.write(1, "a piece");
49
- s.write(2, "");
50
- const index = s.index();
51
-
52
- assert.deepEqual([...index.segmentNumbers()], [1]);
53
- assert.equal(index.pathOf(nameOf(1)), path.join(s.dirPath, nameOf(1)));
54
- // The empty file exists and is not an answer — this is the pair of beliefs
55
- // that stopped playback for ten minutes on 2026-09-03, now one belief.
56
- assert.equal(index.pathOf(nameOf(2)), null);
57
- } finally {
58
- s.remove();
59
- }
60
- });
61
-
62
-
63
-
64
- test("a file that is not a segment is found by name", () => {
65
- const s = output();
66
- try {
67
- s.writeNamed("init.mp4", "header");
68
- const index = s.index();
69
- assert.equal(index.pathOf("init.mp4"), path.join(s.dirPath, "init.mp4"));
70
- assert.ok(index.fileNames().includes("init.mp4"));
71
- } finally {
72
- s.remove();
73
- }
74
- });
75
-
76
- test("a directory that is gone stops answering", () => {
77
- const s = output();
78
- try {
79
- s.write(4, "piece");
80
- const index = s.index();
81
- assert.equal(index.segmentNumbers().size, 1);
82
-
83
- rmSync(s.dirPath, { recursive: true, force: true });
84
- assert.equal(index.segmentNumbers().size, 0);
85
- assert.equal(index.pathOf(nameOf(4)), null);
86
- } finally {
87
- s.remove();
88
- }
89
- });
90
-
91
- test("a file removed on purpose is not still an answer", () => {
92
- const s = output();
93
- try {
94
- const written = s.write(9, "piece");
95
- const index = s.index();
96
- assert.equal(index.pathOf(nameOf(9)), written);
97
-
98
- unlinkSync(written);
99
- // The directory's own time has moved, but a caller that deletes and asks in
100
- // the same tick must not be told the file is there.
101
- index.invalidate();
102
- assert.equal(index.pathOf(nameOf(9)), null);
103
- } finally {
104
- s.remove();
105
- }
106
- });
107
-
108
- test("an unchanged directory is not listed again", () => {
109
- const s = output();
110
- try {
111
- s.write(1, "piece");
112
- const index = s.index();
113
-
114
- index.segmentNumbers();
115
- assert.equal(index.directoryReads, 1, "the first question reads what is there and no more");
116
-
117
- for (let asked = 0; asked < 20; asked += 1) {
118
- index.segmentNumbers();
119
- index.pathOf(nameOf(1));
120
- }
121
- assert.equal(
122
- index.directoryReads,
123
- 1,
124
- "forty more questions about a directory that has not changed must read nothing"
125
- );
126
-
127
- s.write(2, "another piece");
128
- index.segmentNumbers();
129
- assert.equal(index.directoryReads, 2, "a directory that gained a file is read once more");
130
- } finally {
131
- s.remove();
132
- }
133
- });
134
-
135
- test("an output directory that does not exist answers with nothing", () => {
136
- const index = new ProducedIndex({
137
- dirPath: path.join(os.tmpdir(), "produced-index-absent-0000"),
138
- segmentFormat
139
- });
140
- assert.equal(index.pathOf(nameOf(1)), null);
141
- assert.deepEqual([...index.segmentNumbers()], []);
142
- });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes