prismcast 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (631) hide show
  1. package/dist/app.d.ts +41 -1
  2. package/dist/app.js +164 -76
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/blockedPage.d.ts +0 -1
  5. package/dist/browser/browserSupervisor.d.ts +34 -5
  6. package/dist/browser/browserSupervisor.js +39 -12
  7. package/dist/browser/browserSupervisor.js.map +1 -1
  8. package/dist/browser/cdp.d.ts +58 -6
  9. package/dist/browser/cdp.js +147 -9
  10. package/dist/browser/cdp.js.map +1 -1
  11. package/dist/browser/channelSelection.d.ts +18 -6
  12. package/dist/browser/channelSelection.js +47 -36
  13. package/dist/browser/channelSelection.js.map +1 -1
  14. package/dist/browser/consent.d.ts +33 -4
  15. package/dist/browser/consent.js +87 -50
  16. package/dist/browser/consent.js.map +1 -1
  17. package/dist/browser/display.d.ts +0 -1
  18. package/dist/browser/hlsPlaylistObserver.d.ts +3 -2
  19. package/dist/browser/hlsPlaylistObserver.js +11 -6
  20. package/dist/browser/hlsPlaylistObserver.js.map +1 -1
  21. package/dist/browser/index.d.ts +273 -59
  22. package/dist/browser/index.js +768 -255
  23. package/dist/browser/index.js.map +1 -1
  24. package/dist/browser/launchGovernor.d.ts +0 -1
  25. package/dist/browser/login.d.ts +11 -10
  26. package/dist/browser/login.js +42 -33
  27. package/dist/browser/login.js.map +1 -1
  28. package/dist/browser/manifestInterceptor.d.ts +14 -5
  29. package/dist/browser/manifestInterceptor.js +26 -13
  30. package/dist/browser/manifestInterceptor.js.map +1 -1
  31. package/dist/browser/navigation.d.ts +28 -0
  32. package/dist/browser/navigation.js +57 -0
  33. package/dist/browser/navigation.js.map +1 -0
  34. package/dist/browser/pageStaleness.d.ts +1 -2
  35. package/dist/browser/pageStaleness.js +9 -9
  36. package/dist/browser/pageStaleness.js.map +1 -1
  37. package/dist/browser/precaching.d.ts +27 -13
  38. package/dist/browser/precaching.js +219 -73
  39. package/dist/browser/precaching.js.map +1 -1
  40. package/dist/browser/tabCapture.d.ts +97 -0
  41. package/dist/browser/tabCapture.js +314 -0
  42. package/dist/browser/tabCapture.js.map +1 -0
  43. package/dist/browser/tabNetworkObserver.d.ts +0 -1
  44. package/dist/browser/tabSelection.d.ts +117 -0
  45. package/dist/browser/tabSelection.js +547 -0
  46. package/dist/browser/tabSelection.js.map +1 -0
  47. package/dist/browser/tuning/cache.d.ts +0 -1
  48. package/dist/browser/tuning/comcastPolymer.d.ts +0 -1
  49. package/dist/browser/tuning/comcastPolymer.js +18 -19
  50. package/dist/browser/tuning/comcastPolymer.js.map +1 -1
  51. package/dist/browser/tuning/cox.d.ts +0 -1
  52. package/dist/browser/tuning/directv.d.ts +0 -1
  53. package/dist/browser/tuning/directv.js +12 -13
  54. package/dist/browser/tuning/directv.js.map +1 -1
  55. package/dist/browser/tuning/fox.d.ts +0 -1
  56. package/dist/browser/tuning/fox.js +2 -2
  57. package/dist/browser/tuning/fox.js.map +1 -1
  58. package/dist/browser/tuning/gridSearch.d.ts +0 -1
  59. package/dist/browser/tuning/hbo.d.ts +0 -1
  60. package/dist/browser/tuning/hbo.js +2 -2
  61. package/dist/browser/tuning/hbo.js.map +1 -1
  62. package/dist/browser/tuning/hulu.d.ts +14 -2
  63. package/dist/browser/tuning/hulu.js +150 -24
  64. package/dist/browser/tuning/hulu.js.map +1 -1
  65. package/dist/browser/tuning/shared.d.ts +1 -2
  66. package/dist/browser/tuning/shared.js +1 -1
  67. package/dist/browser/tuning/sling.d.ts +0 -1
  68. package/dist/browser/tuning/sling.js +17 -22
  69. package/dist/browser/tuning/sling.js.map +1 -1
  70. package/dist/browser/tuning/spectrum.d.ts +0 -1
  71. package/dist/browser/tuning/thumbnailRow.d.ts +0 -1
  72. package/dist/browser/tuning/tileClick.d.ts +0 -1
  73. package/dist/browser/tuning/xfinity.d.ts +0 -1
  74. package/dist/browser/tuning/youtubeTv.d.ts +0 -1
  75. package/dist/browser/tuning/youtubeTv.js +1 -2
  76. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  77. package/dist/browser/video.d.ts +55 -33
  78. package/dist/browser/video.js +213 -169
  79. package/dist/browser/video.js.map +1 -1
  80. package/dist/browser/windowSync.d.ts +45 -0
  81. package/dist/browser/windowSync.js +153 -0
  82. package/dist/browser/windowSync.js.map +1 -0
  83. package/dist/channels/index.d.ts +7 -1
  84. package/dist/channels/index.js +24 -8
  85. package/dist/channels/index.js.map +1 -1
  86. package/dist/config/channelForm.d.ts +0 -1
  87. package/dist/config/channelIdentity.d.ts +27 -0
  88. package/dist/config/channelIdentity.js +72 -0
  89. package/dist/config/channelIdentity.js.map +1 -0
  90. package/dist/config/channelSort.d.ts +25 -0
  91. package/dist/config/channelSort.js +101 -0
  92. package/dist/config/channelSort.js.map +1 -0
  93. package/dist/config/consistencyProbe.d.ts +0 -1
  94. package/dist/config/consistencyProbe.js +1 -1
  95. package/dist/config/consistencyProbe.js.map +1 -1
  96. package/dist/config/health.d.ts +3 -2
  97. package/dist/config/health.js +41 -31
  98. package/dist/config/health.js.map +1 -1
  99. package/dist/config/index.d.ts +12 -1
  100. package/dist/config/index.js +77 -31
  101. package/dist/config/index.js.map +1 -1
  102. package/dist/config/paths.d.ts +17 -2
  103. package/dist/config/paths.js +21 -1
  104. package/dist/config/paths.js.map +1 -1
  105. package/dist/config/persistence.context.d.ts +0 -1
  106. package/dist/config/persistence.d.ts +8 -4
  107. package/dist/config/persistence.js +143 -82
  108. package/dist/config/persistence.js.map +1 -1
  109. package/dist/config/presets.d.ts +0 -1
  110. package/dist/config/profiles.d.ts +9 -4
  111. package/dist/config/profiles.js +39 -6
  112. package/dist/config/profiles.js.map +1 -1
  113. package/dist/config/providerLineups.d.ts +0 -1
  114. package/dist/config/providerLineups.js +0 -3
  115. package/dist/config/providerLineups.js.map +1 -1
  116. package/dist/config/reactivity.d.ts +0 -1
  117. package/dist/config/servicePacks.d.ts +0 -1
  118. package/dist/config/services.d.ts +1 -47
  119. package/dist/config/services.js +4 -135
  120. package/dist/config/services.js.map +1 -1
  121. package/dist/config/sites.d.ts +0 -9
  122. package/dist/config/sites.js +82 -98
  123. package/dist/config/sites.js.map +1 -1
  124. package/dist/config/userChannels.d.ts +22 -29
  125. package/dist/config/userChannels.js +47 -65
  126. package/dist/config/userChannels.js.map +1 -1
  127. package/dist/config/userConfig.d.ts +19 -4
  128. package/dist/config/userConfig.js +123 -25
  129. package/dist/config/userConfig.js.map +1 -1
  130. package/dist/config/userProfiles.d.ts +2 -3
  131. package/dist/config/userProfiles.js +16 -13
  132. package/dist/config/userProfiles.js.map +1 -1
  133. package/dist/hdhr/channelMap.d.ts +0 -1
  134. package/dist/hdhr/deviceId.d.ts +0 -1
  135. package/dist/hdhr/discover.d.ts +0 -1
  136. package/dist/hdhr/getHandlers.d.ts +0 -1
  137. package/dist/hdhr/identity.d.ts +0 -1
  138. package/dist/hdhr/index.d.ts +0 -1
  139. package/dist/hdhr/protocol.d.ts +1 -2
  140. package/dist/hdhr/protocol.js +1 -1
  141. package/dist/hdhr/tunerState.d.ts +0 -1
  142. package/dist/hdhr/udp.d.ts +0 -1
  143. package/dist/hdhr/udp.js +16 -4
  144. package/dist/hdhr/udp.js.map +1 -1
  145. package/dist/identity.d.ts +0 -1
  146. package/dist/index.d.ts +0 -1
  147. package/dist/index.js +10 -3
  148. package/dist/index.js.map +1 -1
  149. package/dist/native/codecInference.d.ts +5 -4
  150. package/dist/native/codecInference.js +12 -7
  151. package/dist/native/codecInference.js.map +1 -1
  152. package/dist/native/decrypt.d.ts +10 -3
  153. package/dist/native/decrypt.js +9 -5
  154. package/dist/native/decrypt.js.map +1 -1
  155. package/dist/native/index.d.ts +26 -24
  156. package/dist/native/index.js +26 -20
  157. package/dist/native/index.js.map +1 -1
  158. package/dist/native/probe.d.ts +7 -10
  159. package/dist/native/probe.js +41 -37
  160. package/dist/native/probe.js.map +1 -1
  161. package/dist/native/proxy.d.ts +3 -4
  162. package/dist/native/proxy.js +91 -69
  163. package/dist/native/proxy.js.map +1 -1
  164. package/dist/native/tokenExpiry.d.ts +0 -1
  165. package/dist/routes/assets.d.ts +0 -1
  166. package/dist/routes/auth.d.ts +0 -1
  167. package/dist/routes/auth.js +1 -1
  168. package/dist/routes/auth.js.map +1 -1
  169. package/dist/routes/cdp.d.ts +11 -5
  170. package/dist/routes/cdp.js +91 -63
  171. package/dist/routes/cdp.js.map +1 -1
  172. package/dist/routes/channels.d.ts +0 -1
  173. package/dist/routes/clientActions.d.ts +0 -1
  174. package/dist/routes/components.d.ts +0 -1
  175. package/dist/routes/config/channels/endpoints/browse.d.ts +0 -1
  176. package/dist/routes/config/channels/endpoints/bulk.d.ts +0 -1
  177. package/dist/routes/config/channels/endpoints/bulk.js +3 -3
  178. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
  179. package/dist/routes/config/channels/endpoints/crud.d.ts +0 -1
  180. package/dist/routes/config/channels/endpoints/crud.js +4 -3
  181. package/dist/routes/config/channels/endpoints/crud.js.map +1 -1
  182. package/dist/routes/config/channels/endpoints/importExport.d.ts +0 -1
  183. package/dist/routes/config/channels/endpoints/importExport.js +7 -3
  184. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -1
  185. package/dist/routes/config/channels/endpoints/predefined.d.ts +0 -1
  186. package/dist/routes/config/channels/endpoints/prefs.d.ts +0 -1
  187. package/dist/routes/config/channels/endpoints/prefs.js +1 -1
  188. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -1
  189. package/dist/routes/config/channels/endpoints/service.d.ts +0 -1
  190. package/dist/routes/config/channels/endpoints/service.js +8 -5
  191. package/dist/routes/config/channels/endpoints/service.js.map +1 -1
  192. package/dist/routes/config/channels/endpoints/tags.d.ts +0 -1
  193. package/dist/routes/config/channels/endpoints/tags.js +3 -3
  194. package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
  195. package/dist/routes/config/channels/healthBridge.d.ts +4 -1
  196. package/dist/routes/config/channels/healthBridge.js +9 -5
  197. package/dist/routes/config/channels/healthBridge.js.map +1 -1
  198. package/dist/routes/config/channels/http/handler.d.ts +0 -1
  199. package/dist/routes/config/channels/http/playlistHint.d.ts +0 -1
  200. package/dist/routes/config/channels/http/serviceWarning.d.ts +0 -1
  201. package/dist/routes/config/channels/http/serviceWarning.js +1 -1
  202. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -1
  203. package/dist/routes/config/channels/index.d.ts +0 -1
  204. package/dist/routes/config/channels/setup.d.ts +0 -1
  205. package/dist/routes/config/channels/table.d.ts +0 -1
  206. package/dist/routes/config/channels/table.js +66 -134
  207. package/dist/routes/config/channels/table.js.map +1 -1
  208. package/dist/routes/config/http/envelope.d.ts +1 -2
  209. package/dist/routes/config/index.d.ts +5 -5
  210. package/dist/routes/config/index.js +7 -5
  211. package/dist/routes/config/index.js.map +1 -1
  212. package/dist/routes/config/services.d.ts +0 -1
  213. package/dist/routes/config/services.js +17 -19
  214. package/dist/routes/config/services.js.map +1 -1
  215. package/dist/routes/config/settings.d.ts +6 -15
  216. package/dist/routes/config/settings.js +16 -35
  217. package/dist/routes/config/settings.js.map +1 -1
  218. package/dist/routes/debug.d.ts +0 -1
  219. package/dist/routes/debug.js +1 -1
  220. package/dist/routes/debug.js.map +1 -1
  221. package/dist/routes/health.d.ts +16 -8
  222. package/dist/routes/health.js +27 -12
  223. package/dist/routes/health.js.map +1 -1
  224. package/dist/routes/hls.d.ts +0 -1
  225. package/dist/routes/icons.d.ts +0 -1
  226. package/dist/routes/index.d.ts +0 -1
  227. package/dist/routes/logs.d.ts +3 -2
  228. package/dist/routes/logs.js +54 -14
  229. package/dist/routes/logs.js.map +1 -1
  230. package/dist/routes/mpegts.d.ts +0 -1
  231. package/dist/routes/play.d.ts +0 -1
  232. package/dist/routes/playlist.d.ts +0 -1
  233. package/dist/routes/playlist.js +2 -1
  234. package/dist/routes/playlist.js.map +1 -1
  235. package/dist/routes/root/content.d.ts +0 -1
  236. package/dist/routes/root/content.js +32 -11
  237. package/dist/routes/root/content.js.map +1 -1
  238. package/dist/routes/root/index.d.ts +10 -1
  239. package/dist/routes/root/index.js +17 -7
  240. package/dist/routes/root/index.js.map +1 -1
  241. package/dist/routes/root/scripts/channels.d.ts +0 -1
  242. package/dist/routes/root/scripts/channels.js +10 -20
  243. package/dist/routes/root/scripts/channels.js.map +1 -1
  244. package/dist/routes/root/scripts/clientEscape.d.ts +0 -1
  245. package/dist/routes/root/scripts/clientEscape.js +1 -1
  246. package/dist/routes/root/scripts/clientUrl.d.ts +0 -1
  247. package/dist/routes/root/scripts/config.d.ts +0 -1
  248. package/dist/routes/root/scripts/config.js +8 -29
  249. package/dist/routes/root/scripts/config.js.map +1 -1
  250. package/dist/routes/root/scripts/index.d.ts +0 -1
  251. package/dist/routes/root/scripts/shared.d.ts +0 -1
  252. package/dist/routes/root/scripts/shared.js +16 -13
  253. package/dist/routes/root/scripts/shared.js.map +1 -1
  254. package/dist/routes/root/scripts/status.d.ts +0 -1
  255. package/dist/routes/root/scripts/status.handlers.d.ts +3 -1
  256. package/dist/routes/root/scripts/status.handlers.js +52 -26
  257. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  258. package/dist/routes/root/styles.d.ts +0 -1
  259. package/dist/routes/root/styles.js +10 -18
  260. package/dist/routes/root/styles.js.map +1 -1
  261. package/dist/routes/services.d.ts +0 -1
  262. package/dist/routes/services.js +1 -1
  263. package/dist/routes/services.js.map +1 -1
  264. package/dist/routes/sse.d.ts +3 -2
  265. package/dist/routes/sse.js +7 -3
  266. package/dist/routes/sse.js.map +1 -1
  267. package/dist/routes/streams.d.ts +4 -2
  268. package/dist/routes/streams.js +8 -5
  269. package/dist/routes/streams.js.map +1 -1
  270. package/dist/routes/theme.d.ts +0 -1
  271. package/dist/routes/theme.js +3 -3
  272. package/dist/routes/theme.js.map +1 -1
  273. package/dist/routes/ui.d.ts +0 -1
  274. package/dist/routes/ui.js +5 -5
  275. package/dist/routes/ui.js.map +1 -1
  276. package/dist/routes/upgrade.d.ts +0 -1
  277. package/dist/routes/upgrade.js +2 -1
  278. package/dist/routes/upgrade.js.map +1 -1
  279. package/dist/service/commands.context.d.ts +0 -1
  280. package/dist/service/commands.context.js +7 -1
  281. package/dist/service/commands.context.js.map +1 -1
  282. package/dist/service/commands.d.ts +0 -1
  283. package/dist/service/generators.context.d.ts +0 -1
  284. package/dist/service/generators.d.ts +0 -1
  285. package/dist/service/index.d.ts +0 -1
  286. package/dist/streaming/captureLock.d.ts +1 -2
  287. package/dist/streaming/captureLock.js +8 -7
  288. package/dist/streaming/captureLock.js.map +1 -1
  289. package/dist/streaming/captureSession.d.ts +0 -1
  290. package/dist/streaming/clients.d.ts +4 -3
  291. package/dist/streaming/clients.js +5 -4
  292. package/dist/streaming/clients.js.map +1 -1
  293. package/dist/streaming/codec.d.ts +0 -1
  294. package/dist/streaming/fmp4Segmenter.d.ts +11 -6
  295. package/dist/streaming/fmp4Segmenter.js +39 -17
  296. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  297. package/dist/streaming/hls.d.ts +96 -3
  298. package/dist/streaming/hls.js +282 -171
  299. package/dist/streaming/hls.js.map +1 -1
  300. package/dist/streaming/hlsResume.d.ts +8 -5
  301. package/dist/streaming/hlsResume.js +10 -8
  302. package/dist/streaming/hlsResume.js.map +1 -1
  303. package/dist/streaming/hlsSegments.d.ts +11 -8
  304. package/dist/streaming/hlsSegments.js +38 -18
  305. package/dist/streaming/hlsSegments.js.map +1 -1
  306. package/dist/streaming/lifecycle.d.ts +5 -4
  307. package/dist/streaming/lifecycle.js +43 -13
  308. package/dist/streaming/lifecycle.js.map +1 -1
  309. package/dist/streaming/monitor.d.ts +14 -3
  310. package/dist/streaming/monitor.js +447 -234
  311. package/dist/streaming/monitor.js.map +1 -1
  312. package/dist/streaming/mp4Parser.d.ts +0 -1
  313. package/dist/streaming/mpegts.d.ts +11 -1
  314. package/dist/streaming/mpegts.js +92 -28
  315. package/dist/streaming/mpegts.js.map +1 -1
  316. package/dist/streaming/playlistBuilder.d.ts +0 -1
  317. package/dist/streaming/preroll.d.ts +34 -15
  318. package/dist/streaming/preroll.js +32 -30
  319. package/dist/streaming/preroll.js.map +1 -1
  320. package/dist/streaming/pretune.d.ts +2 -3
  321. package/dist/streaming/pretune.js +45 -45
  322. package/dist/streaming/pretune.js.map +1 -1
  323. package/dist/streaming/pretuneTimers.d.ts +16 -12
  324. package/dist/streaming/pretuneTimers.js +27 -37
  325. package/dist/streaming/pretuneTimers.js.map +1 -1
  326. package/dist/streaming/recovery.d.ts +97 -9
  327. package/dist/streaming/recovery.js +96 -18
  328. package/dist/streaming/recovery.js.map +1 -1
  329. package/dist/streaming/registry.d.ts +102 -15
  330. package/dist/streaming/registry.js +90 -5
  331. package/dist/streaming/registry.js.map +1 -1
  332. package/dist/streaming/setup.d.ts +90 -32
  333. package/dist/streaming/setup.js +379 -248
  334. package/dist/streaming/setup.js.map +1 -1
  335. package/dist/streaming/showInfo.d.ts +8 -4
  336. package/dist/streaming/showInfo.js +54 -45
  337. package/dist/streaming/showInfo.js.map +1 -1
  338. package/dist/streaming/statusEmitter.d.ts +5 -3
  339. package/dist/streaming/statusEmitter.js +20 -8
  340. package/dist/streaming/statusEmitter.js.map +1 -1
  341. package/dist/types/channels.d.ts +0 -1
  342. package/dist/types/channels.js +0 -4
  343. package/dist/types/channels.js.map +1 -1
  344. package/dist/types/config.d.ts +0 -1
  345. package/dist/types/index.d.ts +4 -4
  346. package/dist/types/index.js +2 -1
  347. package/dist/types/index.js.map +1 -1
  348. package/dist/types/profiles.d.ts +51 -9
  349. package/dist/types/profiles.js +35 -1
  350. package/dist/types/profiles.js.map +1 -1
  351. package/dist/types/selection.d.ts +10 -1
  352. package/dist/types/shared.d.ts +0 -1
  353. package/dist/types/streaming.d.ts +8 -1
  354. package/dist/types/streaming.js +7 -0
  355. package/dist/types/streaming.js.map +1 -1
  356. package/dist/upgrade/commands.context.d.ts +0 -1
  357. package/dist/upgrade/commands.d.ts +0 -1
  358. package/dist/upgrade/commands.js +1 -1
  359. package/dist/upgrade/commands.js.map +1 -1
  360. package/dist/upgrade/detection.context.d.ts +0 -1
  361. package/dist/upgrade/detection.d.ts +1 -2
  362. package/dist/upgrade/index.d.ts +0 -1
  363. package/dist/upgrade/lifecycle.context.d.ts +2 -1
  364. package/dist/upgrade/lifecycle.context.js +9 -4
  365. package/dist/upgrade/lifecycle.context.js.map +1 -1
  366. package/dist/upgrade/lifecycle.d.ts +0 -1
  367. package/dist/upgrade/pathHandle.d.ts +0 -1
  368. package/dist/utils/bootSession.context.d.ts +0 -1
  369. package/dist/utils/bootSession.context.js +3 -2
  370. package/dist/utils/bootSession.context.js.map +1 -1
  371. package/dist/utils/bootSession.d.ts +0 -1
  372. package/dist/utils/chromeFetch.d.ts +0 -1
  373. package/dist/utils/cliOutput.d.ts +0 -1
  374. package/dist/utils/debugFilter.d.ts +0 -1
  375. package/dist/utils/debugFilter.js +5 -3
  376. package/dist/utils/debugFilter.js.map +1 -1
  377. package/dist/utils/delay.d.ts +65 -13
  378. package/dist/utils/delay.js +96 -23
  379. package/dist/utils/delay.js.map +1 -1
  380. package/dist/utils/errors.d.ts +0 -11
  381. package/dist/utils/errors.js +3 -14
  382. package/dist/utils/errors.js.map +1 -1
  383. package/dist/utils/evaluate.d.ts +11 -3
  384. package/dist/utils/evaluate.js +4 -3
  385. package/dist/utils/evaluate.js.map +1 -1
  386. package/dist/utils/ffmpeg.context.d.ts +0 -1
  387. package/dist/utils/ffmpeg.d.ts +1 -1
  388. package/dist/utils/ffmpeg.js +2 -1
  389. package/dist/utils/ffmpeg.js.map +1 -1
  390. package/dist/utils/fileLogger.d.ts +26 -7
  391. package/dist/utils/fileLogger.js +241 -102
  392. package/dist/utils/fileLogger.js.map +1 -1
  393. package/dist/utils/format.d.ts +36 -4
  394. package/dist/utils/format.js +66 -12
  395. package/dist/utils/format.js.map +1 -1
  396. package/dist/utils/index.d.ts +1 -2
  397. package/dist/utils/index.js +1 -1
  398. package/dist/utils/index.js.map +1 -1
  399. package/dist/utils/logEmitter.d.ts +0 -1
  400. package/dist/utils/logger.d.ts +0 -1
  401. package/dist/utils/m3u.d.ts +0 -1
  402. package/dist/utils/markup.d.ts +0 -1
  403. package/dist/utils/memo.d.ts +0 -1
  404. package/dist/utils/morganStream.d.ts +2 -3
  405. package/dist/utils/morganStream.js +6 -8
  406. package/dist/utils/morganStream.js.map +1 -1
  407. package/dist/utils/network.d.ts +0 -1
  408. package/dist/utils/never.d.ts +8 -0
  409. package/dist/utils/never.js +20 -0
  410. package/dist/utils/never.js.map +1 -0
  411. package/dist/utils/pid.d.ts +0 -1
  412. package/dist/utils/platform.d.ts +0 -1
  413. package/dist/utils/processInspector.context.d.ts +32 -2
  414. package/dist/utils/processInspector.context.js +117 -1
  415. package/dist/utils/processInspector.context.js.map +1 -1
  416. package/dist/utils/processInspector.d.ts +0 -32
  417. package/dist/utils/processInspector.js +10 -117
  418. package/dist/utils/processInspector.js.map +1 -1
  419. package/dist/utils/retry.d.ts +5 -4
  420. package/dist/utils/retry.js +43 -33
  421. package/dist/utils/retry.js.map +1 -1
  422. package/dist/utils/runtimeIdentity.context.d.ts +0 -1
  423. package/dist/utils/runtimeIdentity.context.js +3 -1
  424. package/dist/utils/runtimeIdentity.context.js.map +1 -1
  425. package/dist/utils/runtimeIdentity.d.ts +1 -1
  426. package/dist/utils/runtimeIdentity.js +1 -1
  427. package/dist/utils/runtimeIdentity.js.map +1 -1
  428. package/dist/utils/sanitize.d.ts +8 -1
  429. package/dist/utils/sanitize.js +19 -0
  430. package/dist/utils/sanitize.js.map +1 -1
  431. package/dist/utils/streamContext.d.ts +0 -1
  432. package/dist/utils/timing.d.ts +3 -4
  433. package/dist/utils/timing.js +4 -4
  434. package/dist/utils/timing.js.map +1 -1
  435. package/dist/utils/version.d.ts +14 -8
  436. package/dist/utils/version.js +35 -24
  437. package/dist/utils/version.js.map +1 -1
  438. package/package.json +7 -7
  439. package/dist/app.d.ts.map +0 -1
  440. package/dist/browser/blockedPage.d.ts.map +0 -1
  441. package/dist/browser/browserSupervisor.d.ts.map +0 -1
  442. package/dist/browser/cdp.d.ts.map +0 -1
  443. package/dist/browser/channelSelection.d.ts.map +0 -1
  444. package/dist/browser/consent.d.ts.map +0 -1
  445. package/dist/browser/display.d.ts.map +0 -1
  446. package/dist/browser/hlsPlaylistObserver.d.ts.map +0 -1
  447. package/dist/browser/index.d.ts.map +0 -1
  448. package/dist/browser/launchGovernor.d.ts.map +0 -1
  449. package/dist/browser/login.d.ts.map +0 -1
  450. package/dist/browser/manifestInterceptor.d.ts.map +0 -1
  451. package/dist/browser/pageStaleness.d.ts.map +0 -1
  452. package/dist/browser/precaching.d.ts.map +0 -1
  453. package/dist/browser/tabNetworkObserver.d.ts.map +0 -1
  454. package/dist/browser/tuning/cache.d.ts.map +0 -1
  455. package/dist/browser/tuning/comcastPolymer.d.ts.map +0 -1
  456. package/dist/browser/tuning/cox.d.ts.map +0 -1
  457. package/dist/browser/tuning/directv.d.ts.map +0 -1
  458. package/dist/browser/tuning/fox.d.ts.map +0 -1
  459. package/dist/browser/tuning/gridSearch.d.ts.map +0 -1
  460. package/dist/browser/tuning/hbo.d.ts.map +0 -1
  461. package/dist/browser/tuning/hulu.d.ts.map +0 -1
  462. package/dist/browser/tuning/shared.d.ts.map +0 -1
  463. package/dist/browser/tuning/sling.d.ts.map +0 -1
  464. package/dist/browser/tuning/spectrum.d.ts.map +0 -1
  465. package/dist/browser/tuning/thumbnailRow.d.ts.map +0 -1
  466. package/dist/browser/tuning/tileClick.d.ts.map +0 -1
  467. package/dist/browser/tuning/xfinity.d.ts.map +0 -1
  468. package/dist/browser/tuning/youtubeTv.d.ts.map +0 -1
  469. package/dist/browser/video.d.ts.map +0 -1
  470. package/dist/channels/index.d.ts.map +0 -1
  471. package/dist/config/channelForm.d.ts.map +0 -1
  472. package/dist/config/consistencyProbe.d.ts.map +0 -1
  473. package/dist/config/health.d.ts.map +0 -1
  474. package/dist/config/index.d.ts.map +0 -1
  475. package/dist/config/paths.d.ts.map +0 -1
  476. package/dist/config/persistence.context.d.ts.map +0 -1
  477. package/dist/config/persistence.d.ts.map +0 -1
  478. package/dist/config/presets.d.ts.map +0 -1
  479. package/dist/config/profiles.d.ts.map +0 -1
  480. package/dist/config/providerLineups.d.ts.map +0 -1
  481. package/dist/config/reactivity.d.ts.map +0 -1
  482. package/dist/config/servicePacks.d.ts.map +0 -1
  483. package/dist/config/services.d.ts.map +0 -1
  484. package/dist/config/sites.d.ts.map +0 -1
  485. package/dist/config/userChannels.d.ts.map +0 -1
  486. package/dist/config/userConfig.d.ts.map +0 -1
  487. package/dist/config/userProfiles.d.ts.map +0 -1
  488. package/dist/hdhr/channelMap.d.ts.map +0 -1
  489. package/dist/hdhr/deviceId.d.ts.map +0 -1
  490. package/dist/hdhr/discover.d.ts.map +0 -1
  491. package/dist/hdhr/getHandlers.d.ts.map +0 -1
  492. package/dist/hdhr/identity.d.ts.map +0 -1
  493. package/dist/hdhr/index.d.ts.map +0 -1
  494. package/dist/hdhr/protocol.d.ts.map +0 -1
  495. package/dist/hdhr/tunerState.d.ts.map +0 -1
  496. package/dist/hdhr/udp.d.ts.map +0 -1
  497. package/dist/identity.d.ts.map +0 -1
  498. package/dist/index.d.ts.map +0 -1
  499. package/dist/native/codecInference.d.ts.map +0 -1
  500. package/dist/native/decrypt.d.ts.map +0 -1
  501. package/dist/native/index.d.ts.map +0 -1
  502. package/dist/native/probe.d.ts.map +0 -1
  503. package/dist/native/proxy.d.ts.map +0 -1
  504. package/dist/native/tokenExpiry.d.ts.map +0 -1
  505. package/dist/routes/assets.d.ts.map +0 -1
  506. package/dist/routes/auth.d.ts.map +0 -1
  507. package/dist/routes/cdp.d.ts.map +0 -1
  508. package/dist/routes/channels.d.ts.map +0 -1
  509. package/dist/routes/clientActions.d.ts.map +0 -1
  510. package/dist/routes/components.d.ts.map +0 -1
  511. package/dist/routes/config/channels/endpoints/browse.d.ts.map +0 -1
  512. package/dist/routes/config/channels/endpoints/bulk.d.ts.map +0 -1
  513. package/dist/routes/config/channels/endpoints/crud.d.ts.map +0 -1
  514. package/dist/routes/config/channels/endpoints/importExport.d.ts.map +0 -1
  515. package/dist/routes/config/channels/endpoints/predefined.d.ts.map +0 -1
  516. package/dist/routes/config/channels/endpoints/prefs.d.ts.map +0 -1
  517. package/dist/routes/config/channels/endpoints/service.d.ts.map +0 -1
  518. package/dist/routes/config/channels/endpoints/tags.d.ts.map +0 -1
  519. package/dist/routes/config/channels/healthBridge.d.ts.map +0 -1
  520. package/dist/routes/config/channels/http/handler.d.ts.map +0 -1
  521. package/dist/routes/config/channels/http/playlistHint.d.ts.map +0 -1
  522. package/dist/routes/config/channels/http/serviceWarning.d.ts.map +0 -1
  523. package/dist/routes/config/channels/index.d.ts.map +0 -1
  524. package/dist/routes/config/channels/setup.d.ts.map +0 -1
  525. package/dist/routes/config/channels/table.d.ts.map +0 -1
  526. package/dist/routes/config/http/envelope.d.ts.map +0 -1
  527. package/dist/routes/config/index.d.ts.map +0 -1
  528. package/dist/routes/config/services.d.ts.map +0 -1
  529. package/dist/routes/config/settings.d.ts.map +0 -1
  530. package/dist/routes/debug.d.ts.map +0 -1
  531. package/dist/routes/health.d.ts.map +0 -1
  532. package/dist/routes/hls.d.ts.map +0 -1
  533. package/dist/routes/icons.d.ts.map +0 -1
  534. package/dist/routes/index.d.ts.map +0 -1
  535. package/dist/routes/logs.d.ts.map +0 -1
  536. package/dist/routes/mpegts.d.ts.map +0 -1
  537. package/dist/routes/play.d.ts.map +0 -1
  538. package/dist/routes/playlist.d.ts.map +0 -1
  539. package/dist/routes/root/content.d.ts.map +0 -1
  540. package/dist/routes/root/index.d.ts.map +0 -1
  541. package/dist/routes/root/scripts/channels.d.ts.map +0 -1
  542. package/dist/routes/root/scripts/clientEscape.d.ts.map +0 -1
  543. package/dist/routes/root/scripts/clientUrl.d.ts.map +0 -1
  544. package/dist/routes/root/scripts/config.d.ts.map +0 -1
  545. package/dist/routes/root/scripts/index.d.ts.map +0 -1
  546. package/dist/routes/root/scripts/shared.d.ts.map +0 -1
  547. package/dist/routes/root/scripts/status.d.ts.map +0 -1
  548. package/dist/routes/root/scripts/status.handlers.d.ts.map +0 -1
  549. package/dist/routes/root/styles.d.ts.map +0 -1
  550. package/dist/routes/services.d.ts.map +0 -1
  551. package/dist/routes/sse.d.ts.map +0 -1
  552. package/dist/routes/streams.d.ts.map +0 -1
  553. package/dist/routes/theme.d.ts.map +0 -1
  554. package/dist/routes/ui.d.ts.map +0 -1
  555. package/dist/routes/upgrade.d.ts.map +0 -1
  556. package/dist/service/commands.context.d.ts.map +0 -1
  557. package/dist/service/commands.d.ts.map +0 -1
  558. package/dist/service/generators.context.d.ts.map +0 -1
  559. package/dist/service/generators.d.ts.map +0 -1
  560. package/dist/service/index.d.ts.map +0 -1
  561. package/dist/streaming/captureLock.d.ts.map +0 -1
  562. package/dist/streaming/captureSession.d.ts.map +0 -1
  563. package/dist/streaming/clients.d.ts.map +0 -1
  564. package/dist/streaming/codec.d.ts.map +0 -1
  565. package/dist/streaming/fmp4Segmenter.d.ts.map +0 -1
  566. package/dist/streaming/hls.d.ts.map +0 -1
  567. package/dist/streaming/hlsResume.d.ts.map +0 -1
  568. package/dist/streaming/hlsSegments.d.ts.map +0 -1
  569. package/dist/streaming/lifecycle.d.ts.map +0 -1
  570. package/dist/streaming/monitor.d.ts.map +0 -1
  571. package/dist/streaming/mp4Parser.d.ts.map +0 -1
  572. package/dist/streaming/mpegts.d.ts.map +0 -1
  573. package/dist/streaming/playlistBuilder.d.ts.map +0 -1
  574. package/dist/streaming/preroll.d.ts.map +0 -1
  575. package/dist/streaming/pretune.d.ts.map +0 -1
  576. package/dist/streaming/pretuneTimers.d.ts.map +0 -1
  577. package/dist/streaming/recovery.d.ts.map +0 -1
  578. package/dist/streaming/registry.d.ts.map +0 -1
  579. package/dist/streaming/setup.d.ts.map +0 -1
  580. package/dist/streaming/showInfo.d.ts.map +0 -1
  581. package/dist/streaming/statusEmitter.d.ts.map +0 -1
  582. package/dist/types/channels.d.ts.map +0 -1
  583. package/dist/types/config.d.ts.map +0 -1
  584. package/dist/types/index.d.ts.map +0 -1
  585. package/dist/types/profiles.d.ts.map +0 -1
  586. package/dist/types/selection.d.ts.map +0 -1
  587. package/dist/types/shared.d.ts.map +0 -1
  588. package/dist/types/streaming.d.ts.map +0 -1
  589. package/dist/upgrade/commands.context.d.ts.map +0 -1
  590. package/dist/upgrade/commands.d.ts.map +0 -1
  591. package/dist/upgrade/detection.context.d.ts.map +0 -1
  592. package/dist/upgrade/detection.d.ts.map +0 -1
  593. package/dist/upgrade/index.d.ts.map +0 -1
  594. package/dist/upgrade/lifecycle.context.d.ts.map +0 -1
  595. package/dist/upgrade/lifecycle.d.ts.map +0 -1
  596. package/dist/upgrade/pathHandle.d.ts.map +0 -1
  597. package/dist/utils/bootSession.context.d.ts.map +0 -1
  598. package/dist/utils/bootSession.d.ts.map +0 -1
  599. package/dist/utils/chromeFetch.d.ts.map +0 -1
  600. package/dist/utils/cliOutput.d.ts.map +0 -1
  601. package/dist/utils/clock.d.ts +0 -15
  602. package/dist/utils/clock.d.ts.map +0 -1
  603. package/dist/utils/clock.js +0 -31
  604. package/dist/utils/clock.js.map +0 -1
  605. package/dist/utils/debugFilter.d.ts.map +0 -1
  606. package/dist/utils/delay.d.ts.map +0 -1
  607. package/dist/utils/errors.d.ts.map +0 -1
  608. package/dist/utils/evaluate.d.ts.map +0 -1
  609. package/dist/utils/ffmpeg.context.d.ts.map +0 -1
  610. package/dist/utils/ffmpeg.d.ts.map +0 -1
  611. package/dist/utils/fileLogger.d.ts.map +0 -1
  612. package/dist/utils/format.d.ts.map +0 -1
  613. package/dist/utils/index.d.ts.map +0 -1
  614. package/dist/utils/logEmitter.d.ts.map +0 -1
  615. package/dist/utils/logger.d.ts.map +0 -1
  616. package/dist/utils/m3u.d.ts.map +0 -1
  617. package/dist/utils/markup.d.ts.map +0 -1
  618. package/dist/utils/memo.d.ts.map +0 -1
  619. package/dist/utils/morganStream.d.ts.map +0 -1
  620. package/dist/utils/network.d.ts.map +0 -1
  621. package/dist/utils/pid.d.ts.map +0 -1
  622. package/dist/utils/platform.d.ts.map +0 -1
  623. package/dist/utils/processInspector.context.d.ts.map +0 -1
  624. package/dist/utils/processInspector.d.ts.map +0 -1
  625. package/dist/utils/retry.d.ts.map +0 -1
  626. package/dist/utils/runtimeIdentity.context.d.ts.map +0 -1
  627. package/dist/utils/runtimeIdentity.d.ts.map +0 -1
  628. package/dist/utils/sanitize.d.ts.map +0 -1
  629. package/dist/utils/streamContext.d.ts.map +0 -1
  630. package/dist/utils/timing.d.ts.map +0 -1
  631. package/dist/utils/version.d.ts.map +0 -1
@@ -1,10 +1,10 @@
1
- import { LOG, formatError, runWithStreamContext, startTimer } from "../utils/index.js";
1
+ import { LOG, formatError, formatResolutionLabel, runWithStreamContext, startTimer } from "../utils/index.js";
2
2
  import { StreamSetupError, createPageWithCapture, generateStreamId, reestablishChannelManifest, setupStream, validateStreamUrl } from "./setup.js";
3
+ import { applyNativeQualityRefresh, cancelPrerollTimer, createHLSState, getAllStreams, getNextStreamId, getStream, getStreamCount, isCaptureIdentity, makePendingCaptureIdentity, registerStream, updateLastAccess } from "./registry.js";
3
4
  import { buildProbeCacheStamp, clearProbeCache } from "../native/probe.js";
4
- import { cancelPrerollTimer, createHLSState, getAllStreams, getNextStreamId, getStream, getStreamCount, registerStream, updateLastAccess } from "./registry.js";
5
5
  import { createInitialStreamStatus, emitStreamAdded } from "./statusEmitter.js";
6
6
  import { deleteResumeData, getResumeSegmentIndex, peekResumeData } from "./hlsResume.js";
7
- import { emitCurrentSystemStatus, isLoginModeActive, unregisterManagedPage } from "../browser/index.js";
7
+ import { emitCurrentSystemStatus, isLoginModeActive, syncWindowVisibility, unregisterManagedPage } from "../browser/index.js";
8
8
  import { generatePrerollPlaylist, getPrerollCodec, getPrerollSegmentCount, isPrerollReady } from "./preroll.js";
9
9
  import { getAllChannels, getChannelLogo, isPredefinedChannelDisabled } from "../config/userChannels.js";
10
10
  import { getAudioPlaylist, getAudioSegment, getInitSegment, getNamedInitSegment, getPlaylist, getSegment, getVideoPlaylist, waitForPlaylist } from "./hlsSegments.js";
@@ -19,6 +19,7 @@ import { createHash } from "node:crypto";
19
19
  import { getProviderBySlug } from "../browser/channelSelection.js";
20
20
  import { registerClient } from "./clients.js";
21
21
  import { suppressPageAudio } from "../browser/video.js";
22
+ import { systemClock } from "homebridge-plugin-utils";
22
23
  import { triggerShowNameUpdate } from "./showInfo.js";
23
24
  /* This module handles HLS (HTTP Live Streaming) output using fMP4 (fragmented MP4) segments. HLS mode uses MP4/AAC capture from puppeteer-stream, which is then
24
25
  * segmented natively without any external dependencies. The stream initialization flow has three phases:
@@ -46,6 +47,9 @@ import { triggerShowNameUpdate } from "./showInfo.js";
46
47
  // content directly. This ensures fast-tuning services (native HLS at 2-3s, most capture services at 4-7s) never see preroll, while slow services (Xfinity/Cox at
47
48
  // 13-15s) get preroll content after the delay to prevent HTTP timeouts.
48
49
  const PREROLL_DELAY_MS = 9000;
50
+ // How long a playlist request waits for a playlist to exist, in milliseconds. It bounds the window between a pending entry's registration and the first playlist
51
+ // from either the preroll timer or the segmenter, so a setup that hangs without ever failing cannot hold a client request open indefinitely.
52
+ const PLAYLIST_WAIT_MS = 30000;
49
53
  /**
50
54
  * Builds the onError/onStop callbacks for createFMP4Segmenter. Both callbacks share the same termination chain (skip-if-already-terminating guard, log error,
51
55
  * terminate stream, emit current system status); the only differences between callsites are the log-message and termination-reason suffixes used to mark the
@@ -137,10 +141,45 @@ export function sendValidationError(validation, res) {
137
141
  res.status(validation.statusCode).send(validation.body);
138
142
  }
139
143
  }
144
+ /**
145
+ * Describes a stream setup failure as the status and message the client is answered with. A StreamSetupError carries its own status and user-facing text, which is
146
+ * the meaning the setup path assigned to the failure; anything else is unexpected and the client learns nothing beyond a 500.
147
+ * @param error - The error a setup path produced.
148
+ * @returns The status and message this failure answers with.
149
+ */
150
+ export function describeSetupFailure(error) {
151
+ if (error instanceof StreamSetupError) {
152
+ return { statusCode: error.statusCode, userMessage: error.userMessage };
153
+ }
154
+ return { statusCode: 500, userMessage: "Internal server error." };
155
+ }
156
+ /**
157
+ * Sends a described setup failure to the client. Every streaming route answers a setup failure through here, so one failure means one response shape no matter
158
+ * which route the client arrived on.
159
+ *
160
+ * A 503 carries both back-off headers on every route: Channels DVR reads Retry-After and waits instead of retrying in a tight loop, and an HDHomeRun client reads
161
+ * X-HDHomeRun-Error to tell a busy tuner apart from a broken one. A response whose headers are already flushed can carry no status at all, so it is ended and the
162
+ * client sees the connection close.
163
+ *
164
+ * Logging stays with the callers: each route describes its own failure in its own words and at its own level.
165
+ * @param failure - The described failure, from describeSetupFailure or from a literal the caller composed.
166
+ * @param res - Express response object.
167
+ */
168
+ export function sendSetupFailure(failure, res) {
169
+ if (res.headersSent) {
170
+ res.end();
171
+ return;
172
+ }
173
+ if (failure.statusCode === 503) {
174
+ res.setHeader("Retry-After", "10");
175
+ res.setHeader("X-HDHomeRun-Error", "All Tuners In Use");
176
+ }
177
+ res.status(failure.statusCode).send(failure.userMessage);
178
+ }
140
179
  // Capacity Reservation.
141
180
  /**
142
181
  * Pure predicate for the concurrent-stream capacity decision. Returns true when a new stream may be admitted given the current active count and the configured
143
- * limit. Extracted as a standalone function so the boundary arithmetic is pinned by a unit test without driving a browser. The count passed in must be the count
182
+ * limit. Extracted as a standalone function so the boundary arithmetic is asserted by a unit test without driving a browser. The count passed in must be the count
144
183
  * BEFORE the new stream's pending entry is registered, so the new stream is naturally excluded from its own capacity check; admitting the final slot
145
184
  * (active === maxConcurrent - 1) returns true.
146
185
  * @param activeCount - Number of streams currently registered, excluding the stream being admitted.
@@ -203,11 +242,10 @@ export async function ensureChannelStream(channelName, req, res) {
203
242
  // Reserve a capacity slot before registering the pending entry. The pending entry occupies a registry slot the instant it is registered, so the capacity
204
243
  // decision must happen here - while the new stream is still excluded from the count - to avoid a later self-counting check rejecting it after the client has
205
244
  // already received a preroll playlist. reserveStreamSlot is the single source of truth for this decision and reclaims an idle stream when at the limit. On
206
- // failure we send a proper 503 here, before any registration or preroll response.
245
+ // failure we answer here, before any registration or preroll response, through the shared responder that writes the back-off headers for every route.
207
246
  if (!reserveStreamSlot()) {
208
- res.setHeader("Retry-After", "10");
209
- res.setHeader("X-HDHomeRun-Error", "All Tuners In Use");
210
- res.status(503).send("Maximum concurrent streams (" + String(CONFIG.streaming.maxConcurrentStreams) + ") reached. Try again later.");
247
+ const refusal = "Maximum concurrent streams (" + String(CONFIG.streaming.maxConcurrentStreams) + ") reached. Try again later.";
248
+ sendSetupFailure({ statusCode: 503, userMessage: refusal }, res);
211
249
  return null;
212
250
  }
213
251
  const clientAddress = req.ip ?? req.socket.remoteAddress ?? null;
@@ -262,6 +300,8 @@ export async function handleHLSPlaylist(req, res) {
262
300
  * @param res - Express response object.
263
301
  */
264
302
  export function handleHLSSegment(req, res) {
303
+ // The serve instant, read once at this request boundary and used for whichever access stamp the branch below writes.
304
+ const now = systemClock.now();
265
305
  const channelName = req.params.name;
266
306
  const segmentName = req.params.segment;
267
307
  if (!channelName || !segmentName) {
@@ -280,7 +320,7 @@ export function handleHLSSegment(req, res) {
280
320
  res.status(404).send("Init segment not found.");
281
321
  return;
282
322
  }
283
- updateLastAccess(streamId);
323
+ updateLastAccess(streamId, now);
284
324
  sendSegment(initSegment, "init.mp4", res);
285
325
  return;
286
326
  }
@@ -292,7 +332,7 @@ export function handleHLSSegment(req, res) {
292
332
  res.status(404).send("Segment not found.");
293
333
  return;
294
334
  }
295
- updateLastAccess(streamId);
335
+ updateLastAccess(streamId, now);
296
336
  sendSegment(segment, segmentName, res);
297
337
  }
298
338
  /**
@@ -305,6 +345,8 @@ export function handleHLSSegment(req, res) {
305
345
  * @param res - Express response object.
306
346
  */
307
347
  export function handleHLSVariantPlaylist(req, res) {
348
+ // The serve instant, read once at this request boundary.
349
+ const now = systemClock.now();
308
350
  const channelName = req.params.name;
309
351
  // Extract the playlist filename from the URL path. The route is registered as two explicit paths (/video.m3u8 and /audio.m3u8) rather than a parameterized route,
310
352
  // so there is no :playlist param to read.
@@ -330,7 +372,7 @@ export function handleHLSVariantPlaylist(req, res) {
330
372
  res.status(404).send("Playlist not found.");
331
373
  return;
332
374
  }
333
- updateLastAccess(streamId);
375
+ updateLastAccess(streamId, now);
334
376
  sendPlaylist(playlist, res);
335
377
  }
336
378
  // Ad-Hoc Streaming.
@@ -393,15 +435,11 @@ export async function handlePlayStream(req, res) {
393
435
  }
394
436
  }
395
437
  catch (error) {
396
- if (error instanceof StreamSetupError) {
397
- if (error.statusCode === 503) {
398
- res.setHeader("Retry-After", "10");
399
- }
400
- res.status(error.statusCode).send(error.userMessage);
401
- return;
438
+ // A StreamSetupError is an expected refusal whose message is already meant for the client, so only an unexpected failure earns an error log here.
439
+ if (!(error instanceof StreamSetupError)) {
440
+ LOG.error("Unexpected error during ad-hoc stream setup: %s.", formatError(error));
402
441
  }
403
- LOG.error("Unexpected error during ad-hoc stream setup: %s.", formatError(error));
404
- res.status(500).send("Internal server error.");
442
+ sendSetupFailure(describeSetupFailure(error), res);
405
443
  return;
406
444
  }
407
445
  res.redirect(302, "/hls/" + channelName + "/stream.m3u8");
@@ -411,7 +449,8 @@ export async function handlePlayStream(req, res) {
411
449
  * Sends the playlist for a stream. With the deferred preroll timer, the playlist may not be available immediately after stream registration - it arrives when either
412
450
  * the timer fires (seeding preroll) or the segmenter produces real content, whichever comes first. This function awaits the playlistReady promise to handle that
413
451
  * window. For the blocking fallback path (no FFmpeg / no preroll), the playlist is guaranteed to exist because initializeStream blocks until the segmenter produces
414
- * it. Returns 404 only if the stream was terminated or the playlist wait timed out.
452
+ * it. A stream whose setup failed answers with that failure's status, so the client learns the tune was refused; a stream terminated for any other reason, or a
453
+ * wait that timed out, answers 404.
415
454
  * @param streamId - The numeric stream ID.
416
455
  * @param clientAddress - Client address for tracking.
417
456
  * @param res - Express response object.
@@ -419,26 +458,40 @@ export async function handlePlayStream(req, res) {
419
458
  async function sendPlaylistResponse(streamId, clientAddress, res) {
420
459
  let playlist = getPlaylist(streamId);
421
460
  // The playlist may not be populated yet if the deferred preroll timer hasn't fired and the segmenter hasn't produced content. Wait for the playlistReady promise
422
- // which resolves when either source provides a playlist. The 30-second timeout covers pathological cases like setup hanging.
461
+ // which resolves when either source provides a playlist. The bounded wait covers pathological cases like setup hanging.
423
462
  if (!playlist) {
424
- const ready = await waitForPlaylist(streamId, 30000);
463
+ /* The reference a failed setup's status is read through, taken before the wait rather than after it. A setup failure records its status on this entry and then
464
+ * leaves the registry in the same synchronous run that wakes this wait, so a lookup on the far side would find nothing where the reference taken here still
465
+ * reaches the entry object.
466
+ */
467
+ const waiting = getStream(streamId);
468
+ const ready = await waitForPlaylist(streamId, PLAYLIST_WAIT_MS);
425
469
  if (ready) {
426
470
  playlist = getPlaylist(streamId);
427
471
  }
472
+ // A refused tune answers with the refusal's own status, which is what tells Channels DVR to back off - a 404 carries no back-off and invites an immediate
473
+ // retry of a stream that is not coming.
474
+ if (!playlist && waiting?.hls.setupFailure) {
475
+ sendSetupFailure(waiting.hls.setupFailure, res);
476
+ return;
477
+ }
428
478
  }
429
479
  if (!playlist) {
430
480
  res.status(404).send("Stream not found.");
431
481
  return;
432
482
  }
433
- updateLastAccess(streamId);
434
- registerClient(streamId, clientAddress, "hls");
483
+ // The serve instant, so the stream's access stamp and the client's last-seen both carry the moment the response is served.
484
+ const now = systemClock.now();
485
+ updateLastAccess(streamId, now);
486
+ registerClient(streamId, clientAddress, "hls", now);
435
487
  // When still serving preroll, regenerate the progressive playlist on each poll so the sliding window advances based on elapsed wall-clock time. This simulates a
436
488
  // live stream - the client sees new segments appear on each poll and keeps playing without stalling. Once real content arrives, hasRealPlaylist becomes true and
437
489
  // the segmenter's playlist takes over.
438
490
  const stream = getStream(streamId);
439
491
  if (stream && !stream.hls.hasRealPlaylist) {
440
- if (stream.hls.prerollBaseUrl && stream.hls.prerollCodec && stream.hls.prerollStartTime) {
441
- playlist = generatePrerollPlaylist(stream.hls.prerollBaseUrl, stream.hls.prerollCodec, stream.hls.resumeSegmentIndex, stream.hls.prerollStartTime);
492
+ if (stream.hls.prerollBaseUrl && stream.hls.prerollCodec && (stream.hls.prerollStartTime !== null)) {
493
+ playlist = generatePrerollPlaylist({ baseUrl: stream.hls.prerollBaseUrl, codec: stream.hls.prerollCodec, now,
494
+ prerollStartTime: stream.hls.prerollStartTime, startingSequence: stream.hls.resumeSegmentIndex });
442
495
  }
443
496
  LOG.debug("streaming:preroll", "Serving preroll playlist for stream %d.", streamId);
444
497
  }
@@ -471,8 +524,6 @@ function sendSegment(data, segmentName, res) {
471
524
  res.send(data);
472
525
  }
473
526
  // Stream Lifecycle.
474
- // Maps vertical resolution heights to standard display labels. Used by formatNativeQuality() to convert "1920x1080" -> "1080p".
475
- const RESOLUTION_LABELS = { "1080": "1080p", "2160": "4K", "360": "360p", "480": "480p", "720": "720p" };
476
527
  /**
477
528
  * Formats the native HLS quality string for the "Streaming..." log line. Combines codec, bandwidth (as Mbps), and resolution (as standard label like "1080p")
478
529
  * into a compact suffix. Returns an empty string when no values are available, or a comma-prefixed string like ", H264 12.1Mbps 1080p" for inclusion in the log.
@@ -490,28 +541,79 @@ function formatNativeQuality(bandwidth, codec, resolution) {
490
541
  parts.push((bandwidth / 1000000).toFixed(1) + "Mbps");
491
542
  }
492
543
  if (resolution) {
493
- // Map the vertical resolution to a standard label. Parse the height from "WIDTHxHEIGHT" format.
494
- const height = resolution.split("x")[1];
495
- parts.push((height ? RESOLUTION_LABELS[height] : undefined) ?? resolution);
544
+ parts.push(formatResolutionLabel(resolution));
496
545
  }
497
546
  if (parts.length === 0) {
498
547
  return "";
499
548
  }
500
549
  return ", " + parts.join(" ");
501
550
  }
551
+ const defaultTabReplacementDeps = { createPageWithCapture, unregisterManagedPage };
502
552
  /**
503
- * Creates a tab replacement handler for recovery from unresponsive browser tabs. When the monitor detects 3+ consecutive evaluate timeouts, it calls this handler to:
504
- * 1. Stop the current segmenter and FFmpeg process
505
- * 2. Close the unresponsive page
506
- * 3. Create a fresh page with new capture
507
- * 4. Create a new segmenter piped to the new capture
508
- * 5. Update the registry with the new resources
509
- * 6. Return the new page and context for the monitor to continue
553
+ * Swaps a freshly established capture pipeline in for the one a stream is running on. This is deliberately a plain, non-async function: everything below runs in
554
+ * a single synchronous frame, so no tick, client request, monitor pass, or termination can interleave between the old pipeline's disposal and the new one's
555
+ * installation. Two segmenters therefore never write one HLS state, the registry never holds two pipelines, and the monitor's post-replacement state reset -
556
+ * which reads the registry's segmenter immediately after this returns - is reading the new pipeline by construction rather than by timing.
557
+ *
558
+ * Keeping it a separate function rather than a block inside the handler is what protects that: an await added here is a syntax error rather than a review catch.
559
+ *
560
+ * The continuity read lives here, at the swap, and not at the top of the replacement. The old segmenter keeps producing throughout the minute or more the
561
+ * replacement page spends navigating and tuning, so its segment index, track timestamps, and session statistics all advance across that window - a read taken
562
+ * before the tune would seed the new segmenter with a stale sequence and rewind the playlist.
563
+ * @param options - The entry, the fresh pipeline, the outgoing page, and the encoder facts carried forward.
564
+ */
565
+ function spliceReplacementCapture(options) {
566
+ const { captureResult, channelName, deps, entry, numericStreamId, oldCaptureCodec, oldHardwareAccelerated, oldPage, streamId } = options;
567
+ // Read the outgoing segmenter's continuity at the last possible instant, then tear its pipeline down. The CaptureSession kills the FFmpeg child first (which
568
+ // silences that pipeline's stream listeners for good), then destroys the capture stream - which MUST happen before the old page closes, so chrome.tabCapture
569
+ // releases and a later acquisition does not fail with "Cannot capture a tab with an active stream" - then stops the segmenter. The session is null on the
570
+ // capture-pending identity a native fallback arrives with, which is why the read is guarded.
571
+ const oldSession = (entry.identity.mode === "capture") ? entry.identity.captureSession : null;
572
+ const continuity = oldSession?.segmenter?.getContinuitySnapshot();
573
+ if (oldSession) {
574
+ LOG.debug("recovery:tab", "Disposing the outgoing capture pipeline at the swap.");
575
+ oldSession.dispose();
576
+ }
577
+ // Release the outgoing page. It has been serving the stream up to this instant, so the close is what ends it.
578
+ if (oldPage) {
579
+ deps.unregisterManagedPage(oldPage);
580
+ if (!oldPage.isClosed()) {
581
+ LOG.debug("recovery:tab", "Closing the outgoing page for stream %s.", streamId);
582
+ oldPage.close().catch((error) => {
583
+ LOG.debug("recovery:tab", "Page close error during tab replacement: %s.", formatError(error));
584
+ });
585
+ }
586
+ }
587
+ // Build the new segmenter on the continuity just read, and mark its first segment with a discontinuity tag so clients flush decoder state. The tag is
588
+ // suppressed automatically when the new init segment turns out byte-identical to the old one.
589
+ const newSegmenter = createFMP4Segmenter({
590
+ ...buildSegmenterTerminationHandlers(numericStreamId, channelName, { logSuffix: "after tab replacement", reasonSuffix: "after recovery" }),
591
+ ...(continuity ? { continuity } : {}),
592
+ pendingDiscontinuity: true,
593
+ streamId: numericStreamId
594
+ });
595
+ // Wire the new segmenter into the new capture session (attachSegmenter pipes the session's capture output into it), then re-point the entry at the new
596
+ // pipeline and page. The identity is a pure literal because this is a within-mode replacement of the whole value, not a refresh of part of one.
597
+ captureResult.captureSession.attachSegmenter(newSegmenter);
598
+ entry.identity = { captureCodec: oldCaptureCodec, captureSession: captureResult.captureSession, hardwareAccelerated: oldHardwareAccelerated, mode: "capture" };
599
+ entry.page = captureResult.page;
600
+ }
601
+ /**
602
+ * Creates a tab replacement handler for recovery from unresponsive browser tabs. The replacement builds before it tears down:
603
+ *
604
+ * 1. Create a fresh page and acquire its capture, and tune it, while the existing pipeline keeps producing segments for the client
605
+ * 2. If anything fails before the swap, discard the fresh page and capture and leave the existing pipeline untouched - a logged recovery miss, not a lost recording
606
+ * 3. Otherwise swap in one synchronous frame: read the old segmenter's continuity, dispose the old pipeline, close the old page, splice the new one in
607
+ * 4. Return the new page and context for the monitor to continue on
608
+ *
609
+ * Because nothing is disposed until the swap, the monitor's single retry re-reads continuity from the still-live old segmenter on its second attempt, and a
610
+ * stream whose replacement keeps failing keeps serving its recording while the circuit breaker counts.
510
611
  *
511
- * The handler preserves existing HLS segments and marks a discontinuity so clients know the stream parameters may have changed.
612
+ * The handler preserves existing HLS segments and marks a discontinuity so clients know the stream parameters may have changed. The cost of the overlap is a
613
+ * second capture and encoder running for the length of the tune, which the CPU and GPU carry.
512
614
  *
513
- * Note: This handler is only invoked for capture-mode streams. Native-mode streams bypass video element monitoring entirely (the monitor early-returns for native
514
- * streams), so evaluate timeouts that trigger tab replacement cannot occur.
615
+ * Note: This handler is only invoked for capture-mode streams, plus the native fallback that has already declared a pending capture identity. Native-mode
616
+ * streams bypass video element monitoring entirely, so evaluate timeouts that trigger tab replacement cannot occur on one.
515
617
  *
516
618
  * @param numericStreamId - The stream's numeric ID for registry lookups.
517
619
  * @param streamId - The stream's string ID for logging.
@@ -520,9 +622,11 @@ function formatNativeQuality(bandwidth, codec, resolution) {
520
622
  * @param profile - The site profile for video handling.
521
623
  * @param metadataComment - Optional comment to embed in FFmpeg output metadata.
522
624
  * @param onCircuitBreak - Callback for circuit breaker trips during replacement.
625
+ * @param deps - The injected establishment collaborators; defaults to defaultTabReplacementDeps. Threaded so a test drives the real handler - its phase tracking,
626
+ * its discard path, and the swap itself - against a substituted establishment rather than a live Chrome.
523
627
  * @returns A handler function that performs tab replacement, or null if the stream no longer exists.
524
628
  */
525
- function createTabReplacementHandler(numericStreamId, streamId, channelName, url, profile, metadataComment, onCircuitBreak) {
629
+ export function createTabReplacementHandler(numericStreamId, streamId, channelName, url, profile, metadataComment, onCircuitBreak, deps = defaultTabReplacementDeps) {
526
630
  return async () => {
527
631
  const tabElapsed = startTimer();
528
632
  // Get the current stream entry.
@@ -531,41 +635,32 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
531
635
  LOG.debug("recovery:tab", "Tab replacement requested but stream %s no longer exists.", streamId);
532
636
  return null;
533
637
  }
534
- // Get the current init segment, segment index, and per-track timestamps from the old segmenter before stopping it. The init segment enables discontinuity
535
- // suppression when codec parameters are unchanged, the segment index allows the new segmenter to continue numbering, and the track timestamps ensure monotonic
536
- // baseMediaDecodeTime across capture restarts.
537
- const oldSegmenter = stream.captureSession?.segmenter;
538
- const currentInitSegment = oldSegmenter?.getInitSegment();
539
- const currentInitVersion = oldSegmenter?.getInitVersion() ?? 0;
540
- const currentSegmentIndex = oldSegmenter?.getSegmentIndex() ?? 0;
541
- const currentSessionStats = oldSegmenter?.getSessionStats();
542
- const currentTrackTimestamps = oldSegmenter?.getTrackTimestamps();
543
- // Dispose the OLD capture pipeline. The CaptureSession kills the FFmpeg child first (setting its shuttingDown flag before the capture stream's EOF can reach
544
- // FFmpeg's stdin), then destroys the capture stream (which MUST happen before the old page is closed below, so chrome.tabCapture releases the capture and the
545
- // new getStream() does not hang with "Cannot capture a tab with an active stream"), then stops the segmenter. The new pipeline is constructed fresh further down.
546
- if (stream.captureSession) {
547
- LOG.debug("recovery:tab", "Disposing old capture pipeline for tab replacement.");
548
- stream.captureSession.dispose();
549
- }
550
- // Close the current page. The page may be null for pending entries whose async setup has not yet completed.
551
- const oldPage = stream.page;
552
- if (oldPage) {
553
- unregisterManagedPage(oldPage);
554
- if (!oldPage.isClosed()) {
555
- LOG.debug("recovery:tab", "Closing unresponsive page for tab replacement.");
556
- oldPage.close().catch((error) => {
557
- LOG.debug("recovery:tab", "Page close error during tab replacement: %s.", formatError(error));
558
- });
559
- }
560
- }
561
- LOG.debug("timing:tab", "Old tab cleanup complete. (+%sms)", tabElapsed());
562
- // Create a new page with capture.
638
+ /* The encoder facts the replacement carries forward, read now off the identity that is still serving the stream. A replacement changes the page, not the
639
+ * codec decision, so this is the only source for them: the fresh capture result carries no codec or acceleration of its own.
640
+ */
641
+ const oldCaptureCodec = isCaptureIdentity(stream) ? stream.identity.captureCodec : null;
642
+ const oldHardwareAccelerated = isCaptureIdentity(stream) && stream.identity.hardwareAccelerated;
643
+ /* Where this attempt stands, from the perspective of the fresh pipeline's own FFmpeg error callback. One tri-state rather than a pair of flags, so "failed
644
+ * and committed at once" is not something the code can express. It is held on a small record because the callback and the frame awaiting establishment both
645
+ * have to see the same value across an await, which a captured local does not give the reader or the compiler.
646
+ *
647
+ * The phase is local to a single attempt, moves at most once, and is never reset: the monitor's retry runs this function again and gets a fresh one. While
648
+ * the attempt is pending, an FFmpeg fault belongs to a pipeline that is not the stream yet, so it must not reach the circuit breaker - the stream it would
649
+ * terminate is the one still running happily on the old page. Once the swap has committed, the new pipeline IS the stream and a fault is treated exactly as
650
+ * it always has been.
651
+ */
652
+ const attempt = { phase: "pending" };
653
+ // Create the replacement page and acquire its capture, with the existing pipeline still producing throughout.
563
654
  let captureResult;
564
655
  try {
565
- captureResult = await createPageWithCapture({
656
+ captureResult = await deps.createPageWithCapture({
566
657
  comment: metadataComment,
567
- numericStreamId,
568
658
  onFFmpegError: (error) => {
659
+ if (attempt.phase !== "committed") {
660
+ LOG.warn("The replacement capture's FFmpeg failed before it took over: %s. The existing capture continues.", formatError(error));
661
+ attempt.phase = "failed";
662
+ return;
663
+ }
569
664
  LOG.error("FFmpeg error during tab replacement recovery: %s.", formatError(error));
570
665
  onCircuitBreak();
571
666
  },
@@ -576,42 +671,32 @@ function createTabReplacementHandler(numericStreamId, streamId, channelName, url
576
671
  });
577
672
  }
578
673
  catch (error) {
579
- LOG.warn("Failed to create new page during tab replacement: %s.", formatError(error));
674
+ LOG.warn("Could not establish a replacement capture for this stream: %s. The existing capture continues.", formatError(error));
580
675
  return null;
581
676
  }
582
677
  LOG.debug("timing:tab", "New page with capture created. (+%sms)", tabElapsed());
583
- // Re-check the stream before adopting the fresh resources. The awaited createPageWithCapture above yields the event loop, so the stream can terminate mid-await;
584
- // the entry writes below would then attach the new page and capture session to an entry that termination already disposed, orphaning both. Tear the fresh resources
585
- // down in the same order the old-pipeline teardown above uses - dispose the capture session first so chrome.tabCapture releases cleanly, then unregister the
586
- // managed page, then close it - and abandon the replacement.
587
- if (!getStream(numericStreamId)) {
678
+ /* Re-check before committing. The awaited establishment above yields the event loop for the length of a tune, so two things can have happened meanwhile: the
679
+ * stream can have terminated - in which case termination found exactly one pipeline in the registry, the old one, and disposed it - or the fresh pipeline's
680
+ * own FFmpeg can have failed, which the swap-aware callback recorded. Either way the fresh page and capture are discarded in the teardown order every other
681
+ * site uses (dispose the session first so chrome.tabCapture releases cleanly, then unregister the managed page, then close it) and the stream is left exactly
682
+ * as it was. A discarded session's FFmpeg is killed by that dispose, which is what keeps the discard silent.
683
+ */
684
+ const entry = getStream(numericStreamId);
685
+ if (!entry || (attempt.phase === "failed")) {
588
686
  captureResult.captureSession.dispose();
589
- unregisterManagedPage(captureResult.page);
687
+ deps.unregisterManagedPage(captureResult.page);
590
688
  if (!captureResult.page.isClosed()) {
591
689
  captureResult.page.close().catch((error) => {
592
690
  LOG.debug("recovery:tab", "Page close error during aborted tab replacement: %s.", formatError(error));
593
691
  });
594
692
  }
595
- LOG.debug("recovery:tab", "Stream %s terminated during tab replacement - discarded the fresh page and capture session.", streamId);
693
+ LOG.debug("recovery:tab", "Abandoned the tab replacement for stream %s - discarded the fresh page and capture session.", streamId);
596
694
  return null;
597
695
  }
598
- // Create a new segmenter for the new capture stream. Continue from the current segment index for playlist continuity, pass the per-track timestamp counters
599
- // for monotonic baseMediaDecodeTime, and mark the first segment with a discontinuity tag so clients know the stream parameters may have changed.
600
- const newSegmenter = createFMP4Segmenter({
601
- initialTrackTimestamps: currentTrackTimestamps,
602
- ...buildSegmenterTerminationHandlers(numericStreamId, channelName, { logSuffix: "after tab replacement", reasonSuffix: "after recovery" }),
603
- pendingDiscontinuity: true,
604
- previousInitSegment: currentInitSegment,
605
- priorSessionStats: currentSessionStats,
606
- startingInitVersion: currentInitVersion,
607
- startingSegmentIndex: currentSegmentIndex,
608
- streamId: numericStreamId
609
- });
610
- // Wire the new segmenter into the new capture session (attachSegmenter pipes the session's capture output into it), then install the session and page on the
611
- // registry entry.
612
- captureResult.captureSession.attachSegmenter(newSegmenter);
613
- stream.captureSession = captureResult.captureSession;
614
- stream.page = captureResult.page;
696
+ // The swap, and the commit that follows it in the same synchronous frame. Nothing separates the two, so the error callback can never observe a stream whose
697
+ // registry entry has moved on while the attempt still reads as pending.
698
+ spliceReplacementCapture({ captureResult, channelName, deps, entry, numericStreamId, oldCaptureCodec, oldHardwareAccelerated, oldPage: entry.page, streamId });
699
+ attempt.phase = "committed";
615
700
  LOG.info("Tab replacement complete. New capture started with segment continuity.");
616
701
  LOG.debug("timing:tab", "Tab replacement complete. Total: %sms.", tabElapsed());
617
702
  return {
@@ -667,6 +752,8 @@ export async function initializeStream(options) {
667
752
  * @returns The allocated stream IDs.
668
753
  */
669
754
  function registerPendingStream(channelName, channel, clientAddress, req, codec) {
755
+ // The registration instant, read once at this composition point and used for the resume-index read, the entry's start time, and the preroll timer's arming.
756
+ const now = systemClock.now();
670
757
  const numericStreamId = getNextStreamId();
671
758
  const streamIdStr = generateStreamId(channelName, channel.url);
672
759
  // Derive the base URL from the request for absolute preroll segment URLs.
@@ -675,16 +762,16 @@ function registerPendingStream(channelName, channel, clientAddress, req, codec)
675
762
  const host = forwardedHost ? (forwardedHost.split(",")[0] ?? "").trim() : req.get("host");
676
763
  const fallbackHost = CONFIG.server.host + ":" + String(CONFIG.server.port);
677
764
  const baseUrl = protocol + "://" + (host ?? fallbackHost);
678
- // Create HLS state with a deferred preroll timer. The timer fires after PREROLL_DELAY_MS - if stream setup hasn't completed by then, preroll is seeded and the
679
- // playlist response is unblocked. For resume streams, the preroll's MEDIA-SEQUENCE is offset by the saved segment index so it continues from the prior session's
680
- // sequence range. The timer is disarmed through cancelPrerollTimer() once real content arrives or preroll state is otherwise invalidated, so it cannot fire against
681
- // state that has moved on.
765
+ // Create HLS state with a deferred preroll timer - a one-shot on the system clock whose handle the single disarm point disposes. The timer fires after
766
+ // PREROLL_DELAY_MS - if stream setup hasn't completed by then, preroll is seeded and the playlist response is unblocked. For resume streams, the preroll's
767
+ // MEDIA-SEQUENCE is offset by the saved segment index so it continues from the prior session's sequence range. The timer is disarmed through
768
+ // cancelPrerollTimer() once real content arrives or preroll state is otherwise invalidated, so it cannot fire against state that has moved on.
682
769
  const hls = createHLSState();
683
770
  // Snapshot the resume segment index once at registration. Both the preroll timer callback and completeStreamSetup() use this single snapshot, eliminating the TTL
684
771
  // race that would occur if each read the resume map independently at different times.
685
- hls.resumeSegmentIndex = getResumeSegmentIndex(channelName) ?? 0;
772
+ hls.resumeSegmentIndex = getResumeSegmentIndex(channelName, now) ?? 0;
686
773
  // Capture the stream start time at registration. This timestamp is used for the registry's startTime field (stream age display, etc.).
687
- const streamStartTime = new Date();
774
+ const streamStartTime = now;
688
775
  if (isPrerollReady(codec)) {
689
776
  // Set preroll metadata immediately at registration so the segmenter and native proxy can read it regardless of whether the deferred timer has fired. The segment
690
777
  // count, codec, and base URL are structural properties of the preroll system - they determine segment index offsets, codec-aware URL paths, and composite playlist
@@ -693,11 +780,12 @@ function registerPendingStream(channelName, channel, clientAddress, req, codec)
693
780
  hls.prerollBaseUrl = baseUrl;
694
781
  hls.prerollCodec = codec;
695
782
  hls.prerollSegmentCount = getPrerollSegmentCount(codec);
696
- hls.prerollTimer = setTimeout(() => {
783
+ hls.prerollTimer = systemClock.schedule(() => {
697
784
  // Record the preroll start time and seed the initial progressive playlist. On subsequent polls, sendPlaylistResponse() regenerates the playlist with an
698
- // advancing window based on elapsed time from this start time, simulating a live stream.
699
- hls.prerollStartTime = new Date();
700
- hls.playlist = generatePrerollPlaylist(baseUrl, codec, hls.resumeSegmentIndex, hls.prerollStartTime);
785
+ // advancing window based on elapsed time from this start time, simulating a live stream. The reveal at the fire instant is the initial window.
786
+ const firedAt = systemClock.now();
787
+ hls.prerollStartTime = firedAt;
788
+ hls.playlist = generatePrerollPlaylist({ baseUrl, codec, now: firedAt, prerollStartTime: firedAt, startingSequence: hls.resumeSegmentIndex });
701
789
  hls.signalPlaylistReady();
702
790
  }, PREROLL_DELAY_MS);
703
791
  }
@@ -713,46 +801,51 @@ function registerPendingStream(channelName, channel, clientAddress, req, codec)
713
801
  */
714
802
  function createPendingEntry(options) {
715
803
  const { channel, channelName, hls, numericStreamId, streamIdStr, url } = options;
804
+ // The registration instant, read once so the access stamp and the entry's start time agree when the caller supplied no start of its own.
805
+ const now = systemClock.now();
716
806
  registerStream({
717
- captureCodec: null,
718
- captureSession: null,
719
807
  channelName: channel?.name ?? null,
720
808
  clientAddress: options.clientAddress ?? null,
721
- hardwareAccelerated: false,
722
809
  hls,
723
810
  id: numericStreamId,
811
+ identity: makePendingCaptureIdentity(),
724
812
  info: {
725
- lastPlaylistRequest: Date.now(),
813
+ lastPlaylistRequest: now,
726
814
  storeKey: channelName
727
815
  },
728
816
  monitor: null,
729
817
  mpegTsClientCount: 0,
730
- nativeBandwidth: 0,
731
- nativeContainer: null,
732
- nativeProxy: null,
733
- nativeResolution: null,
734
818
  page: null,
735
819
  preTuned: options.preTuned ?? false,
736
820
  probeIdentity: null,
737
821
  profile: null,
738
- reestablishManifest: null,
739
- startTime: options.streamStartTime ?? new Date(),
822
+ startTime: options.streamStartTime ?? now,
740
823
  streamIdStr,
741
- streamingMode: "capture",
742
824
  url
743
825
  });
744
826
  setChannelStreamId(channelName, numericStreamId);
745
827
  }
746
828
  /**
747
- * Handles stream setup failure by marking channel health, terminating the pending entry, and optionally logging the error. The blocking path (initializeStream) skips
748
- * logging because it re-throws for callers to handle. The non-blocking path (ensureChannelStream .catch) logs because there is no outer handler.
829
+ * Handles stream setup failure by recording the failure's status on the pending entry, marking channel health, terminating the entry, and optionally logging the
830
+ * error. The blocking path (initializeStream) skips logging because it re-throws for callers to handle. The non-blocking path (ensureChannelStream .catch) logs
831
+ * because there is no outer handler.
832
+ *
833
+ * Exported for unit-test coverage of the ordering the recorded status depends on. Production callers reach this only through the setup paths above, never directly.
749
834
  * @param numericStreamId - The pending entry's numeric stream ID.
750
835
  * @param channelName - The channel key for health tracking and termination.
751
836
  * @param channel - The channel definition, or undefined for ad-hoc streams (no health tracking).
752
837
  * @param error - The error that caused the failure.
753
838
  * @param logError - Whether to log the error. False when the caller will re-throw (blocking path), true when fire-and-forget (non-blocking path).
754
839
  */
755
- function handleSetupFailure(numericStreamId, channelName, channel, error, logError = true) {
840
+ export function handleSetupFailure(numericStreamId, channelName, channel, error, logError = true) {
841
+ /* Record what this failure answers with before anything tears the entry down. terminateStream unregisters the entry in the same synchronous run in which it
842
+ * wakes every request waiting on this stream, so a request that resumes afterward finds nothing in the registry to read - the status has to be on the entry
843
+ * object by then. A waiting request holds its own reference to that object, taken before its wait, and reads the status through it.
844
+ */
845
+ const entry = getStream(numericStreamId);
846
+ if (entry) {
847
+ entry.hls.setupFailure = describeSetupFailure(error);
848
+ }
756
849
  // Mark channel health as failed. Only for predefined channels (channel is defined). Ad-hoc URL streams have no persistent channel identity.
757
850
  if (channel) {
758
851
  const failVariantKey = resolveServiceKey(channelName);
@@ -821,9 +914,7 @@ async function startNativeProxy(setup, numericStreamId, channelName, url, mpegTs
821
914
  if (!refreshed) {
822
915
  return;
823
916
  }
824
- refreshed.captureCodec = metadata.codec;
825
- refreshed.nativeBandwidth = metadata.bandwidth;
826
- refreshed.nativeResolution = metadata.resolution;
917
+ applyNativeQualityRefresh(refreshed, metadata);
827
918
  },
828
919
  page: setup.page,
829
920
  prerollCodec: pendingForNative?.hls.prerollCodec ?? "h264",
@@ -857,7 +948,6 @@ async function startNativeProxy(setup, numericStreamId, channelName, url, mpegTs
857
948
  // registration, so nulling the fields silences only sendPlaylistResponse's per-poll regeneration, not the one-shot overwrite the callback would still perform when
858
949
  // it fires. Disarming here completes the invalidation. One residual is inherent to preroll-for-hasAudio: if the native tune already exceeded PREROLL_DELAY_MS the
859
950
  // timer has already fired, so the nulled fields freeze the already-written preroll playlist until the proxy's first real content arrives - bounded, not a stall.
860
- currentStream.captureSession = null;
861
951
  currentStream.hls.hasAudio = nativeResult.hasAudio;
862
952
  if (nativeResult.hasAudio) {
863
953
  cancelPrerollTimer(currentStream.hls);
@@ -865,14 +955,19 @@ async function startNativeProxy(setup, numericStreamId, channelName, url, mpegTs
865
955
  currentStream.hls.prerollCodec = null;
866
956
  currentStream.hls.prerollSegmentCount = 0;
867
957
  }
868
- currentStream.captureCodec = nativeResult.codec;
869
- currentStream.hardwareAccelerated = false;
870
- currentStream.nativeBandwidth = nativeResult.bandwidth;
871
- currentStream.nativeContainer = nativeResult.container;
872
- currentStream.nativeProxy = nativeResult.proxy;
873
- currentStream.nativeResolution = nativeResult.resolution;
874
- currentStream.reestablishManifest = reestablishManifest;
875
- currentStream.streamingMode = "native";
958
+ // The mode flip is one whole-identity replacement written as a pure literal, so the capture pipeline this stream no longer has cannot survive the transition.
959
+ currentStream.identity = {
960
+ captureCodec: nativeResult.codec,
961
+ mode: "native",
962
+ nativeBandwidth: nativeResult.bandwidth,
963
+ nativeContainer: nativeResult.container,
964
+ nativeProxy: nativeResult.proxy,
965
+ nativeResolution: nativeResult.resolution,
966
+ reestablishManifest
967
+ };
968
+ // The capture that was reading the compositor has ended, so this stream has no further claim on the window. Fire-and-forget: a native stream does not depend on
969
+ // the window at all, and the executor is serialized, so nothing here waits on a presentation change.
970
+ void syncWindowVisibility();
876
971
  // Start the native proxy. Init readiness is conditional on the container. An MPEG-TS source carries its own PAT/PMT codec configuration in every segment and
877
972
  // has no separate init to wait for, so readiness is signaled here and MPEG-TS clients are released immediately rather than blocking on waitForInitSegment()
878
973
  // until it times out. An fMP4 source does have an init, and the store signals readiness when the video track's first one lands - video because the consumer
@@ -888,6 +983,30 @@ async function startNativeProxy(setup, numericStreamId, channelName, url, mpegTs
888
983
  LOG.debug("native:coordinator", "Capture pipeline stopped for %s. Native proxy active.", channelName);
889
984
  return { codec: nativeResult.codec, quality: formatNativeQuality(nativeResult.bandwidth, nativeResult.codec, nativeResult.resolution) };
890
985
  }
986
+ /**
987
+ * Derives what a segmenter continues from, which on a fresh stream is nothing at all. A resume carries the prior session's timestamps and init version, and its
988
+ * init segment only when no preroll window precedes it - the preroll init differs from the real one, so a byte match there would suppress a discontinuity the
989
+ * boundary genuinely needs. Session statistics are deliberately absent: a resume from disk has none, and inventing an empty set would tell the segmenter a live
990
+ * prior session is continuing and count a tab replacement that never happened. The starting index accounts for both the resume offset and the preroll segment
991
+ * range whenever either contributes one.
992
+ *
993
+ * Each member is present only when this resume genuinely carries it, because the segmenter reads presence rather than value: an absent member and a member set
994
+ * to an empty or zero stand-in mean different things to it.
995
+ *
996
+ * @param options - The resume record and the preroll accounting to derive from.
997
+ * @returns The continuity the segmenter is constructed with.
998
+ */
999
+ export function buildResumeContinuity(options) {
1000
+ const { baseSegmentIndex, prerollSegmentCount, resumeData } = options;
1001
+ return {
1002
+ ...(resumeData ? {
1003
+ initialTrackTimestamps: resumeData.trackTimestamps,
1004
+ ...((prerollSegmentCount === 0) ? { previousInitSegment: resumeData.initSegment } : {}),
1005
+ startingInitVersion: resumeData.initVersion
1006
+ } : {}),
1007
+ ...((resumeData || (prerollSegmentCount > 0)) ? { startingSegmentIndex: baseSegmentIndex + prerollSegmentCount } : {})
1008
+ };
1009
+ }
891
1010
  /**
892
1011
  * Creates the fMP4 segmenter for capture mode streams. Reads resume data, creates the segmenter with preroll and resume configuration, and attaches it to the
893
1012
  * capture session (which pipes the session's capture output into it). Resume data is consumed only after the segmenter is successfully attached to a non-disposed
@@ -895,37 +1014,30 @@ async function startNativeProxy(setup, numericStreamId, channelName, url, mpegTs
895
1014
  * @param setup - The stream setup result from setupStream().
896
1015
  * @param numericStreamId - The stream's numeric ID.
897
1016
  * @param channelName - The channel key for resume data and logging.
1017
+ * @param now - The instant the resume data's TTL is measured against.
898
1018
  * @returns True if the segmenter was attached, false if the stream was terminated during setup (the session was already disposed).
899
1019
  */
900
- function createCaptureSegmenter(setup, numericStreamId, channelName) {
1020
+ function createCaptureSegmenter(setup, numericStreamId, channelName, now) {
901
1021
  // Peek at resume data from a previous shutdown without consuming it. The data is consumed (deleted) only after the segmenter is successfully created and
902
1022
  // stored in the registry. This ensures resume data survives if segmenter creation fails - the next stream start retries with the same resume state instead
903
1023
  // of losing it and causing an HLS sequence reset.
904
- const resumeData = peekResumeData(channelName);
1024
+ const resumeData = peekResumeData(channelName, now);
905
1025
  const currentStream = getStream(numericStreamId);
906
1026
  const prerollSegmentCount = currentStream?.hls.prerollSegmentCount ?? 0;
907
1027
  // When preroll is active, use the snapshotted resume index (stored on HLS state at registration) so the segmenter's starting index is guaranteed to match
908
1028
  // the preroll playlist's MEDIA-SEQUENCE offset. When preroll is inactive, use the resume data directly - no preroll playlist to be consistent with.
909
1029
  const baseSegmentIndex = (prerollSegmentCount > 0) ? (currentStream?.hls.resumeSegmentIndex ?? 0) : (resumeData?.segmentIndex ?? 0);
910
- // Create the fMP4 segmenter. The starting segment index accounts for both the resume offset and the preroll segment range. When preroll is active, the
911
- // segmenter includes preroll entries in its sliding window via the compositor. The pending discontinuity at the preroll-to-real boundary is always needed -
912
- // previousInitSegment is only passed without preroll, because the preroll init segment differs from the real init and the discontinuity must not be
913
- // suppressed by an init-match comparison against the prior session.
1030
+ const continuity = buildResumeContinuity({ baseSegmentIndex, prerollSegmentCount, resumeData });
1031
+ // Create the fMP4 segmenter. When preroll is active, it includes preroll entries in its sliding window via the compositor, and the pending discontinuity at the
1032
+ // preroll-to-real boundary is always needed.
914
1033
  const segmenter = createFMP4Segmenter({
915
- ...(resumeData ? {
916
- initialTrackTimestamps: resumeData.trackTimestamps,
917
- ...((prerollSegmentCount === 0) ? { previousInitSegment: resumeData.initSegment } : {}),
918
- startingInitVersion: resumeData.initVersion
919
- } : {}),
1034
+ continuity,
920
1035
  ...((prerollSegmentCount > 0) ? {
921
1036
  prerollBaseUrl: currentStream?.hls.prerollBaseUrl ?? null,
922
1037
  prerollCodec: currentStream?.hls.prerollCodec ?? "h264",
923
1038
  prerollSegmentCount
924
1039
  } : {}),
925
- ...((resumeData || (prerollSegmentCount > 0)) ? {
926
- pendingDiscontinuity: true,
927
- startingSegmentIndex: baseSegmentIndex + prerollSegmentCount
928
- } : {}),
1040
+ ...((resumeData || (prerollSegmentCount > 0)) ? { pendingDiscontinuity: true } : {}),
929
1041
  ...buildSegmenterTerminationHandlers(numericStreamId, channelName),
930
1042
  streamId: numericStreamId
931
1043
  });
@@ -964,7 +1076,7 @@ async function completeStreamSetup(options) {
964
1076
  * stamp assembled from a frame holding only part of the binding would silently describe a different stream than the one the tune reaches.
965
1077
  *
966
1078
  * The effective profile is the query override when one was supplied, otherwise the stored profile: an overridden tune reaches a different stream than the
967
- * stored profile would, so the stamp honestly differs and an entry cached under the stored profile re-probes once under the override rather than answering
1079
+ * stored profile would, so the stamp differs and an entry cached under the stored profile re-probes once under the override rather than answering
968
1080
  * for a page it never described.
969
1081
  */
970
1082
  const probeIdentity = {
@@ -1010,6 +1122,8 @@ async function completeStreamSetup(options) {
1010
1122
  streamId: streamIdStr,
1011
1123
  url
1012
1124
  }, onCircuitBreak);
1125
+ // The tune-complete instant, read once here so the resume data's TTL check and the tune-time line below measure against the same moment.
1126
+ const now = systemClock.now();
1013
1127
  // Fill in the pending registry entry with the real browser state. The entry was registered in Phase 1 (registerPendingStream or initializeStream).
1014
1128
  const stream = getStream(numericStreamId);
1015
1129
  if (!stream) {
@@ -1019,7 +1133,9 @@ async function completeStreamSetup(options) {
1019
1133
  });
1020
1134
  return null;
1021
1135
  }
1022
- stream.captureSession = setup.captureSession;
1136
+ // Setup produced the capture pipeline the entry was registered without. The identity is still the pending capture shape at this point - the native upgrade runs
1137
+ // later in this same function - so the write installs the pipeline and leaves the encoder facts to the completion write further down.
1138
+ stream.identity = { ...makePendingCaptureIdentity(), captureSession: setup.captureSession };
1023
1139
  stream.monitor = setup.monitor;
1024
1140
  stream.page = setup.page;
1025
1141
  stream.probeIdentity = setup.probeIdentity;
@@ -1045,7 +1161,7 @@ async function completeStreamSetup(options) {
1045
1161
  }
1046
1162
  // If native streaming was not viable or not attempted, create the fMP4 segmenter for capture mode.
1047
1163
  if (streamingMode === "capture") {
1048
- if (!createCaptureSegmenter(setup, numericStreamId, channelName)) {
1164
+ if (!createCaptureSegmenter(setup, numericStreamId, channelName, now)) {
1049
1165
  return null;
1050
1166
  }
1051
1167
  }
@@ -1057,7 +1173,7 @@ async function completeStreamSetup(options) {
1057
1173
  const captureMode = (streamingMode === "native") ? ("native HLS" + nativeQuality) :
1058
1174
  (CONFIG.streaming.captureMode === "ffmpeg" ? "FFmpeg [" + ffmpegCodec + "]" : "Native fMP4");
1059
1175
  const displayName = channel?.name ?? url;
1060
- const tuneTime = ((Date.now() - setup.startTime.getTime()) / 1000).toFixed(1);
1176
+ const tuneTime = ((now - setup.startTime) / 1000).toFixed(1);
1061
1177
  LOG.info("Streaming %s: %s, %s, %s. Tuned in %ss%s.", displayName, setup.serviceName, setup.profileName, captureMode, tuneTime, setup.directTune ? " (direct)" : "");
1062
1178
  // Mark channel health as successful. Only for predefined channels (channel is defined). Ad-hoc URL streams have no persistent channel identity. Domain
1063
1179
  // auth is conditionally skipped when the provider module defines validateTune and the tuned channel does not prove paid access (e.g., Sling Freestream).
@@ -1069,14 +1185,14 @@ async function completeStreamSetup(options) {
1069
1185
  const markAuth = !provider?.validateTune || provider.validateTune(channel.channelSelector ?? channelName);
1070
1186
  markChannelSuccess(channelName, successAuthDomain, markAuth);
1071
1187
  }
1072
- // Update the registry entry with codec and hardware acceleration state, then emit the stream added event for the dashboard. Native streams set their codec
1073
- // and quality fields earlier (when the native proxy is created), so only capture mode needs updating here.
1188
+ // Record the codec and hardware acceleration this stream settled on, then emit the stream added event for the dashboard. The registry write is capture-only:
1189
+ // a native upgrade already wrote its manifest-derived codec into the native identity, and hardware acceleration is a capture fact the status projection
1190
+ // answers false for on a native stream. The two DTO values below are computed for both modes because the wire shape stays flat.
1074
1191
  const streamCodec = (streamingMode === "native") ? nativeCodec : effectiveCodec.toUpperCase();
1075
1192
  const hwAccelerated = (streamingMode !== "native") && captureHwAccel;
1076
1193
  const currentEntry = getStream(numericStreamId);
1077
- if (currentEntry) {
1078
- currentEntry.captureCodec = streamCodec;
1079
- currentEntry.hardwareAccelerated = hwAccelerated;
1194
+ if (currentEntry && isCaptureIdentity(currentEntry)) {
1195
+ currentEntry.identity = { ...currentEntry.identity, captureCodec: streamCodec, hardwareAccelerated: hwAccelerated };
1080
1196
  }
1081
1197
  emitStreamAdded(createInitialStreamStatus({
1082
1198
  captureCodec: streamCodec,
@@ -1114,16 +1230,11 @@ async function startHLSStream(channelName, url, req, res, channel) {
1114
1230
  return await initializeStream({ channel, channelName, clientAddress, profileOverride, url });
1115
1231
  }
1116
1232
  catch (error) {
1117
- if (error instanceof StreamSetupError) {
1118
- if (error.statusCode === 503) {
1119
- res.setHeader("Retry-After", "10");
1120
- res.setHeader("X-HDHomeRun-Error", "All Tuners In Use");
1121
- }
1122
- res.status(error.statusCode).send(error.userMessage);
1123
- return null;
1233
+ // A StreamSetupError is an expected refusal whose message is already meant for the client, so only an unexpected failure earns an error log here.
1234
+ if (!(error instanceof StreamSetupError)) {
1235
+ LOG.error("Unexpected error during HLS stream setup: %s.", formatError(error));
1124
1236
  }
1125
- LOG.error("Unexpected error during HLS stream setup: %s.", formatError(error));
1126
- res.status(500).send("Internal server error.");
1237
+ sendSetupFailure(describeSetupFailure(error), res);
1127
1238
  return null;
1128
1239
  }
1129
1240
  }
@@ -1132,10 +1243,10 @@ async function startHLSStream(channelName, url, req, res, channel) {
1132
1243
  * Returns all streams that have exceeded the idle timeout and have no active MPEG-TS clients. Pretuned streams are excluded - they have no clients by design and
1133
1244
  * the pretune module manages their lifecycle via a safety timeout. The result is sorted by last access time (oldest first) so callers can efficiently pick the
1134
1245
  * longest-idle stream for reclamation.
1246
+ * @param now - The instant each stream's idle window is measured against.
1135
1247
  * @returns Idle streams sorted by last access time ascending (oldest first).
1136
1248
  */
1137
- function getIdleStreams() {
1138
- const now = Date.now();
1249
+ function getIdleStreams(now) {
1139
1250
  return getAllStreams()
1140
1251
  .filter((stream) => !stream.preTuned && (stream.mpegTsClientCount === 0) && ((now - stream.info.lastPlaylistRequest) >= CONFIG.hls.idleTimeout))
1141
1252
  .sort((a, b) => a.info.lastPlaylistRequest - b.info.lastPlaylistRequest);
@@ -1144,7 +1255,7 @@ function getIdleStreams() {
1144
1255
  * Checks for idle streams and terminates them. Called periodically by the idle detection interval.
1145
1256
  */
1146
1257
  export function cleanupIdleStreams() {
1147
- const idle = getIdleStreams();
1258
+ const idle = getIdleStreams(systemClock.now());
1148
1259
  for (const stream of idle) {
1149
1260
  terminateStream(stream.id, stream.info.storeKey, "no active clients");
1150
1261
  }
@@ -1159,7 +1270,7 @@ export function cleanupIdleStreams() {
1159
1270
  * @returns True if a stream was reclaimed, false if no idle streams exist.
1160
1271
  */
1161
1272
  function reclaimIdleStream() {
1162
- const idle = getIdleStreams();
1273
+ const idle = getIdleStreams(systemClock.now());
1163
1274
  if (idle.length === 0) {
1164
1275
  return false;
1165
1276
  }