@torrent-tv/proxy 2.89.5 → 2.89.7

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 (447) 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 +338 -338
  13. package/CHANGELOG.md +16 -0
  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/diagnostics/sctpstate.gdb +31 -31
  19. package/bin/cli.js +684 -684
  20. package/bin/thread-pool.js +30 -30
  21. package/docs/download-architecture.md +261 -261
  22. package/docs/encode-architecture.md +765 -765
  23. package/docs/encode-run-state.md +96 -96
  24. package/docs/logs.md +97 -97
  25. package/docs/playback-start-forecast.md +17 -2
  26. package/docs/socks5-egress-research.md +114 -114
  27. package/docs/storage-architecture.md +273 -273
  28. package/knip.json +14 -14
  29. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  30. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  31. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  32. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  33. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  34. package/openspec/changes/binary-distribution/proposal.md +53 -53
  35. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  36. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  37. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  38. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  39. package/openspec/changes/cold-start/design.md +98 -98
  40. package/openspec/changes/cold-start/proposal.md +62 -62
  41. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  42. package/openspec/changes/cold-start/tasks.md +48 -48
  43. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  44. package/openspec/changes/disk-cap/proposal.md +37 -37
  45. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  46. package/openspec/changes/disk-cap/tasks.md +19 -19
  47. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  48. package/openspec/changes/proxy-observability/design.md +38 -38
  49. package/openspec/changes/proxy-observability/proposal.md +49 -49
  50. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  51. package/openspec/changes/proxy-observability/tasks.md +21 -21
  52. package/openspec/changes/seek-debounce/design.md +118 -118
  53. package/openspec/changes/seek-debounce/proposal.md +59 -59
  54. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  55. package/openspec/changes/seek-debounce/tasks.md +33 -33
  56. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  57. package/openspec/changes/subtitle-language/proposal.md +50 -50
  58. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  59. package/openspec/changes/subtitle-language/tasks.md +26 -26
  60. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  61. package/openspec/changes/track-selection/design.md +47 -47
  62. package/openspec/changes/track-selection/proposal.md +45 -45
  63. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  64. package/openspec/changes/track-selection/tasks.md +20 -20
  65. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  66. package/openspec/changes/transcode-quality/proposal.md +51 -51
  67. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  68. package/openspec/changes/transcode-quality/tasks.md +63 -63
  69. package/openspec/config.yaml +38 -38
  70. package/package.json +1 -1
  71. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  72. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  73. package/research/double-spawn-2026-09-10.md +171 -171
  74. package/research/handover-2026-09-13.md +209 -209
  75. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  76. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  77. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  78. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  79. package/routes/api/client-logs/post.js +86 -86
  80. package/routes/api/delivery-sink/get.js +72 -72
  81. package/routes/api/link-probe/get.js +51 -51
  82. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  83. package/routes/api/playback-plan/post.js +53 -53
  84. package/routes/api/sources/post.js +36 -36
  85. package/routes/api/sources/stats/get.js +132 -132
  86. package/routes/api/sources/warm/post.js +217 -217
  87. package/routes/api/subtitles/get.js +230 -230
  88. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  89. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  90. package/routes/api/transcode-sessions/post.js +178 -178
  91. package/routes/api/transcode-sessions/progress/get.js +27 -27
  92. package/routes/api/transcode-sessions/release/post.js +41 -41
  93. package/routes/api/transcode-sessions/seek/post.js +63 -63
  94. package/routes/health/get.js +13 -13
  95. package/routes/healthz/get.js +13 -13
  96. package/routes/stream/get.js +416 -416
  97. package/routes/transcode/audio-file/get.js +64 -64
  98. package/routes/transcode/audio-warm/get.js +95 -95
  99. package/routes/transcode/session-file/get.js +52 -52
  100. package/routes/transcode/variant-file/get.js +95 -95
  101. package/routes/transcode/variant-warm/get.js +84 -84
  102. package/scripts/render-run-graph.js +173 -173
  103. package/server.js +748 -748
  104. package/services/encode/CoverageMap.js +428 -428
  105. package/services/encode/CushionReport.js +345 -345
  106. package/services/encode/EncodeAdmission.js +376 -376
  107. package/services/encode/EncodeOrchestrator.js +1192 -1192
  108. package/services/encode/EncodePlan.js +1368 -1368
  109. package/services/encode/EncodeRun.js +32 -4
  110. package/services/encode/EncodeRuns.js +19 -7
  111. package/services/encode/Encoder.js +142 -142
  112. package/services/encode/EncoderSelection.js +38 -38
  113. package/services/encode/LocalObservations.js +359 -359
  114. package/services/encode/NvencEncoder.js +50 -50
  115. package/services/encode/OutputTimes.js +579 -579
  116. package/services/encode/QsvEncoder.js +58 -58
  117. package/services/encode/Renditions.js +2283 -2283
  118. package/services/encode/RunClock.js +87 -87
  119. package/services/encode/RunProgress.js +79 -79
  120. package/services/encode/SegmentDemand.js +95 -95
  121. package/services/encode/SoftwareEncoder.js +121 -121
  122. package/services/encode/V4l2m2mEncoder.js +53 -53
  123. package/services/encode/VaapiEncoder.js +66 -66
  124. package/services/encode/args.js +418 -418
  125. package/services/encode/available-share.js +89 -89
  126. package/services/encode/calibration.js +361 -361
  127. package/services/encode/contention.js +128 -128
  128. package/services/encode/decode-cost-fit.js +363 -363
  129. package/services/encode/encode-exit.js +116 -114
  130. package/services/encode/encode-run-state.js +551 -551
  131. package/services/encode/encoder-readings.js +43 -43
  132. package/services/encode/fingerprint.js +322 -322
  133. package/services/encode/host-load.js +174 -174
  134. package/services/encode/index.js +9 -9
  135. package/services/encode/learned-median.js +90 -90
  136. package/services/encode/output/EncodedOutput.js +57 -57
  137. package/services/encode/output/Output.js +131 -131
  138. package/services/encode/output/OutputCatalog.js +414 -414
  139. package/services/encode/output/OutputSpec.js +390 -390
  140. package/services/encode/output/Timeline.js +308 -308
  141. package/services/encode/output/cut-grid.js +107 -107
  142. package/services/encode/output/ladder.js +32 -32
  143. package/services/encode/output/playlists.js +284 -284
  144. package/services/encode/output/rates.js +125 -125
  145. package/services/encode/output-key-format.js +21 -21
  146. package/services/encode/piece-completeness.js +60 -2
  147. package/services/encode/quality/EncodeCost.js +1043 -1043
  148. package/services/encode/quality/HostLoad.js +525 -525
  149. package/services/encode/quality/HostTimings.js +249 -249
  150. package/services/encode/quality/OutputQualityState.js +24 -24
  151. package/services/encode/quality/QualityController.js +758 -758
  152. package/services/encode/quality/QualityOffer.js +416 -416
  153. package/services/encode/quality/drain-threat.js +47 -47
  154. package/services/encode/quality/link-budget.js +337 -337
  155. package/services/encode/quality/output-format.js +319 -319
  156. package/services/encode/quality/serving-output.js +216 -216
  157. package/services/encode/quality/visible-rung.js +57 -57
  158. package/services/encode/run-budget.js +97 -97
  159. package/services/encode/run-command.js +3 -1
  160. package/services/encode/run-costs.js +155 -155
  161. package/services/encode/run-inputs.js +53 -53
  162. package/services/encode/segment-formats/fmp4.js +476 -476
  163. package/services/encode/segment-formats/index.js +1 -0
  164. package/services/encode/segment-formats/init-compat.js +324 -324
  165. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  166. package/services/encode/segment-formats/mpegts.js +134 -134
  167. package/services/encode/start-stop-cost.js +178 -178
  168. package/services/encode/throughput.js +97 -97
  169. package/services/encode/torrent-cost.js +88 -88
  170. package/services/identity/installation-id.js +38 -38
  171. package/services/media/SourceFile.js +298 -298
  172. package/services/media/SubtitleOrchestrator.js +167 -167
  173. package/services/media/container/KeyframeTable.js +272 -272
  174. package/services/media/container/ebml-reader.js +159 -159
  175. package/services/media/ffmpeg-banner.js +260 -260
  176. package/services/media/keyframe-probe.js +157 -157
  177. package/services/media/media-info-probe.js +93 -93
  178. package/services/media/playback-planner.js +931 -931
  179. package/services/media/tracks/AudioTrack.js +143 -143
  180. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  181. package/services/media/tracks/SubtitleTrack.js +38 -38
  182. package/services/media/tracks/VideoTrack.js +33 -33
  183. package/services/media/tracks/audio-bound.js +233 -233
  184. package/services/media/tracks/index.js +15 -15
  185. package/services/media/tracks/language-detect.js +174 -174
  186. package/services/server/OutputLifecycle.js +418 -418
  187. package/services/server/SegmentServing.js +1152 -1152
  188. package/services/server/ViewerRequests.js +785 -785
  189. package/services/server/WaitLedger.js +142 -142
  190. package/services/server/controllers/PlaybackController.js +40 -40
  191. package/services/server/controllers/SubtitleController.js +109 -109
  192. package/services/server/http-responses.js +60 -60
  193. package/services/server/transcode-session-files.js +325 -325
  194. package/services/server/wire-outputs.js +1029 -1029
  195. package/services/storage/Diagnostics.js +202 -202
  196. package/services/storage/MachineBudget.js +235 -235
  197. package/services/storage/allowance.js +107 -107
  198. package/services/storage/core-dumps.js +106 -106
  199. package/services/storage/files/CompletedFiles.js +391 -391
  200. package/services/storage/files/piece-from-whole-file.js +118 -118
  201. package/services/storage/free.js +71 -71
  202. package/services/storage/keep.js +48 -48
  203. package/services/storage/machine-memory.js +56 -56
  204. package/services/storage/memory-report.js +575 -575
  205. package/services/storage/piece-store/piece-disk-store.js +539 -539
  206. package/services/storage/piece-store/piece-lru.js +495 -495
  207. package/services/storage/returns.js +103 -103
  208. package/services/storage/wire.js +175 -175
  209. package/services/torrent/demand/DemandRegister.js +202 -202
  210. package/services/torrent/demand/Urgency.js +137 -137
  211. package/services/torrent/demand/Window.js +118 -118
  212. package/services/torrent/demand/index.js +8 -8
  213. package/services/torrent/demand/pieces.js +140 -140
  214. package/services/torrent/download/SwarmSelection.js +386 -386
  215. package/services/torrent/download/registry.js +102 -102
  216. package/services/torrent/download/withdraw-claim.js +80 -80
  217. package/services/torrent/files.js +455 -455
  218. package/services/torrent/naming.js +619 -619
  219. package/services/torrent/piece-store-of.js +29 -29
  220. package/services/torrent/supply-margin.js +264 -264
  221. package/services/torrent/worker/background-fill.js +213 -213
  222. package/services/torrent/worker/channel.js +271 -271
  223. package/services/torrent/worker/fastest-wires.js +342 -342
  224. package/services/torrent/worker/handle-state.js +23 -23
  225. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  226. package/services/torrent/worker/piece-reader.js +1078 -1078
  227. package/services/torrent/worker/pool-adapter.js +371 -371
  228. package/services/torrent/worker/protocol.js +184 -184
  229. package/services/torrent/worker/resume-warm.js +107 -107
  230. package/services/torrent/worker/webrtc-shim.js +219 -219
  231. package/services/transport/delivery-probe.js +842 -842
  232. package/services/transport/health-collector.js +53 -53
  233. package/services/transport/nat-classifier.js +231 -231
  234. package/services/transport/packet-witness.js +808 -808
  235. package/services/transport/port-mapper.js +235 -235
  236. package/services/transport/registry-api.js +81 -81
  237. package/services/transport/tunnel-client.js +447 -447
  238. package/services/transport/usrsctp-state.js +175 -175
  239. package/services/transport/webrtc-manager.js +484 -484
  240. package/services/viewer/Assignments.js +340 -340
  241. package/services/viewer/PriorityOrchestrator.js +325 -325
  242. package/services/viewer/Viewer.js +691 -691
  243. package/services/viewer/Viewers.js +368 -368
  244. package/services/viewer/active-output.js +37 -37
  245. package/services/viewer/audio-start.js +46 -46
  246. package/services/viewer/buffer-trend.js +81 -81
  247. package/services/viewer/choices.js +647 -647
  248. package/services/viewer/media-time.js +355 -355
  249. package/services/viewer/playback-readiness.js +635 -635
  250. package/services/viewer/positions.js +126 -126
  251. package/services/viewer/report-intake.js +46 -46
  252. package/store/source-registry.js +71 -71
  253. package/test/asset-paths.test.js +28 -28
  254. package/test/audio-buffer-continuity.test.js +77 -77
  255. package/test/audio-inventory.test.js +212 -212
  256. package/test/audio-track-bitrate.test.js +40 -40
  257. package/test/auto-quality-step.test.js +762 -753
  258. package/test/available-share.test.js +76 -76
  259. package/test/background-fill.test.js +190 -190
  260. package/test/client-log-file.test.js +110 -110
  261. package/test/completed-files.test.js +115 -115
  262. package/test/concurrent-cost.test.js +148 -148
  263. package/test/container-media-info.test.js +228 -228
  264. package/test/contention.test.js +76 -76
  265. package/test/core-dumps.test.js +57 -57
  266. package/test/coverage-follows-the-disk.test.js +228 -228
  267. package/test/coverage-map.test.js +195 -195
  268. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  269. package/test/cut-times-timeline.test.js +65 -65
  270. package/test/cuts-follow-published-grid.test.js +109 -109
  271. package/test/data-channel-frame.test.js +63 -63
  272. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  273. package/test/dead-torrent-handle.test.js +52 -52
  274. package/test/declared-rates.test.js +116 -116
  275. package/test/declared-tracks.test.js +43 -43
  276. package/test/decode-cost-fit.test.js +202 -202
  277. package/test/decode-cost.test.js +475 -462
  278. package/test/delivery-probe.test.js +393 -393
  279. package/test/demand-register.test.js +206 -206
  280. package/test/dht-bootstrap.test.js +72 -72
  281. package/test/drain-threat.test.js +65 -65
  282. package/test/duplicate-add.test.js +48 -48
  283. package/test/encode-admission.test.js +146 -146
  284. package/test/encode-cost.test.js +275 -275
  285. package/test/encode-exit.test.js +111 -111
  286. package/test/encode-orchestrator.test.js +426 -426
  287. package/test/encode-plan-viewers.test.js +725 -725
  288. package/test/encode-plan.test.js +545 -545
  289. package/test/encode-run-state.test.js +314 -314
  290. package/test/encode-run.test.js +38 -0
  291. package/test/encode-slope.test.js +46 -46
  292. package/test/encoded-output.test.js +39 -39
  293. package/test/encoder-kinds.test.js +122 -122
  294. package/test/encoder-readings.test.js +68 -68
  295. package/test/every-claimant-is-told-its-share.test.js +152 -152
  296. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  297. package/test/failed-start-breaker.test.js +202 -202
  298. package/test/fastest-wires.test.js +143 -143
  299. package/test/ffmpeg-stream-counts.test.js +78 -78
  300. package/test/file-edges.test.js +191 -191
  301. package/test/fixtures/refused-audio-20261002.json +10 -0
  302. package/test/fmp4-presentation-clock.test.js +4 -2
  303. package/test/fmp4-self-contained.test.js +80 -80
  304. package/test/fmp4-timestamps.test.js +140 -140
  305. package/test/fmp4-track-completeness.test.js +41 -41
  306. package/test/format-keyed-outputs.test.js +78 -78
  307. package/test/health-metrics.test.js +38 -38
  308. package/test/held-request-width.test.js +157 -157
  309. package/test/helpers/encode-run.js +240 -240
  310. package/test/helpers/manager.js +62 -57
  311. package/test/helpers/output-spec.js +63 -63
  312. package/test/host-load.test.js +68 -68
  313. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  314. package/test/init-compat.test.js +219 -219
  315. package/test/input-lost-quiets-the-plan.test.js +261 -261
  316. package/test/input-unavailable.test.js +48 -48
  317. package/test/installation-id.test.js +39 -39
  318. package/test/keeping-period.test.js +83 -83
  319. package/test/keyframe-index-accuracy.test.js +188 -188
  320. package/test/keyframes-belong-to-the-file.test.js +249 -249
  321. package/test/learned-median.test.js +37 -37
  322. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  323. package/test/limit-row-by-area.test.js +60 -60
  324. package/test/link-budget.test.js +111 -111
  325. package/test/link-probe.test.js +56 -56
  326. package/test/local-encoding-data.test.js +273 -273
  327. package/test/logger-repeats.test.js +134 -134
  328. package/test/look-ahead-coverage.test.js +56 -56
  329. package/test/manager-surface.test.js +291 -291
  330. package/test/matroska-cues-track.test.js +192 -192
  331. package/test/media-time.test.js +62 -62
  332. package/test/memory-budget.test.js +390 -390
  333. package/test/move-cost.test.js +216 -216
  334. package/test/mp4-subtitles.test.js +173 -173
  335. package/test/one-authority.test.js +285 -285
  336. package/test/one-budget.test.js +149 -149
  337. package/test/one-resource-divided.test.js +185 -185
  338. package/test/one-serving-output-per-height.test.js +458 -458
  339. package/test/orchestrator-wired.test.js +207 -207
  340. package/test/output-catalog.test.js +39 -39
  341. package/test/output-shape.test.js +21 -21
  342. package/test/output-spec.test.js +251 -251
  343. package/test/output-speed.test.js +87 -87
  344. package/test/outputs-being-prepared.test.js +40 -40
  345. package/test/packet-witness-ring.test.js +236 -236
  346. package/test/packet-witness.test.js +148 -148
  347. package/test/piece-completeness.test.js +37 -1
  348. package/test/piece-disk-store.test.js +401 -401
  349. package/test/piece-from-whole-file.test.js +129 -129
  350. package/test/piece-lru.test.js +319 -319
  351. package/test/piece-reader.test.js +162 -162
  352. package/test/piece-selection-offset.test.js +85 -85
  353. package/test/piece-store-eviction.test.js +595 -595
  354. package/test/piece-store-never-refuses.test.js +153 -153
  355. package/test/piece-store-reservations.test.js +403 -403
  356. package/test/piece-store-slow-disk.test.js +159 -159
  357. package/test/piece-tail.test.js +97 -97
  358. package/test/plan-host-timings.test.js +68 -68
  359. package/test/plan-is-steady.test.js +225 -225
  360. package/test/playback-readiness.test.js +518 -518
  361. package/test/playback-start-regressions.test.js +101 -101
  362. package/test/priority-map-download.test.js +174 -174
  363. package/test/priority-map-emptied.test.js +204 -204
  364. package/test/priority-map-per-output.test.js +215 -215
  365. package/test/priority-map.test.js +163 -163
  366. package/test/probe-wedge-certainty.test.js +60 -60
  367. package/test/produced-copy-choice.test.js +213 -213
  368. package/test/published-timeline.test.js +83 -83
  369. package/test/quality-offer.test.js +160 -160
  370. package/test/quality-variants.test.js +1270 -1257
  371. package/test/rate-control-in-the-output.test.js +180 -180
  372. package/test/read-survives-withdrawal.test.js +164 -164
  373. package/test/read-window.test.js +365 -365
  374. package/test/resume-warm.test.js +39 -39
  375. package/test/run-graph-drift.test.js +37 -37
  376. package/test/run-inputs.test.js +80 -80
  377. package/test/run-intervals.test.js +100 -100
  378. package/test/run-position-follows-published-grid.test.js +89 -89
  379. package/test/run-progress.test.js +64 -64
  380. package/test/same-height-switch.test.js +663 -663
  381. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  382. package/test/seek-landing.test.js +109 -109
  383. package/test/seek-target-not-superseded.test.js +195 -195
  384. package/test/segment-cut-times.test.js +63 -63
  385. package/test/segment-demand.test.js +80 -80
  386. package/test/segment-hold-supersede.test.js +91 -91
  387. package/test/segment-judged-by-its-own-header.test.js +222 -221
  388. package/test/segment-serve-wiring.test.js +9 -3
  389. package/test/segment-store-eviction.test.js +247 -247
  390. package/test/segment-store.test.js +352 -352
  391. package/test/segment-true-start.test.js +86 -86
  392. package/test/segments-are-shared.test.js +176 -176
  393. package/test/send-chunk-bytes.test.js +86 -86
  394. package/test/serving-existing-output.test.js +111 -105
  395. package/test/serving-output.test.js +102 -102
  396. package/test/shared-piece-store.test.js +310 -310
  397. package/test/sidecar-naming.test.js +142 -142
  398. package/test/soundtrack-rate-bound.test.js +289 -289
  399. package/test/source-file.test.js +133 -133
  400. package/test/source-registry.test.js +18 -18
  401. package/test/split-a-stretch.test.js +139 -139
  402. package/test/stale-request-after-seek.test.js +158 -158
  403. package/test/startup-readings.test.js +124 -124
  404. package/test/stream-route.test.js +237 -237
  405. package/test/subtitle-cue-framing.test.js +200 -200
  406. package/test/subtitle-cue-source.test.js +102 -102
  407. package/test/subtitle-cursor.test.js +63 -63
  408. package/test/subtitle-defaults.test.js +118 -118
  409. package/test/subtitle-language.test.js +252 -252
  410. package/test/supply-margin.test.js +190 -190
  411. package/test/swarm-follows-readers.test.js +174 -174
  412. package/test/swarm-reach.test.js +130 -130
  413. package/test/swarm-selection.test.js +220 -220
  414. package/test/tail-duplication.test.js +167 -167
  415. package/test/the-channel-has-both-ends.test.js +91 -91
  416. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  417. package/test/thread-pool.test.js +53 -53
  418. package/test/timeline.test.js +84 -84
  419. package/test/torrent-contents.test.js +235 -235
  420. package/test/torrent-cost.test.js +71 -71
  421. package/test/torrent-files.test.js +221 -221
  422. package/test/torrent-naming.test.js +255 -255
  423. package/test/tracks-begin-together.test.js +209 -209
  424. package/test/tunnel-renewal.test.js +167 -167
  425. package/test/two-viewers-one-picture.test.js +508 -504
  426. package/test/upload-hurry.test.js +186 -186
  427. package/test/usrsctp-state.test.js +131 -131
  428. package/test/video-facts.test.js +102 -102
  429. package/test/viewer-assignments.test.js +143 -143
  430. package/test/viewer-outputs.test.js +285 -285
  431. package/test/viewer-presence.test.js +116 -116
  432. package/test/viewer-requests-admission.test.js +92 -89
  433. package/test/viewer-states.test.js +189 -189
  434. package/test/viewer-subtitle-subscription.test.js +96 -96
  435. package/test/viewer.test.js +139 -139
  436. package/test/viewing-generations.test.js +390 -390
  437. package/test/visible-rung.test.js +37 -37
  438. package/test/wait-ledger.test.js +100 -100
  439. package/test/webrtc-shim.test.js +149 -149
  440. package/test/wedge-certainty.test.js +131 -131
  441. package/test/withdraw-piece-claim.test.js +212 -212
  442. package/test/worker-channel.test.js +158 -158
  443. package/utils/client-log-file.js +151 -151
  444. package/utils/logger.js +312 -312
  445. package/utils/parse-range.js +25 -25
  446. package/utils/perf.js +59 -59
  447. package/utils/torrent-source-key.js +38 -38
@@ -1,213 +1,213 @@
1
- /**
2
- * @file A piece that cannot be played: waited for, or removed and made again.
3
- *
4
- * Field 2026-09-03. A run was suspended 548 ms after it started with
5
- * `segment-00025.mp4` newly opened, so the file stayed at zero bytes. From then
6
- * on the two halves of the proxy disagreed about it and neither could see the
7
- * other's reason: the look-ahead counted the NAME, found the numbering unbroken
8
- * through #83, called it `420s ahead of the viewer` and kept the encoder
9
- * stopped; the serving path read the FILE, found it short of a track, and
10
- * waited for a run that had produced nothing to finish it.
11
- *
12
- * The viewer's picture stood still for ten minutes on a transport measuring
13
- * 3-9 ms round trip.
14
- *
15
- * The question of WHICH copy answers is gone with the directory-per-run scheme:
16
- * there is one directory per output, runs are kept apart by their stretches,
17
- * and one name is one file. What is left is the question that remains real —
18
- * whether a file that cannot be played is being written now or was left by a
19
- * run that has ended.
20
- */
21
-
22
- import test from "node:test";
23
- import assert from "node:assert/strict";
24
- import { SourceFile } from "../services/media/SourceFile.js";
25
- import { Timeline } from "../services/encode/output/Timeline.js";
26
- import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
27
- import os from "node:os";
28
- import path from "node:path";
29
- import { managerWithOwnStore } from "./helpers/manager.js";
30
- import { startRunOn } from "./helpers/encode-run.js";
31
- import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
32
- import { outputSpec } from "./helpers/output-spec.js";
33
-
34
- const MOVIE_TIMESCALE = 1000;
35
- const VIDEO_TIMESCALE = 90_000;
36
- const AUDIO_TIMESCALE = 48_000;
37
- const SEGMENT_SECONDS = 12.5;
38
- const SESSION_ID = "aef21c88a8d64a9a";
39
-
40
- /**
41
- * @param {string} type
42
- * @param {Buffer} body
43
- * @returns {Buffer}
44
- */
45
- function box(type, body) {
46
- const head = Buffer.alloc(8);
47
- head.writeUInt32BE(8 + body.length, 0);
48
- head.write(type, 4, "latin1");
49
- return Buffer.concat([head, body]);
50
- }
51
-
52
- /**
53
- * @param {number} offsetSeconds
54
- * @returns {Buffer}
55
- */
56
- function emptyEdit(offsetSeconds) {
57
- const body = Buffer.alloc(16);
58
- body.writeUInt32BE(1, 4);
59
- body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8);
60
- body.writeInt32BE(-1, 12);
61
- return box("elst", body);
62
- }
63
-
64
- /**
65
- * @param {number} trackId
66
- * @param {number} timescale
67
- * @param {number} offsetSeconds
68
- * @returns {Buffer}
69
- */
70
- function trak(trackId, timescale, offsetSeconds) {
71
- const tkhdBody = Buffer.alloc(84);
72
- tkhdBody.writeUInt32BE(trackId, 12);
73
- const mdhdBody = Buffer.alloc(20);
74
- mdhdBody.writeUInt32BE(timescale, 12);
75
- return box("trak", Buffer.concat([
76
- box("tkhd", tkhdBody),
77
- box("edts", emptyEdit(offsetSeconds)),
78
- box("mdia", box("mdhd", mdhdBody))
79
- ]));
80
- }
81
-
82
- /**
83
- * @param {number} trackId
84
- * @returns {Buffer}
85
- */
86
- function traf(trackId) {
87
- const tfhdBody = Buffer.alloc(8);
88
- tfhdBody.writeUInt32BE(trackId, 4);
89
- const tfdtBody = Buffer.alloc(12);
90
- tfdtBody.writeUInt8(1, 0);
91
- tfdtBody.writeBigUInt64BE(0n, 4);
92
- return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
93
- }
94
-
95
- /**
96
- * A piece shaped like the `segment` muxer's output, carrying both tracks.
97
- *
98
- * @param {number} offsetSeconds
99
- * @returns {Buffer}
100
- */
101
- function wholePiece(offsetSeconds) {
102
- const mvhdBody = Buffer.alloc(100);
103
- mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
104
- const moov = box("moov", Buffer.concat([
105
- box("mvhd", mvhdBody),
106
- trak(1, VIDEO_TIMESCALE, offsetSeconds),
107
- trak(2, AUDIO_TIMESCALE, offsetSeconds)
108
- ]));
109
- const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
110
- return Buffer.concat([
111
- box("ftyp", Buffer.alloc(16, 0)),
112
- moov,
113
- moof,
114
- box("mdat", Buffer.alloc(64, 0x5a)),
115
- box("mfra", Buffer.alloc(24, 0))
116
- ]);
117
- }
118
-
119
-
120
- /**
121
- * A session with the output directory every run writes into.
122
- *
123
- * @returns {Promise<{ manager: object, session: object, dirPath: string }>}
124
- */
125
- async function sessionOnOneDirectory() {
126
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "produced-copy-"));
127
- // Its own store root — see `helpers/manager.js` for what sharing one cost.
128
- const { manager } = managerWithOwnStore();
129
- const session = {
130
- id: SESSION_ID,
131
- spec: outputSpec({ transcodeVideo: false }),
132
- dirPath,
133
- // Where this file is cut, held by the file. A fixture that stated it
134
- // on the session was describing what production no longer does.
135
- timeline: new Timeline({
136
- boundaries: [0, SEGMENT_SECONDS, 25, 37.5, 50],
137
- cutGrid: "uniform"
138
- }),
139
- state: "ready",
140
- file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "Drifters - 04.mkv" }),
141
- // An ordinary session reads its own file, and its sound is inside it. The
142
- // three differ only for a soundtrack shipped as a file of its own.
143
- get inputFile() { return this.file; },
144
- get audioFile() { return this.file; },
145
- startedAt: Date.now(),
146
- lastAccessedAt: Date.now(),
147
- runs: new Set(),
148
- lastError: "",
149
- segmentFormat: fmp4Format,
150
- useSyntheticPlaylist: true,
151
- playlistText: "#EXTM3U\n",
152
- initBytes: fmp4Format.extractInit(wholePiece(0)),
153
- waitEpoch: 0
154
- };
155
- manager.outputs.set(SESSION_ID, session);
156
- // A run exists and is alive — the state the field case was in, and the one in
157
- // which the old test called every leftover "still being written".
158
- startRunOn(session, { from: 0, usesExplicitCuts: true });
159
- return { manager, session, dirPath };
160
- }
161
-
162
-
163
-
164
- test("a leftover of a run that has ended is not served, and answering does not delete it", async (t) => {
165
- const { manager, session, dirPath } = await sessionOnOneDirectory();
166
- t.after(async () => {
167
- await manager.lifecycle.disposeAll();
168
- await rm(dirPath, { recursive: true, force: true });
169
- });
170
- // Opened by a run that is gone. It has a name and no bytes, so there is
171
- // nothing to serve.
172
- session.runs = new Set();
173
- await writeFile(path.join(dirPath, "segment-00001.mp4"), Buffer.alloc(0));
174
-
175
- const result = await manager.serving.getFileStream(SESSION_ID, "segment-00001.mp4");
176
-
177
- assert.equal(result.kind, "warming-up", "nothing servable exists yet, so the viewer waits");
178
- // AND ANSWERING A REQUEST DELETES NOTHING. It used to: this test was written
179
- // when the serving path cleared up after ended runs, and several runs of an
180
- // output share one directory — so what a request removed could be the piece a
181
- // LIVE run had just finished. Clearing up belongs to the ending of a run,
182
- // which is the only thing that knows what it left open and what it named
183
- // and it knows because the piece is under that run's own working name
184
- // (`SegmentStore.clearUpAfter`, checked in `segment-store.test.js`). A file
185
- // with no bytes is
186
- // ignored by everything that reads the directory, so leaving it costs nothing
187
- // and deleting it from here costs a segment.
188
- const left = (await readdir(dirPath)).filter((name) => name === "segment-00001.mp4");
189
- assert.deepEqual(left, ["segment-00001.mp4"]);
190
- });
191
-
192
- test("the current run's own unfinished piece is waited for, never deleted", async (t) => {
193
- const { manager, session, dirPath } = await sessionOnOneDirectory();
194
- t.after(async () => {
195
- await manager.lifecycle.disposeAll();
196
- await rm(dirPath, { recursive: true, force: true });
197
- });
198
- // The same file, in the directory of the run that is alive. It is being
199
- // written right now. Deleting it is the 2026-08-06 incident: #225 was removed
200
- // 14 s into the run producing it, which then wrote on into a file nobody
201
- // could open, and the segment never appeared.
202
- //
203
- // The live run begins at #1, so #1 is its own first piece.
204
- startRunOn(session, { from: 1, producing: false, usesExplicitCuts: true });
205
- await writeFile(path.join(dirPath, "segment-00001.mp4"), Buffer.alloc(0));
206
-
207
- const result = await manager.serving.getFileStream(SESSION_ID, "segment-00001.mp4");
208
-
209
- assert.equal(result.kind, "warming-up");
210
- const left = (await readdir(dirPath)).filter((name) => name === "segment-00001.mp4");
211
- assert.deepEqual(left, ["segment-00001.mp4"], "the live run's own output must be left alone");
212
- });
213
-
1
+ /**
2
+ * @file A piece that cannot be played: waited for, or removed and made again.
3
+ *
4
+ * Field 2026-09-03. A run was suspended 548 ms after it started with
5
+ * `segment-00025.mp4` newly opened, so the file stayed at zero bytes. From then
6
+ * on the two halves of the proxy disagreed about it and neither could see the
7
+ * other's reason: the look-ahead counted the NAME, found the numbering unbroken
8
+ * through #83, called it `420s ahead of the viewer` and kept the encoder
9
+ * stopped; the serving path read the FILE, found it short of a track, and
10
+ * waited for a run that had produced nothing to finish it.
11
+ *
12
+ * The viewer's picture stood still for ten minutes on a transport measuring
13
+ * 3-9 ms round trip.
14
+ *
15
+ * The question of WHICH copy answers is gone with the directory-per-run scheme:
16
+ * there is one directory per output, runs are kept apart by their stretches,
17
+ * and one name is one file. What is left is the question that remains real —
18
+ * whether a file that cannot be played is being written now or was left by a
19
+ * run that has ended.
20
+ */
21
+
22
+ import test from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import { SourceFile } from "../services/media/SourceFile.js";
25
+ import { Timeline } from "../services/encode/output/Timeline.js";
26
+ import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { managerWithOwnStore } from "./helpers/manager.js";
30
+ import { startRunOn } from "./helpers/encode-run.js";
31
+ import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
32
+ import { outputSpec } from "./helpers/output-spec.js";
33
+
34
+ const MOVIE_TIMESCALE = 1000;
35
+ const VIDEO_TIMESCALE = 90_000;
36
+ const AUDIO_TIMESCALE = 48_000;
37
+ const SEGMENT_SECONDS = 12.5;
38
+ const SESSION_ID = "aef21c88a8d64a9a";
39
+
40
+ /**
41
+ * @param {string} type
42
+ * @param {Buffer} body
43
+ * @returns {Buffer}
44
+ */
45
+ function box(type, body) {
46
+ const head = Buffer.alloc(8);
47
+ head.writeUInt32BE(8 + body.length, 0);
48
+ head.write(type, 4, "latin1");
49
+ return Buffer.concat([head, body]);
50
+ }
51
+
52
+ /**
53
+ * @param {number} offsetSeconds
54
+ * @returns {Buffer}
55
+ */
56
+ function emptyEdit(offsetSeconds) {
57
+ const body = Buffer.alloc(16);
58
+ body.writeUInt32BE(1, 4);
59
+ body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8);
60
+ body.writeInt32BE(-1, 12);
61
+ return box("elst", body);
62
+ }
63
+
64
+ /**
65
+ * @param {number} trackId
66
+ * @param {number} timescale
67
+ * @param {number} offsetSeconds
68
+ * @returns {Buffer}
69
+ */
70
+ function trak(trackId, timescale, offsetSeconds) {
71
+ const tkhdBody = Buffer.alloc(84);
72
+ tkhdBody.writeUInt32BE(trackId, 12);
73
+ const mdhdBody = Buffer.alloc(20);
74
+ mdhdBody.writeUInt32BE(timescale, 12);
75
+ return box("trak", Buffer.concat([
76
+ box("tkhd", tkhdBody),
77
+ box("edts", emptyEdit(offsetSeconds)),
78
+ box("mdia", box("mdhd", mdhdBody))
79
+ ]));
80
+ }
81
+
82
+ /**
83
+ * @param {number} trackId
84
+ * @returns {Buffer}
85
+ */
86
+ function traf(trackId) {
87
+ const tfhdBody = Buffer.alloc(8);
88
+ tfhdBody.writeUInt32BE(trackId, 4);
89
+ const tfdtBody = Buffer.alloc(12);
90
+ tfdtBody.writeUInt8(1, 0);
91
+ tfdtBody.writeBigUInt64BE(0n, 4);
92
+ return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
93
+ }
94
+
95
+ /**
96
+ * A piece shaped like the `segment` muxer's output, carrying both tracks.
97
+ *
98
+ * @param {number} offsetSeconds
99
+ * @returns {Buffer}
100
+ */
101
+ function wholePiece(offsetSeconds) {
102
+ const mvhdBody = Buffer.alloc(100);
103
+ mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
104
+ const moov = box("moov", Buffer.concat([
105
+ box("mvhd", mvhdBody),
106
+ trak(1, VIDEO_TIMESCALE, offsetSeconds),
107
+ trak(2, AUDIO_TIMESCALE, offsetSeconds)
108
+ ]));
109
+ const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
110
+ return Buffer.concat([
111
+ box("ftyp", Buffer.alloc(16, 0)),
112
+ moov,
113
+ moof,
114
+ box("mdat", Buffer.alloc(64, 0x5a)),
115
+ box("mfra", Buffer.alloc(24, 0))
116
+ ]);
117
+ }
118
+
119
+
120
+ /**
121
+ * A session with the output directory every run writes into.
122
+ *
123
+ * @returns {Promise<{ manager: object, session: object, dirPath: string }>}
124
+ */
125
+ async function sessionOnOneDirectory() {
126
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "produced-copy-"));
127
+ // Its own store root — see `helpers/manager.js` for what sharing one cost.
128
+ const { manager } = managerWithOwnStore();
129
+ const session = {
130
+ id: SESSION_ID,
131
+ spec: outputSpec({ transcodeVideo: false }),
132
+ dirPath,
133
+ // Where this file is cut, held by the file. A fixture that stated it
134
+ // on the session was describing what production no longer does.
135
+ timeline: new Timeline({
136
+ boundaries: [0, SEGMENT_SECONDS, 25, 37.5, 50],
137
+ cutGrid: "uniform"
138
+ }),
139
+ state: "ready",
140
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "Drifters - 04.mkv" }),
141
+ // An ordinary session reads its own file, and its sound is inside it. The
142
+ // three differ only for a soundtrack shipped as a file of its own.
143
+ get inputFile() { return this.file; },
144
+ get audioFile() { return this.file; },
145
+ startedAt: Date.now(),
146
+ lastAccessedAt: Date.now(),
147
+ runs: new Set(),
148
+ lastError: "",
149
+ segmentFormat: fmp4Format,
150
+ useSyntheticPlaylist: true,
151
+ playlistText: "#EXTM3U\n",
152
+ initBytes: fmp4Format.extractInit(wholePiece(0)),
153
+ waitEpoch: 0
154
+ };
155
+ manager.outputs.set(SESSION_ID, session);
156
+ // A run exists and is alive — the state the field case was in, and the one in
157
+ // which the old test called every leftover "still being written".
158
+ startRunOn(session, { from: 0, usesExplicitCuts: true });
159
+ return { manager, session, dirPath };
160
+ }
161
+
162
+
163
+
164
+ test("a leftover of a run that has ended is not served, and answering does not delete it", async (t) => {
165
+ const { manager, session, dirPath } = await sessionOnOneDirectory();
166
+ t.after(async () => {
167
+ await manager.lifecycle.disposeAll();
168
+ await rm(dirPath, { recursive: true, force: true });
169
+ });
170
+ // Opened by a run that is gone. It has a name and no bytes, so there is
171
+ // nothing to serve.
172
+ session.runs = new Set();
173
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), Buffer.alloc(0));
174
+
175
+ const result = await manager.serving.getFileStream(SESSION_ID, "segment-00001.mp4");
176
+
177
+ assert.equal(result.kind, "warming-up", "nothing servable exists yet, so the viewer waits");
178
+ // AND ANSWERING A REQUEST DELETES NOTHING. It used to: this test was written
179
+ // when the serving path cleared up after ended runs, and several runs of an
180
+ // output share one directory — so what a request removed could be the piece a
181
+ // LIVE run had just finished. Clearing up belongs to the ending of a run,
182
+ // which is the only thing that knows what it left open and what it named
183
+ // and it knows because the piece is under that run's own working name
184
+ // (`SegmentStore.clearUpAfter`, checked in `segment-store.test.js`). A file
185
+ // with no bytes is
186
+ // ignored by everything that reads the directory, so leaving it costs nothing
187
+ // and deleting it from here costs a segment.
188
+ const left = (await readdir(dirPath)).filter((name) => name === "segment-00001.mp4");
189
+ assert.deepEqual(left, ["segment-00001.mp4"]);
190
+ });
191
+
192
+ test("the current run's own unfinished piece is waited for, never deleted", async (t) => {
193
+ const { manager, session, dirPath } = await sessionOnOneDirectory();
194
+ t.after(async () => {
195
+ await manager.lifecycle.disposeAll();
196
+ await rm(dirPath, { recursive: true, force: true });
197
+ });
198
+ // The same file, in the directory of the run that is alive. It is being
199
+ // written right now. Deleting it is the 2026-08-06 incident: #225 was removed
200
+ // 14 s into the run producing it, which then wrote on into a file nobody
201
+ // could open, and the segment never appeared.
202
+ //
203
+ // The live run begins at #1, so #1 is its own first piece.
204
+ startRunOn(session, { from: 1, producing: false, usesExplicitCuts: true });
205
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), Buffer.alloc(0));
206
+
207
+ const result = await manager.serving.getFileStream(SESSION_ID, "segment-00001.mp4");
208
+
209
+ assert.equal(result.kind, "warming-up");
210
+ const left = (await readdir(dirPath)).filter((name) => name === "segment-00001.mp4");
211
+ assert.deepEqual(left, ["segment-00001.mp4"], "the live run's own output must be left alone");
212
+ });
213
+
@@ -1,83 +1,83 @@
1
- /**
2
- * @file A segment must be stamped where the playlist the player holds says.
3
- *
4
- * The field case this pins, 2026-08-17: a seek to 1590.4 s restarted the audio
5
- * rendition at #291; the produced segments #292 and #293 carried their own
6
- * positions, 1587.892 s and 1592.692 s, while the playlist the browser was
7
- * holding said 1585.376 s and 1590.585 s. The browser fetched those two
8
- * segments **1908 times each** over ten minutes, every one served in 4 ms, and
9
- * the film never moved: a fragment appended more than `maxBufferHole` away from
10
- * where the playlist put it is not recognised as buffered, so the player asks
11
- * for it again.
12
- *
13
- * The rule, and the reason it is not simply "always use the file's own time":
14
- * the boundary table keeps being corrected from produced segments, and the
15
- * playlist does not. Whichever is right about the FILE, only one of them was
16
- * sent to the player.
17
- */
18
-
19
- import assert from "node:assert/strict";
20
- import test from "node:test";
21
-
22
- /**
23
- * hls.js's own default. A gap smaller than this is skipped; a larger one is a
24
- * hole, and the fragment across it counts as not loaded.
25
- */
26
- const PLAYER_BUFFER_HOLE_SEC = 0.5;
27
-
28
- /**
29
- * The decision under test, in the shape the session manager applies it.
30
- *
31
- * @param {{ trueStart: number | null, publishedStart: number }} reading
32
- * @returns {{ stamp: number, followedPlaylist: boolean }}
33
- */
34
- function stampFor({ trueStart, publishedStart }) {
35
- if (trueStart === null) {
36
- return { stamp: publishedStart, followedPlaylist: true };
37
- }
38
- if (Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
39
- return { stamp: publishedStart, followedPlaylist: true };
40
- }
41
- return { stamp: trueStart, followedPlaylist: false };
42
- }
43
-
44
- test("the field case: a segment far from its playlist position is stamped where the playlist says", () => {
45
- const segment292 = stampFor({ trueStart: 1587.892, publishedStart: 1585.376 });
46
- assert.equal(segment292.stamp, 1585.376);
47
- assert.equal(segment292.followedPlaylist, true);
48
-
49
- const segment293 = stampFor({ trueStart: 1592.692, publishedStart: 1590.585 });
50
- assert.equal(segment293.stamp, 1590.585);
51
- });
52
-
53
- test("a file whose index is honest keeps its own position", () => {
54
- // Within what a player bridges, the file's own figure is the truthful one and
55
- // it is what keeps speech and subtitles together (2026-08-06, 4.17 s of drift
56
- // when the picture was stamped from a lying index).
57
- const reading = stampFor({ trueStart: 120.13, publishedStart: 120.0 });
58
- assert.equal(reading.stamp, 120.13);
59
- assert.equal(reading.followedPlaylist, false);
60
- });
61
-
62
- test("the boundary is exactly the player's own tolerance", () => {
63
- assert.equal(stampFor({ trueStart: 10.5, publishedStart: 10.0 }).stamp, 10.5);
64
- assert.equal(stampFor({ trueStart: 10.51, publishedStart: 10.0 }).stamp, 10.0);
65
- });
66
-
67
- test("a segment that does not say where it begins is stamped from the playlist", () => {
68
- assert.equal(stampFor({ trueStart: null, publishedStart: 42.0 }).stamp, 42.0);
69
- });
70
-
71
- test("a corrected boundary does not move the stamp of a session already playing", () => {
72
- // The correction writes into the live table; the published one is frozen. If
73
- // the stamp followed the live table, every correction would move segments
74
- // under a player holding the original playlist — which is the same failure
75
- // seen from the other side.
76
- const published = [0, 4, 8, 12];
77
- const live = [...published];
78
- live[2] = 9.7; // corrected from a produced segment
79
- const publishedStart = published[2];
80
- const reading = stampFor({ trueStart: live[2], publishedStart });
81
- assert.equal(publishedStart, 8);
82
- assert.equal(reading.stamp, 8, "the player was told 8 s and must be given 8 s");
83
- });
1
+ /**
2
+ * @file A segment must be stamped where the playlist the player holds says.
3
+ *
4
+ * The field case this pins, 2026-08-17: a seek to 1590.4 s restarted the audio
5
+ * rendition at #291; the produced segments #292 and #293 carried their own
6
+ * positions, 1587.892 s and 1592.692 s, while the playlist the browser was
7
+ * holding said 1585.376 s and 1590.585 s. The browser fetched those two
8
+ * segments **1908 times each** over ten minutes, every one served in 4 ms, and
9
+ * the film never moved: a fragment appended more than `maxBufferHole` away from
10
+ * where the playlist put it is not recognised as buffered, so the player asks
11
+ * for it again.
12
+ *
13
+ * The rule, and the reason it is not simply "always use the file's own time":
14
+ * the boundary table keeps being corrected from produced segments, and the
15
+ * playlist does not. Whichever is right about the FILE, only one of them was
16
+ * sent to the player.
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import test from "node:test";
21
+
22
+ /**
23
+ * hls.js's own default. A gap smaller than this is skipped; a larger one is a
24
+ * hole, and the fragment across it counts as not loaded.
25
+ */
26
+ const PLAYER_BUFFER_HOLE_SEC = 0.5;
27
+
28
+ /**
29
+ * The decision under test, in the shape the session manager applies it.
30
+ *
31
+ * @param {{ trueStart: number | null, publishedStart: number }} reading
32
+ * @returns {{ stamp: number, followedPlaylist: boolean }}
33
+ */
34
+ function stampFor({ trueStart, publishedStart }) {
35
+ if (trueStart === null) {
36
+ return { stamp: publishedStart, followedPlaylist: true };
37
+ }
38
+ if (Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
39
+ return { stamp: publishedStart, followedPlaylist: true };
40
+ }
41
+ return { stamp: trueStart, followedPlaylist: false };
42
+ }
43
+
44
+ test("the field case: a segment far from its playlist position is stamped where the playlist says", () => {
45
+ const segment292 = stampFor({ trueStart: 1587.892, publishedStart: 1585.376 });
46
+ assert.equal(segment292.stamp, 1585.376);
47
+ assert.equal(segment292.followedPlaylist, true);
48
+
49
+ const segment293 = stampFor({ trueStart: 1592.692, publishedStart: 1590.585 });
50
+ assert.equal(segment293.stamp, 1590.585);
51
+ });
52
+
53
+ test("a file whose index is honest keeps its own position", () => {
54
+ // Within what a player bridges, the file's own figure is the truthful one and
55
+ // it is what keeps speech and subtitles together (2026-08-06, 4.17 s of drift
56
+ // when the picture was stamped from a lying index).
57
+ const reading = stampFor({ trueStart: 120.13, publishedStart: 120.0 });
58
+ assert.equal(reading.stamp, 120.13);
59
+ assert.equal(reading.followedPlaylist, false);
60
+ });
61
+
62
+ test("the boundary is exactly the player's own tolerance", () => {
63
+ assert.equal(stampFor({ trueStart: 10.5, publishedStart: 10.0 }).stamp, 10.5);
64
+ assert.equal(stampFor({ trueStart: 10.51, publishedStart: 10.0 }).stamp, 10.0);
65
+ });
66
+
67
+ test("a segment that does not say where it begins is stamped from the playlist", () => {
68
+ assert.equal(stampFor({ trueStart: null, publishedStart: 42.0 }).stamp, 42.0);
69
+ });
70
+
71
+ test("a corrected boundary does not move the stamp of a session already playing", () => {
72
+ // The correction writes into the live table; the published one is frozen. If
73
+ // the stamp followed the live table, every correction would move segments
74
+ // under a player holding the original playlist — which is the same failure
75
+ // seen from the other side.
76
+ const published = [0, 4, 8, 12];
77
+ const live = [...published];
78
+ live[2] = 9.7; // corrected from a produced segment
79
+ const publishedStart = published[2];
80
+ const reading = stampFor({ trueStart: live[2], publishedStart });
81
+ assert.equal(publishedStart, 8);
82
+ assert.equal(reading.stamp, 8, "the player was told 8 s and must be given 8 s");
83
+ });