@torrent-tv/proxy 2.87.9 → 2.87.11

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 (458) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2186 -2177
  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/biome.json +411 -411
  22. package/docs/browser-proxy-contract.md +124 -101
  23. package/docs/container-architecture.md +218 -218
  24. package/docs/download-architecture.md +261 -261
  25. package/docs/encode-architecture.md +729 -729
  26. package/docs/encode-run-state.md +96 -96
  27. package/docs/logs.md +97 -97
  28. package/docs/socks5-egress-research.md +114 -114
  29. package/docs/storage-architecture.md +267 -267
  30. package/knip.json +14 -14
  31. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  32. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  33. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  34. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  35. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  36. package/openspec/changes/binary-distribution/proposal.md +53 -53
  37. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  38. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  39. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  40. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  41. package/openspec/changes/cold-start/design.md +98 -98
  42. package/openspec/changes/cold-start/proposal.md +62 -62
  43. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  44. package/openspec/changes/cold-start/tasks.md +48 -48
  45. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  46. package/openspec/changes/disk-cap/proposal.md +37 -37
  47. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  48. package/openspec/changes/disk-cap/tasks.md +19 -19
  49. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  50. package/openspec/changes/proxy-observability/design.md +38 -38
  51. package/openspec/changes/proxy-observability/proposal.md +49 -49
  52. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  53. package/openspec/changes/proxy-observability/tasks.md +21 -21
  54. package/openspec/changes/seek-debounce/design.md +118 -118
  55. package/openspec/changes/seek-debounce/proposal.md +59 -59
  56. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  57. package/openspec/changes/seek-debounce/tasks.md +33 -33
  58. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  59. package/openspec/changes/subtitle-language/proposal.md +50 -50
  60. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  61. package/openspec/changes/subtitle-language/tasks.md +26 -26
  62. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  63. package/openspec/changes/track-selection/design.md +47 -47
  64. package/openspec/changes/track-selection/proposal.md +45 -45
  65. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  66. package/openspec/changes/track-selection/tasks.md +20 -20
  67. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  68. package/openspec/changes/transcode-quality/proposal.md +51 -51
  69. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  70. package/openspec/changes/transcode-quality/tasks.md +63 -63
  71. package/openspec/config.yaml +38 -38
  72. package/package.json +50 -50
  73. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  74. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  75. package/research/double-spawn-2026-09-10.md +171 -171
  76. package/research/handover-2026-09-13.md +209 -209
  77. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  78. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  79. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  80. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  81. package/routes/api/client-logs/post.js +86 -86
  82. package/routes/api/delivery-sink/get.js +72 -72
  83. package/routes/api/link-probe/get.js +51 -51
  84. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  85. package/routes/api/playback-plan/post.js +53 -53
  86. package/routes/api/sources/files/get.js +100 -100
  87. package/routes/api/sources/post.js +36 -36
  88. package/routes/api/sources/stats/get.js +132 -132
  89. package/routes/api/subtitles/get.js +230 -230
  90. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  91. package/routes/api/transcode-sessions/net-report/post.js +136 -129
  92. package/routes/api/transcode-sessions/post.js +160 -160
  93. package/routes/api/transcode-sessions/progress/get.js +27 -27
  94. package/routes/api/transcode-sessions/release/post.js +41 -41
  95. package/routes/api/transcode-sessions/seek/post.js +63 -63
  96. package/routes/health/get.js +13 -13
  97. package/routes/healthz/get.js +13 -13
  98. package/routes/stream/get.js +361 -361
  99. package/routes/transcode/audio-file/get.js +64 -64
  100. package/routes/transcode/audio-warm/get.js +84 -84
  101. package/routes/transcode/session-file/get.js +52 -52
  102. package/routes/transcode/variant-file/get.js +95 -95
  103. package/routes/transcode/variant-warm/get.js +84 -84
  104. package/scripts/render-run-graph.js +173 -173
  105. package/services/encode/CoverageMap.js +428 -428
  106. package/services/encode/CushionReport.js +345 -345
  107. package/services/encode/EncodeAdmission.js +332 -332
  108. package/services/encode/EncodeOrchestrator.js +1217 -1217
  109. package/services/encode/EncodePlan.js +1366 -1366
  110. package/services/encode/EncodeRun.js +776 -776
  111. package/services/encode/EncodeRuns.js +1229 -1229
  112. package/services/encode/Encoder.js +142 -142
  113. package/services/encode/EncoderSelection.js +38 -38
  114. package/services/encode/LocalObservations.js +359 -359
  115. package/services/encode/NvencEncoder.js +50 -50
  116. package/services/encode/OutputOpening.js +755 -755
  117. package/services/encode/OutputTimes.js +579 -579
  118. package/services/encode/QsvEncoder.js +58 -58
  119. package/services/encode/Renditions.js +2184 -2162
  120. package/services/encode/RunProgress.js +84 -84
  121. package/services/encode/SegmentDemand.js +95 -95
  122. package/services/encode/SoftwareEncoder.js +121 -121
  123. package/services/encode/V4l2m2mEncoder.js +53 -53
  124. package/services/encode/VaapiEncoder.js +66 -66
  125. package/services/encode/args.js +416 -416
  126. package/services/encode/available-share.js +89 -89
  127. package/services/encode/calibration.js +361 -361
  128. package/services/encode/contention.js +128 -128
  129. package/services/encode/decode-cost-fit.js +363 -363
  130. package/services/encode/encode-exit.js +114 -114
  131. package/services/encode/encode-run-state.js +551 -551
  132. package/services/encode/encoder-readings.js +45 -45
  133. package/services/encode/fingerprint.js +322 -322
  134. package/services/encode/host-load.js +174 -174
  135. package/services/encode/hwaccel.js +1894 -1894
  136. package/services/encode/index.js +9 -9
  137. package/services/encode/learned-median.js +90 -90
  138. package/services/encode/output/EncodedOutput.js +57 -57
  139. package/services/encode/output/Output.js +131 -131
  140. package/services/encode/output/OutputCatalog.js +414 -414
  141. package/services/encode/output/OutputSpec.js +390 -390
  142. package/services/encode/output/Timeline.js +308 -308
  143. package/services/encode/output/cut-grid.js +107 -107
  144. package/services/encode/output/index.js +20 -20
  145. package/services/encode/output/ladder.js +32 -32
  146. package/services/encode/output/playlists.js +284 -284
  147. package/services/encode/output/rates.js +125 -125
  148. package/services/encode/output-key-format.js +21 -21
  149. package/services/encode/quality/EncodeCost.js +1110 -1083
  150. package/services/encode/quality/HostLoad.js +525 -525
  151. package/services/encode/quality/HostTimings.js +249 -249
  152. package/services/encode/quality/OutputQualityState.js +28 -28
  153. package/services/encode/quality/QualityController.js +935 -935
  154. package/services/encode/quality/QualityOffer.js +416 -416
  155. package/services/encode/quality/drain-threat.js +47 -47
  156. package/services/encode/quality/link-budget.js +254 -254
  157. package/services/encode/quality/output-format.js +266 -266
  158. package/services/encode/quality/serving-output.js +216 -216
  159. package/services/encode/quality/visible-rung.js +57 -57
  160. package/services/encode/run-budget.js +97 -97
  161. package/services/encode/run-command.js +747 -747
  162. package/services/encode/run-costs.js +155 -155
  163. package/services/encode/run-inputs.js +42 -42
  164. package/services/encode/segment-formats/fmp4.js +437 -437
  165. package/services/encode/segment-formats/index.js +77 -77
  166. package/services/encode/segment-formats/init-compat.js +324 -324
  167. package/services/encode/segment-formats/mp4-boxes.js +311 -311
  168. package/services/encode/segment-formats/mpegts.js +134 -134
  169. package/services/encode/start-stop-cost.js +178 -178
  170. package/services/encode/throughput.js +97 -97
  171. package/services/encode/torrent-cost.js +88 -88
  172. package/services/identity/installation-id.js +38 -38
  173. package/services/media/ContainerOrchestrator.js +149 -149
  174. package/services/media/KeyframeTables.js +316 -316
  175. package/services/media/SourceFile.js +298 -298
  176. package/services/media/SubtitleCues.js +520 -520
  177. package/services/media/SubtitleOrchestrator.js +167 -167
  178. package/services/media/container/AviContainer.js +266 -266
  179. package/services/media/container/Container.js +603 -603
  180. package/services/media/container/ContainerFactory.js +56 -56
  181. package/services/media/container/KeyframeTable.js +272 -272
  182. package/services/media/container/MatroskaContainer.js +1494 -1494
  183. package/services/media/container/Mp4Container.js +1244 -1244
  184. package/services/media/container/SubtitleFileContainer.js +322 -322
  185. package/services/media/container/ebml-reader.js +159 -159
  186. package/services/media/ffmpeg-banner.js +260 -260
  187. package/services/media/keyframe-probe.js +157 -157
  188. package/services/media/media-info-probe.js +93 -93
  189. package/services/media/playback-planner.js +931 -928
  190. package/services/media/tracks/AudioTrack.js +131 -131
  191. package/services/media/tracks/ContainerTrack.js +75 -75
  192. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  193. package/services/media/tracks/SubtitleTrack.js +38 -38
  194. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  195. package/services/media/tracks/VideoTrack.js +33 -33
  196. package/services/media/tracks/index.js +15 -15
  197. package/services/media/tracks/language-detect.js +174 -174
  198. package/services/server/OutputLifecycle.js +418 -418
  199. package/services/server/SegmentServing.js +1146 -1146
  200. package/services/server/ViewerRequests.js +703 -522
  201. package/services/server/WaitLedger.js +142 -142
  202. package/services/server/controllers/SubtitleController.js +109 -109
  203. package/services/server/http-responses.js +60 -60
  204. package/services/server/playback-readiness.js +620 -0
  205. package/services/server/transcode-session-files.js +325 -325
  206. package/services/server/wire-outputs.js +989 -983
  207. package/services/storage/Diagnostics.js +202 -202
  208. package/services/storage/MachineBudget.js +235 -235
  209. package/services/storage/allowance.js +107 -107
  210. package/services/storage/core-dumps.js +106 -106
  211. package/services/storage/files/CompletedFiles.js +391 -391
  212. package/services/storage/files/piece-from-whole-file.js +118 -118
  213. package/services/storage/free.js +71 -71
  214. package/services/storage/keep.js +48 -48
  215. package/services/storage/machine-memory.js +56 -56
  216. package/services/storage/memory-report.js +575 -575
  217. package/services/storage/piece-store/piece-disk-store.js +516 -516
  218. package/services/storage/piece-store/piece-lru.js +495 -495
  219. package/services/storage/piece-store/shared-piece-store.js +1880 -1880
  220. package/services/storage/returns.js +103 -103
  221. package/services/storage/segment-store/SegmentStore.js +1196 -1196
  222. package/services/storage/wire.js +175 -175
  223. package/services/torrent/Contents.js +324 -324
  224. package/services/torrent/demand/DemandRegister.js +202 -202
  225. package/services/torrent/demand/Urgency.js +137 -137
  226. package/services/torrent/demand/Window.js +118 -118
  227. package/services/torrent/demand/index.js +8 -8
  228. package/services/torrent/demand/pieces.js +140 -140
  229. package/services/torrent/download/SwarmSelection.js +386 -386
  230. package/services/torrent/download/registry.js +102 -102
  231. package/services/torrent/download/withdraw-claim.js +80 -80
  232. package/services/torrent/files.js +455 -455
  233. package/services/torrent/naming.js +619 -619
  234. package/services/torrent/piece-store-of.js +29 -29
  235. package/services/torrent/supply-margin.js +262 -262
  236. package/services/torrent/worker/background-fill.js +213 -213
  237. package/services/torrent/worker/channel.js +271 -271
  238. package/services/torrent/worker/fastest-wires.js +342 -342
  239. package/services/torrent/worker/handle-state.js +23 -23
  240. package/services/torrent/worker/held-bytes.js +155 -155
  241. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  242. package/services/torrent/worker/piece-reader.js +1078 -1078
  243. package/services/torrent/worker/resume-warm.js +107 -107
  244. package/services/torrent/worker/webrtc-shim.js +219 -219
  245. package/services/transport/delivery-probe.js +842 -842
  246. package/services/transport/health-collector.js +53 -53
  247. package/services/transport/nat-classifier.js +231 -231
  248. package/services/transport/packet-witness.js +808 -808
  249. package/services/transport/port-mapper.js +235 -235
  250. package/services/transport/registry-api.js +81 -81
  251. package/services/transport/tunnel-client.js +447 -447
  252. package/services/transport/usrsctp-state.js +175 -175
  253. package/services/transport/webrtc-manager.js +484 -484
  254. package/services/viewer/Assignments.js +340 -340
  255. package/services/viewer/PriorityMap.js +390 -390
  256. package/services/viewer/PriorityOrchestrator.js +325 -325
  257. package/services/viewer/Viewer.js +675 -657
  258. package/services/viewer/Viewers.js +368 -368
  259. package/services/viewer/active-output.js +37 -37
  260. package/services/viewer/audio-start.js +46 -46
  261. package/services/viewer/buffer-trend.js +81 -81
  262. package/services/viewer/choices.js +647 -647
  263. package/services/viewer/positions.js +126 -126
  264. package/services/viewer/report-intake.js +46 -46
  265. package/store/source-registry.js +71 -71
  266. package/test/asset-paths.test.js +28 -28
  267. package/test/audio-track-bitrate.test.js +40 -40
  268. package/test/auto-quality-step.test.js +707 -707
  269. package/test/available-share.test.js +76 -76
  270. package/test/background-fill.test.js +190 -190
  271. package/test/client-log-file.test.js +110 -110
  272. package/test/completed-files.test.js +115 -115
  273. package/test/concurrent-cost.test.js +148 -148
  274. package/test/container-media-info.test.js +228 -228
  275. package/test/contention.test.js +76 -76
  276. package/test/core-dumps.test.js +57 -57
  277. package/test/coverage-follows-the-disk.test.js +228 -228
  278. package/test/coverage-map.test.js +195 -195
  279. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  280. package/test/cut-times-timeline.test.js +65 -65
  281. package/test/cuts-follow-published-grid.test.js +109 -109
  282. package/test/data-channel-frame.test.js +63 -63
  283. package/test/dead-torrent-handle.test.js +52 -52
  284. package/test/declared-rates.test.js +116 -116
  285. package/test/declared-tracks.test.js +43 -35
  286. package/test/decode-cost-fit.test.js +202 -202
  287. package/test/decode-cost.test.js +462 -462
  288. package/test/delivery-probe.test.js +393 -393
  289. package/test/demand-register.test.js +206 -206
  290. package/test/dht-bootstrap.test.js +72 -72
  291. package/test/drain-threat.test.js +65 -65
  292. package/test/duplicate-add.test.js +48 -48
  293. package/test/encode-admission.test.js +146 -146
  294. package/test/encode-cost.test.js +283 -283
  295. package/test/encode-exit.test.js +111 -111
  296. package/test/encode-orchestrator.test.js +419 -419
  297. package/test/encode-plan-viewers.test.js +718 -718
  298. package/test/encode-plan.test.js +545 -545
  299. package/test/encode-run-state.test.js +314 -314
  300. package/test/encode-run.test.js +161 -161
  301. package/test/encode-slope.test.js +46 -46
  302. package/test/encoded-output.test.js +39 -39
  303. package/test/encoder-kinds.test.js +122 -122
  304. package/test/encoder-readings.test.js +46 -46
  305. package/test/every-claimant-is-told-its-share.test.js +152 -152
  306. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  307. package/test/failed-start-breaker.test.js +202 -202
  308. package/test/fastest-wires.test.js +143 -143
  309. package/test/ffmpeg-stream-counts.test.js +78 -78
  310. package/test/file-edges.test.js +191 -191
  311. package/test/fmp4-self-contained.test.js +80 -80
  312. package/test/fmp4-timestamps.test.js +140 -140
  313. package/test/fmp4-track-completeness.test.js +41 -41
  314. package/test/format-keyed-outputs.test.js +78 -78
  315. package/test/health-metrics.test.js +38 -38
  316. package/test/held-request-width.test.js +157 -157
  317. package/test/helpers/encode-run.js +136 -136
  318. package/test/helpers/held-file.js +29 -29
  319. package/test/helpers/manager.js +57 -57
  320. package/test/helpers/output-spec.js +63 -63
  321. package/test/host-load.test.js +68 -68
  322. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  323. package/test/init-compat.test.js +219 -219
  324. package/test/input-lost-quiets-the-plan.test.js +261 -261
  325. package/test/input-unavailable.test.js +48 -48
  326. package/test/installation-id.test.js +39 -39
  327. package/test/keeping-period.test.js +83 -83
  328. package/test/keyframe-index-accuracy.test.js +188 -188
  329. package/test/keyframe-table-budget.test.js +102 -102
  330. package/test/keyframes-belong-to-the-file.test.js +249 -249
  331. package/test/learned-median.test.js +37 -37
  332. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  333. package/test/limit-row-by-area.test.js +60 -60
  334. package/test/link-budget.test.js +111 -111
  335. package/test/link-probe.test.js +56 -56
  336. package/test/local-encoding-data.test.js +273 -273
  337. package/test/logger-repeats.test.js +134 -134
  338. package/test/look-ahead-coverage.test.js +56 -56
  339. package/test/manager-surface.test.js +292 -292
  340. package/test/matroska-cues-track.test.js +192 -192
  341. package/test/memory-budget.test.js +390 -390
  342. package/test/move-cost.test.js +216 -216
  343. package/test/mp4-subtitles.test.js +173 -173
  344. package/test/one-authority.test.js +285 -285
  345. package/test/one-budget.test.js +149 -149
  346. package/test/one-output-one-session.test.js +204 -204
  347. package/test/one-resource-divided.test.js +185 -185
  348. package/test/one-serving-output-per-height.test.js +458 -458
  349. package/test/orchestrator-wired.test.js +206 -206
  350. package/test/output-catalog.test.js +39 -39
  351. package/test/output-shape.test.js +21 -21
  352. package/test/output-spec.test.js +251 -251
  353. package/test/output-speed.test.js +88 -88
  354. package/test/outputs-being-prepared.test.js +40 -40
  355. package/test/packet-witness-ring.test.js +236 -236
  356. package/test/packet-witness.test.js +148 -148
  357. package/test/piece-disk-store.test.js +381 -381
  358. package/test/piece-from-whole-file.test.js +129 -129
  359. package/test/piece-lru.test.js +319 -319
  360. package/test/piece-reader.test.js +162 -162
  361. package/test/piece-selection-offset.test.js +85 -85
  362. package/test/piece-store-eviction.test.js +560 -560
  363. package/test/piece-store-never-refuses.test.js +153 -153
  364. package/test/piece-store-reservations.test.js +318 -318
  365. package/test/piece-store-slow-disk.test.js +159 -159
  366. package/test/piece-tail.test.js +97 -97
  367. package/test/plan-host-timings.test.js +68 -68
  368. package/test/plan-is-steady.test.js +225 -225
  369. package/test/playback-readiness.test.js +323 -0
  370. package/test/priority-map-download.test.js +174 -174
  371. package/test/priority-map-emptied.test.js +204 -204
  372. package/test/priority-map-per-output.test.js +215 -215
  373. package/test/priority-map.test.js +163 -163
  374. package/test/probe-wedge-certainty.test.js +60 -60
  375. package/test/produced-copy-choice.test.js +213 -213
  376. package/test/published-timeline.test.js +83 -83
  377. package/test/quality-offer.test.js +162 -162
  378. package/test/quality-variants.test.js +1229 -1229
  379. package/test/rate-control-in-the-output.test.js +180 -180
  380. package/test/read-survives-withdrawal.test.js +164 -164
  381. package/test/read-window.test.js +365 -365
  382. package/test/resume-warm.test.js +39 -39
  383. package/test/run-graph-drift.test.js +37 -37
  384. package/test/run-inputs.test.js +80 -80
  385. package/test/run-intervals.test.js +100 -100
  386. package/test/run-position-follows-published-grid.test.js +89 -89
  387. package/test/run-progress.test.js +65 -65
  388. package/test/same-height-switch.test.js +664 -664
  389. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  390. package/test/seek-landing.test.js +109 -109
  391. package/test/seek-target-not-superseded.test.js +195 -195
  392. package/test/segment-closed-on-both-branches.test.js +96 -96
  393. package/test/segment-cut-times.test.js +63 -63
  394. package/test/segment-demand.test.js +80 -80
  395. package/test/segment-hold-supersede.test.js +91 -91
  396. package/test/segment-judged-by-its-own-header.test.js +221 -221
  397. package/test/segment-serve-wiring.test.js +475 -475
  398. package/test/segment-store-eviction.test.js +247 -247
  399. package/test/segment-store.test.js +323 -323
  400. package/test/segment-true-start.test.js +86 -86
  401. package/test/segments-are-shared.test.js +176 -176
  402. package/test/send-chunk-bytes.test.js +86 -86
  403. package/test/serving-existing-output.test.js +102 -102
  404. package/test/serving-output.test.js +102 -102
  405. package/test/shared-piece-store.test.js +310 -310
  406. package/test/sidecar-naming.test.js +142 -142
  407. package/test/source-file.test.js +133 -133
  408. package/test/source-files-route.test.js +103 -103
  409. package/test/source-registry.test.js +18 -18
  410. package/test/split-a-stretch.test.js +139 -139
  411. package/test/stale-request-after-seek.test.js +158 -158
  412. package/test/startup-readings.test.js +124 -124
  413. package/test/stream-route.test.js +182 -182
  414. package/test/subtitle-cue-framing.test.js +200 -200
  415. package/test/subtitle-cue-source.test.js +102 -102
  416. package/test/subtitle-cue-walk.test.js +370 -370
  417. package/test/subtitle-cursor.test.js +63 -63
  418. package/test/subtitle-defaults.test.js +118 -118
  419. package/test/subtitle-language.test.js +252 -252
  420. package/test/subtitle-track-numbering.test.js +386 -386
  421. package/test/supply-margin.test.js +177 -177
  422. package/test/swarm-follows-readers.test.js +174 -174
  423. package/test/swarm-reach.test.js +130 -130
  424. package/test/swarm-selection.test.js +220 -220
  425. package/test/tail-duplication.test.js +167 -167
  426. package/test/the-channel-has-both-ends.test.js +91 -91
  427. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  428. package/test/thread-pool.test.js +53 -53
  429. package/test/timeline.test.js +84 -84
  430. package/test/torrent-contents.test.js +235 -235
  431. package/test/torrent-cost.test.js +71 -71
  432. package/test/torrent-files.test.js +221 -221
  433. package/test/torrent-naming.test.js +255 -255
  434. package/test/tracks-begin-together.test.js +209 -209
  435. package/test/tunnel-renewal.test.js +167 -167
  436. package/test/two-viewers-one-picture.test.js +407 -407
  437. package/test/upload-hurry.test.js +186 -186
  438. package/test/usrsctp-state.test.js +131 -131
  439. package/test/video-facts.test.js +102 -102
  440. package/test/viewer-assignments.test.js +143 -143
  441. package/test/viewer-outputs.test.js +285 -285
  442. package/test/viewer-presence.test.js +116 -116
  443. package/test/viewer-requests-admission.test.js +89 -89
  444. package/test/viewer-states.test.js +189 -189
  445. package/test/viewer-subtitle-subscription.test.js +96 -96
  446. package/test/viewer.test.js +139 -139
  447. package/test/viewing-generations.test.js +390 -390
  448. package/test/visible-rung.test.js +37 -37
  449. package/test/wait-ledger.test.js +100 -100
  450. package/test/webrtc-shim.test.js +149 -149
  451. package/test/wedge-certainty.test.js +131 -131
  452. package/test/withdraw-piece-claim.test.js +212 -212
  453. package/test/worker-channel.test.js +158 -158
  454. package/utils/client-log-file.js +151 -151
  455. package/utils/logger.js +312 -312
  456. package/utils/parse-range.js +25 -25
  457. package/utils/perf.js +59 -59
  458. 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
+ });