prismcast 1.8.0 → 1.10.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 (392) hide show
  1. package/README.md +6 -6
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +41 -13
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.d.ts +4 -6
  6. package/dist/browser/cdp.js +18 -22
  7. package/dist/browser/cdp.js.map +1 -1
  8. package/dist/browser/channelSelection.d.ts +41 -11
  9. package/dist/browser/channelSelection.js +75 -24
  10. package/dist/browser/channelSelection.js.map +1 -1
  11. package/dist/browser/display.d.ts +1 -1
  12. package/dist/browser/index.d.ts +8 -8
  13. package/dist/browser/index.js +44 -45
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/login.d.ts +2 -2
  16. package/dist/browser/login.js +1 -1
  17. package/dist/browser/manifestInterceptor.d.ts +77 -0
  18. package/dist/browser/manifestInterceptor.js +323 -0
  19. package/dist/browser/manifestInterceptor.js.map +1 -0
  20. package/dist/browser/precaching.d.ts +1 -1
  21. package/dist/browser/precaching.js +23 -23
  22. package/dist/browser/precaching.js.map +1 -1
  23. package/dist/browser/tuning/comcastPolymer.d.ts +1 -1
  24. package/dist/browser/tuning/cox.d.ts +1 -1
  25. package/dist/browser/tuning/directv.d.ts +1 -1
  26. package/dist/browser/tuning/directv.js +65 -69
  27. package/dist/browser/tuning/directv.js.map +1 -1
  28. package/dist/browser/tuning/fox.d.ts +1 -1
  29. package/dist/browser/tuning/fox.js +172 -15
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.d.ts +1 -1
  32. package/dist/browser/tuning/hbo.js +7 -7
  33. package/dist/browser/tuning/hbo.js.map +1 -1
  34. package/dist/browser/tuning/hulu.d.ts +1 -1
  35. package/dist/browser/tuning/hulu.js +120 -113
  36. package/dist/browser/tuning/hulu.js.map +1 -1
  37. package/dist/browser/tuning/shared.d.ts +3 -3
  38. package/dist/browser/tuning/shared.js +5 -5
  39. package/dist/browser/tuning/sling.d.ts +1 -1
  40. package/dist/browser/tuning/sling.js +30 -30
  41. package/dist/browser/tuning/sling.js.map +1 -1
  42. package/dist/browser/tuning/spectrum.d.ts +1 -1
  43. package/dist/browser/tuning/spectrum.js +21 -22
  44. package/dist/browser/tuning/spectrum.js.map +1 -1
  45. package/dist/browser/tuning/thumbnailRow.d.ts +1 -1
  46. package/dist/browser/tuning/thumbnailRow.js +1 -1
  47. package/dist/browser/tuning/tileClick.d.ts +1 -1
  48. package/dist/browser/tuning/tileClick.js +5 -5
  49. package/dist/browser/tuning/xfinity.d.ts +1 -1
  50. package/dist/browser/tuning/youtubeTv.d.ts +1 -1
  51. package/dist/browser/tuning/youtubeTv.js +17 -18
  52. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  53. package/dist/browser/video.d.ts +20 -6
  54. package/dist/browser/video.js +30 -38
  55. package/dist/browser/video.js.map +1 -1
  56. package/dist/channels/index.d.ts +1 -2
  57. package/dist/channels/index.js +475 -442
  58. package/dist/channels/index.js.map +1 -1
  59. package/dist/config/channelForm.d.ts +58 -0
  60. package/dist/config/channelForm.js +129 -0
  61. package/dist/config/channelForm.js.map +1 -0
  62. package/dist/config/consistencyProbe.d.ts +7 -0
  63. package/dist/config/consistencyProbe.js +143 -0
  64. package/dist/config/consistencyProbe.js.map +1 -0
  65. package/dist/config/health.d.ts +7 -6
  66. package/dist/config/health.js +78 -65
  67. package/dist/config/health.js.map +1 -1
  68. package/dist/config/index.d.ts +1 -1
  69. package/dist/config/index.js +21 -14
  70. package/dist/config/index.js.map +1 -1
  71. package/dist/config/paths.d.ts +9 -3
  72. package/dist/config/paths.js +12 -4
  73. package/dist/config/paths.js.map +1 -1
  74. package/dist/config/persistence.context.d.ts +8 -0
  75. package/dist/config/persistence.context.js +30 -0
  76. package/dist/config/persistence.context.js.map +1 -0
  77. package/dist/config/persistence.d.ts +157 -0
  78. package/dist/config/persistence.js +446 -0
  79. package/dist/config/persistence.js.map +1 -0
  80. package/dist/config/presets.d.ts +14 -4
  81. package/dist/config/presets.js +84 -75
  82. package/dist/config/presets.js.map +1 -1
  83. package/dist/config/profiles.d.ts +2 -2
  84. package/dist/config/profiles.js +7 -9
  85. package/dist/config/profiles.js.map +1 -1
  86. package/dist/config/servicePacks.d.ts +46 -0
  87. package/dist/config/{providerPacks.js → servicePacks.js} +50 -53
  88. package/dist/config/servicePacks.js.map +1 -0
  89. package/dist/config/services.d.ts +228 -0
  90. package/dist/config/services.js +772 -0
  91. package/dist/config/services.js.map +1 -0
  92. package/dist/config/sites.d.ts +3 -3
  93. package/dist/config/sites.js +75 -75
  94. package/dist/config/sites.js.map +1 -1
  95. package/dist/config/userChannels.d.ts +522 -54
  96. package/dist/config/userChannels.js +1343 -366
  97. package/dist/config/userChannels.js.map +1 -1
  98. package/dist/config/userConfig.d.ts +108 -13
  99. package/dist/config/userConfig.js +296 -143
  100. package/dist/config/userConfig.js.map +1 -1
  101. package/dist/config/userProfiles.d.ts +34 -16
  102. package/dist/config/userProfiles.js +189 -100
  103. package/dist/config/userProfiles.js.map +1 -1
  104. package/dist/hdhr/channelMap.d.ts +1 -1
  105. package/dist/hdhr/channelMap.js +10 -16
  106. package/dist/hdhr/channelMap.js.map +1 -1
  107. package/dist/hdhr/deviceId.js +10 -9
  108. package/dist/hdhr/deviceId.js.map +1 -1
  109. package/dist/hdhr/discover.js +4 -5
  110. package/dist/hdhr/discover.js.map +1 -1
  111. package/dist/hdhr/index.js +12 -19
  112. package/dist/hdhr/index.js.map +1 -1
  113. package/dist/index.js +12 -12
  114. package/dist/index.js.map +1 -1
  115. package/dist/native/codecInference.d.ts +44 -0
  116. package/dist/native/codecInference.js +327 -0
  117. package/dist/native/codecInference.js.map +1 -0
  118. package/dist/native/decrypt.d.ts +1 -1
  119. package/dist/native/index.d.ts +5 -5
  120. package/dist/native/index.js +45 -67
  121. package/dist/native/index.js.map +1 -1
  122. package/dist/native/probe.d.ts +27 -9
  123. package/dist/native/probe.js +159 -68
  124. package/dist/native/probe.js.map +1 -1
  125. package/dist/native/proxy.d.ts +4 -2
  126. package/dist/native/proxy.js +56 -53
  127. package/dist/native/proxy.js.map +1 -1
  128. package/dist/native/tokenExpiry.d.ts +22 -0
  129. package/dist/native/tokenExpiry.js +153 -0
  130. package/dist/native/tokenExpiry.js.map +1 -0
  131. package/dist/routes/assets.js +3 -3
  132. package/dist/routes/assets.js.map +1 -1
  133. package/dist/routes/auth.js +9 -13
  134. package/dist/routes/auth.js.map +1 -1
  135. package/dist/routes/components.js +2 -2
  136. package/dist/routes/components.js.map +1 -1
  137. package/dist/routes/config/channels/endpoints/browse.d.ts +6 -0
  138. package/dist/routes/config/channels/endpoints/browse.js +195 -0
  139. package/dist/routes/config/channels/endpoints/browse.js.map +1 -0
  140. package/dist/routes/config/channels/endpoints/bulk.d.ts +6 -0
  141. package/dist/routes/config/channels/endpoints/bulk.js +128 -0
  142. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -0
  143. package/dist/routes/config/channels/endpoints/crud.d.ts +6 -0
  144. package/dist/routes/config/channels/endpoints/crud.js +504 -0
  145. package/dist/routes/config/channels/endpoints/crud.js.map +1 -0
  146. package/dist/routes/config/channels/endpoints/importExport.d.ts +6 -0
  147. package/dist/routes/config/channels/endpoints/importExport.js +131 -0
  148. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -0
  149. package/dist/routes/config/channels/endpoints/predefined.d.ts +6 -0
  150. package/dist/routes/config/channels/endpoints/predefined.js +78 -0
  151. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -0
  152. package/dist/routes/config/channels/endpoints/prefs.d.ts +6 -0
  153. package/dist/routes/config/channels/endpoints/prefs.js +58 -0
  154. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -0
  155. package/dist/routes/config/channels/endpoints/service.d.ts +6 -0
  156. package/dist/routes/config/channels/endpoints/service.js +149 -0
  157. package/dist/routes/config/channels/endpoints/service.js.map +1 -0
  158. package/dist/routes/config/channels/endpoints/tags.d.ts +6 -0
  159. package/dist/routes/config/channels/endpoints/tags.js +185 -0
  160. package/dist/routes/config/channels/endpoints/tags.js.map +1 -0
  161. package/dist/routes/config/channels/http/handler.d.ts +10 -0
  162. package/dist/routes/config/channels/http/handler.js +20 -0
  163. package/dist/routes/config/channels/http/handler.js.map +1 -0
  164. package/dist/routes/config/channels/http/playlistHint.d.ts +25 -0
  165. package/dist/routes/config/channels/http/playlistHint.js +40 -0
  166. package/dist/routes/config/channels/http/playlistHint.js.map +1 -0
  167. package/dist/routes/config/channels/http/serviceWarning.d.ts +10 -0
  168. package/dist/routes/config/channels/http/serviceWarning.js +28 -0
  169. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -0
  170. package/dist/routes/config/channels/index.d.ts +3 -3
  171. package/dist/routes/config/channels/index.js +2 -2
  172. package/dist/routes/config/channels/index.js.map +1 -1
  173. package/dist/routes/config/channels/{routes.d.ts → setup.d.ts} +1 -1
  174. package/dist/routes/config/channels/setup.js +23 -0
  175. package/dist/routes/config/channels/setup.js.map +1 -0
  176. package/dist/routes/config/channels/table.d.ts +13 -13
  177. package/dist/routes/config/channels/table.js +291 -184
  178. package/dist/routes/config/channels/table.js.map +1 -1
  179. package/dist/routes/config/http/envelope.d.ts +111 -0
  180. package/dist/routes/config/http/envelope.js +103 -0
  181. package/dist/routes/config/http/envelope.js.map +1 -0
  182. package/dist/routes/config/index.d.ts +9 -9
  183. package/dist/routes/config/index.js +5 -5
  184. package/dist/routes/config/index.js.map +1 -1
  185. package/dist/routes/config/{providers.js → services.js} +105 -99
  186. package/dist/routes/config/services.js.map +1 -0
  187. package/dist/routes/config/settings.d.ts +1 -1
  188. package/dist/routes/config/settings.js +85 -60
  189. package/dist/routes/config/settings.js.map +1 -1
  190. package/dist/routes/debug.js +17 -18
  191. package/dist/routes/debug.js.map +1 -1
  192. package/dist/routes/health.js +1 -1
  193. package/dist/routes/health.js.map +1 -1
  194. package/dist/routes/icons.js +2 -2
  195. package/dist/routes/icons.js.map +1 -1
  196. package/dist/routes/index.js +2 -2
  197. package/dist/routes/index.js.map +1 -1
  198. package/dist/routes/logs.js +23 -34
  199. package/dist/routes/logs.js.map +1 -1
  200. package/dist/routes/mpegts.js +1 -1
  201. package/dist/routes/playlist.d.ts +5 -5
  202. package/dist/routes/playlist.js +84 -61
  203. package/dist/routes/playlist.js.map +1 -1
  204. package/dist/routes/root/content.js +82 -61
  205. package/dist/routes/root/content.js.map +1 -1
  206. package/dist/routes/root/index.js +2 -5
  207. package/dist/routes/root/index.js.map +1 -1
  208. package/dist/routes/root/scripts/channels.js +538 -581
  209. package/dist/routes/root/scripts/channels.js.map +1 -1
  210. package/dist/routes/root/scripts/config.js +868 -1022
  211. package/dist/routes/root/scripts/config.js.map +1 -1
  212. package/dist/routes/root/scripts/index.d.ts +4 -4
  213. package/dist/routes/root/scripts/shared.js +445 -197
  214. package/dist/routes/root/scripts/shared.js.map +1 -1
  215. package/dist/routes/root/scripts/status.d.ts +4 -0
  216. package/dist/routes/root/scripts/status.handlers.d.ts +182 -0
  217. package/dist/routes/root/scripts/status.handlers.js +713 -0
  218. package/dist/routes/root/scripts/status.handlers.js.map +1 -0
  219. package/dist/routes/root/scripts/status.js +85 -436
  220. package/dist/routes/root/scripts/status.js.map +1 -1
  221. package/dist/routes/root/styles.js +16 -8
  222. package/dist/routes/root/styles.js.map +1 -1
  223. package/dist/routes/services.d.ts +6 -0
  224. package/dist/routes/{providers.js → services.js} +58 -55
  225. package/dist/routes/services.js.map +1 -0
  226. package/dist/routes/sse.d.ts +16 -0
  227. package/dist/routes/sse.js +29 -0
  228. package/dist/routes/sse.js.map +1 -0
  229. package/dist/routes/streams.js +10 -23
  230. package/dist/routes/streams.js.map +1 -1
  231. package/dist/routes/ui.d.ts +2 -2
  232. package/dist/routes/ui.js +2 -0
  233. package/dist/routes/ui.js.map +1 -1
  234. package/dist/routes/upgrade.js +6 -7
  235. package/dist/routes/upgrade.js.map +1 -1
  236. package/dist/service/commands.context.d.ts +6 -0
  237. package/dist/service/commands.context.js +47 -0
  238. package/dist/service/commands.context.js.map +1 -0
  239. package/dist/service/commands.d.ts +53 -8
  240. package/dist/service/commands.js +172 -192
  241. package/dist/service/commands.js.map +1 -1
  242. package/dist/service/generators.context.d.ts +6 -0
  243. package/dist/service/generators.context.js +45 -0
  244. package/dist/service/generators.context.js.map +1 -0
  245. package/dist/service/generators.d.ts +56 -16
  246. package/dist/service/generators.js +509 -291
  247. package/dist/service/generators.js.map +1 -1
  248. package/dist/service/index.d.ts +2 -2
  249. package/dist/streaming/clients.d.ts +2 -2
  250. package/dist/streaming/clients.js +2 -2
  251. package/dist/streaming/codec.d.ts +2 -2
  252. package/dist/streaming/codec.js +0 -1
  253. package/dist/streaming/codec.js.map +1 -1
  254. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  255. package/dist/streaming/fmp4Segmenter.js +12 -12
  256. package/dist/streaming/hls.d.ts +7 -7
  257. package/dist/streaming/hls.js +104 -92
  258. package/dist/streaming/hls.js.map +1 -1
  259. package/dist/streaming/hlsResume.d.ts +3 -3
  260. package/dist/streaming/hlsResume.js +6 -6
  261. package/dist/streaming/hlsResume.js.map +1 -1
  262. package/dist/streaming/hlsSegments.d.ts +5 -2
  263. package/dist/streaming/hlsSegments.js +14 -9
  264. package/dist/streaming/hlsSegments.js.map +1 -1
  265. package/dist/streaming/monitor.d.ts +12 -12
  266. package/dist/streaming/monitor.js +98 -66
  267. package/dist/streaming/monitor.js.map +1 -1
  268. package/dist/streaming/mp4Parser.d.ts +34 -4
  269. package/dist/streaming/mp4Parser.js +111 -29
  270. package/dist/streaming/mp4Parser.js.map +1 -1
  271. package/dist/streaming/mpegts.d.ts +1 -1
  272. package/dist/streaming/mpegts.js +13 -10
  273. package/dist/streaming/mpegts.js.map +1 -1
  274. package/dist/streaming/playlistBuilder.d.ts +1 -1
  275. package/dist/streaming/playlistBuilder.js +2 -2
  276. package/dist/streaming/preroll.d.ts +9 -9
  277. package/dist/streaming/preroll.js +40 -42
  278. package/dist/streaming/preroll.js.map +1 -1
  279. package/dist/streaming/pretune.js +4 -4
  280. package/dist/streaming/pretune.js.map +1 -1
  281. package/dist/streaming/recovery.d.ts +3 -3
  282. package/dist/streaming/recovery.js +4 -4
  283. package/dist/streaming/registry.d.ts +6 -6
  284. package/dist/streaming/registry.js +4 -8
  285. package/dist/streaming/registry.js.map +1 -1
  286. package/dist/streaming/setup.d.ts +11 -10
  287. package/dist/streaming/setup.js +120 -71
  288. package/dist/streaming/setup.js.map +1 -1
  289. package/dist/streaming/showInfo.d.ts +9 -3
  290. package/dist/streaming/showInfo.js +33 -27
  291. package/dist/streaming/showInfo.js.map +1 -1
  292. package/dist/streaming/statusEmitter.d.ts +8 -7
  293. package/dist/streaming/statusEmitter.js +5 -5
  294. package/dist/streaming/statusEmitter.js.map +1 -1
  295. package/dist/types/channels.d.ts +139 -36
  296. package/dist/types/channels.js +39 -1
  297. package/dist/types/channels.js.map +1 -1
  298. package/dist/types/config.d.ts +22 -3
  299. package/dist/types/index.d.ts +9 -8
  300. package/dist/types/index.js +1 -0
  301. package/dist/types/index.js.map +1 -1
  302. package/dist/types/profiles.d.ts +14 -13
  303. package/dist/types/selection.d.ts +49 -5
  304. package/dist/types/shared.d.ts +1 -1
  305. package/dist/types/streaming.d.ts +2 -2
  306. package/dist/upgrade/commands.context.d.ts +6 -0
  307. package/dist/upgrade/commands.context.js +29 -0
  308. package/dist/upgrade/commands.context.js.map +1 -0
  309. package/dist/upgrade/commands.d.ts +29 -2
  310. package/dist/upgrade/commands.js +87 -134
  311. package/dist/upgrade/commands.js.map +1 -1
  312. package/dist/upgrade/detection.context.d.ts +6 -0
  313. package/dist/upgrade/detection.context.js +24 -0
  314. package/dist/upgrade/detection.context.js.map +1 -0
  315. package/dist/upgrade/detection.d.ts +140 -10
  316. package/dist/upgrade/detection.js +153 -66
  317. package/dist/upgrade/detection.js.map +1 -1
  318. package/dist/upgrade/index.d.ts +2 -2
  319. package/dist/utils/chromeFetch.d.ts +1 -1
  320. package/dist/utils/chromeFetch.js +2 -2
  321. package/dist/utils/clock.d.ts +14 -0
  322. package/dist/utils/clock.js +28 -0
  323. package/dist/utils/clock.js.map +1 -0
  324. package/dist/utils/debugFilter.d.ts +1 -1
  325. package/dist/utils/debugFilter.js +8 -6
  326. package/dist/utils/debugFilter.js.map +1 -1
  327. package/dist/utils/delay.d.ts +2 -3
  328. package/dist/utils/delay.js +10 -16
  329. package/dist/utils/delay.js.map +1 -1
  330. package/dist/utils/evaluate.js +12 -14
  331. package/dist/utils/evaluate.js.map +1 -1
  332. package/dist/utils/ffmpeg.context.d.ts +6 -0
  333. package/dist/utils/ffmpeg.context.js +31 -0
  334. package/dist/utils/ffmpeg.context.js.map +1 -0
  335. package/dist/utils/ffmpeg.d.ts +85 -29
  336. package/dist/utils/ffmpeg.js +126 -129
  337. package/dist/utils/ffmpeg.js.map +1 -1
  338. package/dist/utils/fileLogger.d.ts +18 -2
  339. package/dist/utils/fileLogger.js +60 -31
  340. package/dist/utils/fileLogger.js.map +1 -1
  341. package/dist/utils/format.d.ts +7 -1
  342. package/dist/utils/format.js +22 -2
  343. package/dist/utils/format.js.map +1 -1
  344. package/dist/utils/html.js +1 -3
  345. package/dist/utils/html.js.map +1 -1
  346. package/dist/utils/index.d.ts +21 -20
  347. package/dist/utils/index.js +1 -0
  348. package/dist/utils/index.js.map +1 -1
  349. package/dist/utils/logger.js +31 -45
  350. package/dist/utils/logger.js.map +1 -1
  351. package/dist/utils/m3u.d.ts +18 -3
  352. package/dist/utils/m3u.js +28 -14
  353. package/dist/utils/m3u.js.map +1 -1
  354. package/dist/utils/memo.d.ts +19 -0
  355. package/dist/utils/memo.js +53 -0
  356. package/dist/utils/memo.js.map +1 -0
  357. package/dist/utils/morganStream.js +4 -4
  358. package/dist/utils/morganStream.js.map +1 -1
  359. package/dist/utils/pid.d.ts +2 -2
  360. package/dist/utils/pid.js +2 -2
  361. package/dist/utils/platform.d.ts +1 -1
  362. package/dist/utils/platform.js +3 -2
  363. package/dist/utils/platform.js.map +1 -1
  364. package/dist/utils/retry.d.ts +2 -0
  365. package/dist/utils/retry.js +9 -7
  366. package/dist/utils/retry.js.map +1 -1
  367. package/dist/utils/sanitize.d.ts +2 -2
  368. package/dist/utils/sanitize.js +4 -4
  369. package/dist/utils/streamContext.d.ts +7 -0
  370. package/dist/utils/streamContext.js +10 -1
  371. package/dist/utils/streamContext.js.map +1 -1
  372. package/dist/utils/timing.d.ts +6 -3
  373. package/dist/utils/timing.js +10 -6
  374. package/dist/utils/timing.js.map +1 -1
  375. package/dist/utils/version.d.ts +3 -2
  376. package/dist/utils/version.js +15 -28
  377. package/dist/utils/version.js.map +1 -1
  378. package/package.json +14 -10
  379. package/dist/config/providerPacks.d.ts +0 -46
  380. package/dist/config/providerPacks.js.map +0 -1
  381. package/dist/config/providers.d.ts +0 -175
  382. package/dist/config/providers.js +0 -691
  383. package/dist/config/providers.js.map +0 -1
  384. package/dist/native/intercept.d.ts +0 -35
  385. package/dist/native/intercept.js +0 -184
  386. package/dist/native/intercept.js.map +0 -1
  387. package/dist/routes/config/channels/routes.js +0 -1477
  388. package/dist/routes/config/channels/routes.js.map +0 -1
  389. package/dist/routes/config/providers.js.map +0 -1
  390. package/dist/routes/providers.d.ts +0 -6
  391. package/dist/routes/providers.js.map +0 -1
  392. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  import { LOG, delay, evaluateWithAbort, formatError } from "../../utils/index.js";
2
2
  import { logAvailableChannels, normalizeChannelName, scrollAndClick } from "./shared.js";
3
3
  import { CONFIG } from "../../config/index.js";
4
- // Unified channel cache for Hulu. Maps normalized channel names to their combined entry. Aliases (e.g., "abc" → same entry as "wls") share object references for
4
+ // Unified channel cache for Hulu. Maps normalized channel names to their combined entry. Aliases (e.g., "abc" -> same entry as "wls") share object references for
5
5
  // automatic propagation of fresh programs and EAB data. Cleared on browser disconnect via clearHuluCache().
6
6
  const huluChannelCache = new Map();
7
7
  // Transient staging map for listing API response data. Maps channel UUIDs to their program schedules. Populated from listing API responses (which arrive before
@@ -48,7 +48,7 @@ function resolveHuluCacheKey(normalizedName) {
48
48
  return null;
49
49
  }
50
50
  /**
51
- * Looks up a channel in the unified cache by normalized name, with fuzzy fallback via resolveHuluCacheKey. Only used for lookups keyed by channelSelector —
51
+ * Looks up a channel in the unified cache by normalized name, with fuzzy fallback via resolveHuluCacheKey. Only used for lookups keyed by channelSelector -
52
52
  * internal cache operations that use API-derived names should use huluChannelCache.get() directly.
53
53
  * @param normalizedName - The normalized (lowercased, whitespace-collapsed) channel name to look up.
54
54
  * @returns The matching cache entry, or null if no match found.
@@ -152,11 +152,11 @@ async function readRenderedChannels(page) {
152
152
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
153
153
  const text = srOnly.textContent ?? "";
154
154
  const nameMatch = /^(.+?) Details, row/.exec(text);
155
- if (nameMatch) {
155
+ if (nameMatch?.[1]) {
156
156
  displayName = nameMatch[1].trim();
157
157
  }
158
158
  const rowMatch = /row (\d+) of/.exec(text);
159
- if (rowMatch) {
159
+ if (rowMatch?.[1]) {
160
160
  // Row numbers in sr-only text are 1-based. Convert to 0-based for scroll offset calculation.
161
161
  rowNumber = parseInt(rowMatch[1], 10) - 1;
162
162
  }
@@ -171,9 +171,8 @@ async function readRenderedChannels(page) {
171
171
  }
172
172
  // Assign DOM indices and populate the unified channel cache with row numbers and display names.
173
173
  const rendered = [];
174
- for (let i = 0; i < channels.length; i++) {
175
- const ch = channels[i];
176
- rendered.push({ displayName: ch.displayName, domIndex: i, name: ch.name, rowNumber: ch.rowNumber });
174
+ for (const [domIndex, ch] of channels.entries()) {
175
+ rendered.push({ displayName: ch.displayName, domIndex, name: ch.name, rowNumber: ch.rowNumber });
177
176
  // Cache the row number and display name for future direct-scroll lookups.
178
177
  if (ch.rowNumber >= 0) {
179
178
  const existing = huluChannelCache.get(ch.name);
@@ -242,7 +241,7 @@ async function scrollToGuideRow(page, gridDocTop, rowHeight, rowIndex) {
242
241
  /**
243
242
  * Locates the on-now program cell for the channel at the given data-testid name (lowercased, trimmed), scrolls it into view, and returns its center coordinates
244
243
  * for a subsequent page.mouse.click(). We return coordinates rather than clicking inside the evaluate because page.mouse.click() generates the full pointer event
245
- * chain (pointerdown → mousedown → pointerup → mouseup → click) that React's event delegation requires, whereas a bare DOM .click() dispatches only a synthetic
244
+ * chain (pointerdown -> mousedown -> pointerup -> mouseup -> click) that React's event delegation requires, whereas a bare DOM .click() dispatches only a synthetic
246
245
  * click event that may not be processed reliably in a Puppeteer automation context.
247
246
  * @param page - The Puppeteer page object.
248
247
  * @param targetName - The lowercased, trimmed channel name to match against data-testid.
@@ -279,9 +278,9 @@ async function locateOnNowCell(page, targetName) {
279
278
  // pattern is used to identify call signs in the guide grid so position-based inference can find local affiliates that sort by their hidden network name.
280
279
  const CALL_SIGN_PATTERN = /^[WK][A-Z]{2,3}$/i;
281
280
  // Network names that map to local affiliate call signs in Hulu's guide. The details API returns channel_info.name as the local call sign rather than the
282
- // network name, so these names won't match any details response entry. Cold direct tuning is skipped for these — the guide grid handles them via
281
+ // network name, so these names won't match any details response entry. Cold direct tuning is skipped for these - the guide grid handles them via
283
282
  // position-based inference. On the first guide grid tune, the in-page interceptor captures the affiliate's channel_id from the playlist request and caches
284
- // it under the channelSelector key (e.g., "abc" → uuid). Subsequent tunes resolve via warm direct tuning.
283
+ // it under the channelSelector key (e.g., "abc" -> uuid). Subsequent tunes resolve via warm direct tuning.
285
284
  const NETWORK_NAMES_WITH_AFFILIATES = new Set(["abc", "cbs", "cw", "fox", "nbc", "pbs"]);
286
285
  /**
287
286
  * Position-based inference for local affiliates. When binary search returns "missing" (target name sorts between rendered channels but no exact match), this
@@ -312,30 +311,25 @@ function inferLocalAffiliate(rendered, targetName) {
312
311
  return null;
313
312
  }
314
313
  // Find the insertion point: the first anchor whose name sorts after the target.
315
- let insertBeforeIndex = -1;
316
- for (let i = 0; i < anchors.length; i++) {
317
- if (targetName.localeCompare(anchors[i].name) < 0) {
318
- insertBeforeIndex = i;
319
- break;
320
- }
321
- }
322
- // Determine the DOM index range between the two surrounding anchor channels.
314
+ const insertBeforeIndex = anchors.findIndex((anchor) => targetName.localeCompare(anchor.name) < 0);
315
+ // Determine the DOM index range between the two surrounding anchor channels. The anchor indexing is guaranteed in-range by construction of
316
+ // insertBeforeIndex above, but noUncheckedIndexedAccess needs explicit narrowing.
323
317
  let lowerDomIndex;
324
318
  let upperDomIndex;
325
319
  if (insertBeforeIndex === 0) {
326
320
  // Target sorts before all anchors. Look for call signs before the first anchor.
327
321
  lowerDomIndex = -1;
328
- upperDomIndex = anchors[0].domIndex;
322
+ upperDomIndex = anchors[0]?.domIndex ?? rendered.length;
329
323
  }
330
324
  else if (insertBeforeIndex === -1) {
331
325
  // Target sorts after all anchors. Look for call signs after the last anchor.
332
- lowerDomIndex = anchors[anchors.length - 1].domIndex;
326
+ lowerDomIndex = anchors.at(-1)?.domIndex ?? (rendered.length - 1);
333
327
  upperDomIndex = rendered.length;
334
328
  }
335
329
  else {
336
330
  // Target sorts between two anchors.
337
- lowerDomIndex = anchors[insertBeforeIndex - 1].domIndex;
338
- upperDomIndex = anchors[insertBeforeIndex].domIndex;
331
+ lowerDomIndex = anchors[insertBeforeIndex - 1]?.domIndex ?? -1;
332
+ upperDomIndex = anchors[insertBeforeIndex]?.domIndex ?? rendered.length;
339
333
  }
340
334
  // Find call sign channels in the DOM range between the two anchors.
341
335
  for (const ch of rendered) {
@@ -388,8 +382,8 @@ async function waitForPlayButton(page, playSelector, timeout) {
388
382
  }
389
383
  /**
390
384
  * Clicks the on-now program cell and waits for the play button, retrying on failure. Handles two distinct failure modes: (1) the on-now cell click doesn't
391
- * register because React hasn't fully hydrated event handlers — the play button never appears; (2) the play button appears and is clicked but the click is
392
- * silently swallowed — playback doesn't start. Both failures are detected by waitForPlayButton and trigger a retry of the full on-now cell and play button
385
+ * register because React hasn't fully hydrated event handlers - the play button never appears; (2) the play button appears and is clicked but the click is
386
+ * silently swallowed - playback doesn't start. Both failures are detected by waitForPlayButton and trigger a retry of the full on-now cell and play button
393
387
  * sequence after a brief delay.
394
388
  * @param page - The Puppeteer page object.
395
389
  * @param clickTarget - The lowercased, trimmed channel name to locate in the guide grid.
@@ -400,7 +394,7 @@ async function waitForPlayButton(page, playSelector, timeout) {
400
394
  async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName) {
401
395
  // Maximum number of on-now cell click attempts. The first click may not register if React hasn't finished hydrating the guide's event handlers.
402
396
  const MAX_CLICK_ATTEMPTS = 3;
403
- // Shorter timeout for the play button on non-final attempts. When a click registers, the play button appears in under 10ms — this timeout only determines how
397
+ // Shorter timeout for the play button on non-final attempts. When a click registers, the play button appears in under 10ms - this timeout only determines how
404
398
  // quickly we detect a missed click and retry. Keeping it low saves ~2s per failed attempt compared to the previous 3000ms value.
405
399
  const RETRY_PLAY_TIMEOUT = 1000;
406
400
  // Delay between click retries. Gives React additional time to finish hydrating event handlers.
@@ -483,7 +477,7 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
483
477
  * self-resolved from in-page API data. Two resolution mechanisms handle different channel types:
484
478
  *
485
479
  * 1. Server-side injection: finds the currently-airing EAB from the entry's program schedule and calls __prismcastResolveDirectTune to inject both values into the
486
- * held playlist request. Primary mechanism for local affiliates whose call signs don't match the channelSelector network name — the interceptor can't
480
+ * held playlist request. Primary mechanism for local affiliates whose call signs don't match the channelSelector network name - the interceptor can't
487
481
  * self-resolve by name for these, so external injection is the only path.
488
482
  * 2. Self-resolution detection: queries __prismcastIsDirectTuneResolved to check if the interceptor already captured UUID+EAB from the expanded
489
483
  * Details+Listing API responses and resolved the playlist autonomously. Primary mechanism for non-affiliate channels on cold cache, where the interceptor's
@@ -504,7 +498,7 @@ async function tryFastPathTune(page, entry, channelName) {
504
498
  const currentEab = findCurrentEabFromPrograms(entry.programs);
505
499
  if (currentEab) {
506
500
  const injected = await page.evaluate((u, e) => {
507
- const resolver = window.__prismcastResolveDirectTune;
501
+ const resolver = window["__prismcastResolveDirectTune"];
508
502
  if (typeof resolver === "function") {
509
503
  return resolver(u, e);
510
504
  }
@@ -521,7 +515,7 @@ async function tryFastPathTune(page, entry, channelName) {
521
515
  // responses triggered by the Channels tab click and resolves the held playlist autonomously, making the guide grid click redundant.
522
516
  if (!resolved) {
523
517
  const selfResolved = await page.evaluate(() => {
524
- const checker = window.__prismcastIsDirectTuneResolved;
518
+ const checker = window["__prismcastIsDirectTuneResolved"];
525
519
  if (typeof checker === "function") {
526
520
  return checker();
527
521
  }
@@ -554,7 +548,7 @@ async function tryFastPathTune(page, entry, channelName) {
554
548
  */
555
549
  async function releaseHeldPlaylist(page) {
556
550
  await page.evaluate(() => {
557
- const release = window.__prismcastReleasePlaylist;
551
+ const release = window["__prismcastReleasePlaylist"];
558
552
  if (typeof release === "function") {
559
553
  release();
560
554
  }
@@ -563,13 +557,13 @@ async function releaseHeldPlaylist(page) {
563
557
  /**
564
558
  * Guide grid strategy: finds a channel in a virtualized, alphabetically sorted channel grid by scrolling the page to the target row using binary search, then
565
559
  * clicking the on-now program cell to open the playback overlay. This strategy works for sites like Hulu Live TV where the channel guide is rendered as a
566
- * virtualized list — only ~13 of ~124 rows exist in the DOM at any time, positioned absolutely within a tall spacer div. The virtualizer renders rows based on
560
+ * virtualized list - only ~13 of ~124 rows exist in the DOM at any time, positioned absolutely within a tall spacer div. The virtualizer renders rows based on
567
561
  * the page scroll position (`document.documentElement.scrollTop`), so we scroll to bring the target channel into the DOM, then interact with it directly.
568
562
  *
569
563
  * Three mechanisms handle different channel types:
570
- * 1. Binary search with passive row number caching — primary mechanism for most channels (~800ms first time, ~200ms on cache hit)
571
- * 2. Position-based inference — handles local affiliates when searching by network name (e.g., "ABC" finds the local call sign at the right sort position)
572
- * 3. Linear scan fallback — safety net for raw call sign searches or any channel the binary search cannot find (~2.4 seconds)
564
+ * 1. Binary search with passive row number caching - primary mechanism for most channels (~800ms first time, ~200ms on cache hit)
565
+ * 2. Position-based inference - handles local affiliates when searching by network name (e.g., "ABC" finds the local call sign at the right sort position)
566
+ * 3. Linear scan fallback - safety net for raw call sign searches or any channel the binary search cannot find (~2.4 seconds)
573
567
  *
574
568
  * The selection process:
575
569
  * 1. If listSelector is provided, click the tab/button to reveal the channel list (e.g., a "Channels" tab)
@@ -587,7 +581,7 @@ async function guideGridStrategy(page, profile) {
587
581
  const { channelSelection, channelSelector: channelName } = profile;
588
582
  const { listSelector, playSelector } = channelSelection;
589
583
  // Ensure the guide is open and on the correct tab. We wait for the tab button to become VISIBLE (not just present in the DOM) because the guide overlay may exist
590
- // in the DOM structure while still hidden during page initialization or animation. Clicking a hidden button dispatches a DOM event but has no visual effect — the
584
+ // in the DOM structure while still hidden during page initialization or animation. Clicking a hidden button dispatches a DOM event but has no visual effect - the
591
585
  // guide remains hidden and the virtualizer never populates rows. We use $eval for the click because overlapping elements (spinners, overlays) can intercept
592
586
  // Puppeteer's coordinate-based mouse events.
593
587
  if (listSelector) {
@@ -601,7 +595,7 @@ async function guideGridStrategy(page, profile) {
601
595
  LOG.warn("Could not click channel list selector %s: %s.", listSelector, formatError(error));
602
596
  }
603
597
  }
604
- // Wait for channel grid rows to become visible. If rows don't appear within a short initial window, retry the tab click once — the first click may have fired
598
+ // Wait for channel grid rows to become visible. If rows don't appear within a short initial window, retry the tab click once - the first click may have fired
605
599
  // during a transitional state before the guide was fully interactive, or the guide may have been animating open.
606
600
  let rowsVisible = false;
607
601
  for (let guideAttempt = 0; guideAttempt < 2; guideAttempt++) {
@@ -685,7 +679,7 @@ async function guideGridStrategy(page, profile) {
685
679
  break;
686
680
  }
687
681
  // Determine binary search direction by comparing the target against the first and last rendered non-call-sign channel names. Call sign channels (W*/K*
688
- // local affiliates) are excluded from direction comparison because they sort by hidden network name, not by their displayed call sign — using them for
682
+ // local affiliates) are excluded from direction comparison because they sort by hidden network name, not by their displayed call sign - using them for
689
683
  // localeCompare would send the search the wrong way.
690
684
  const nonCallSigns = rendered.filter((ch) => !CALL_SIGN_PATTERN.test(ch.name));
691
685
  if (nonCallSigns.length === 0) {
@@ -693,8 +687,8 @@ async function guideGridStrategy(page, profile) {
693
687
  low = mid + 1;
694
688
  continue;
695
689
  }
696
- const first = nonCallSigns[0].name;
697
- const last = nonCallSigns[nonCallSigns.length - 1].name;
690
+ const first = nonCallSigns[0]?.name ?? "";
691
+ const last = nonCallSigns.at(-1)?.name ?? "";
698
692
  if (normalizedName.localeCompare(first) < 0) {
699
693
  // Target sorts before the first visible non-call-sign channel. Scroll up (toward lower row indices).
700
694
  high = mid - 1;
@@ -705,7 +699,7 @@ async function guideGridStrategy(page, profile) {
705
699
  low = mid + 1;
706
700
  continue;
707
701
  }
708
- // The target is alphabetically between the first and last rendered channels but was not found by exact data-testid match. This is the "missing" case — the
702
+ // The target is alphabetically between the first and last rendered channels but was not found by exact data-testid match. This is the "missing" case - the
709
703
  // channel may be a local affiliate whose call sign doesn't match the network name we're searching for. Try position-based inference.
710
704
  const inferred = inferLocalAffiliate(rendered, normalizedName);
711
705
  if (inferred) {
@@ -751,7 +745,7 @@ async function guideGridStrategy(page, profile) {
751
745
  if (!found) {
752
746
  // Log available channels from the unified cache to help users identify the correct channelSelector value. The cache accumulates all channel names
753
747
  // encountered during binary search and linear scan, so it contains most or all channels even though the virtualized grid only renders ~13 at a time.
754
- const availableChannels = Array.from(huluChannelCache.keys()).sort();
748
+ const availableChannels = Array.from(huluChannelCache.keys()).toSorted();
755
749
  if (availableChannels.length > 0) {
756
750
  logAvailableChannels({
757
751
  availableChannels,
@@ -767,12 +761,12 @@ async function guideGridStrategy(page, profile) {
767
761
  // triggers full Details+Listing API responses. For non-affiliates, the in-page interceptor may have already self-resolved from those responses (capturing
768
762
  // UUID+EAB and swapping the playlist autonomously). For channels where the interceptor hasn't self-resolved, the unified cache provides UUID+EAB for
769
763
  // injection. Either path avoids the redundant on-now cell click. Note: for affiliates, the inference block above may have already called tryFastPathTune with
770
- // the same entry — that call is redundant here but harmless (~10ms), and avoiding it with a flag would create a fragile coupling to the inference block.
764
+ // the same entry - that call is redundant here but harmless (~10ms), and avoiding it with a flag would create a fragile coupling to the inference block.
771
765
  const fastPathSuccess = await tryFastPathTune(page, findHuluChannelEntry(normalizedName), channelName);
772
766
  if (fastPathSuccess) {
773
767
  return { success: true };
774
768
  }
775
- // Release the held playlist before falling through to the click path. Neither tryFastPathTune nor the inference block calls releaseHeldPlaylist — they either
769
+ // Release the held playlist before falling through to the click path. Neither tryFastPathTune nor the inference block calls releaseHeldPlaylist - they either
776
770
  // inject successfully (resolving the held playlist directly) or return false without side effects. The release is a no-op if the hold was already resolved.
777
771
  await releaseHeldPlaylist(page);
778
772
  // Click the on-now program cell and wait for the play button, with click retries to handle React hydration timing.
@@ -804,7 +798,7 @@ async function guideGridWithRetry(page, profile) {
804
798
  /**
805
799
  * Sets up server-side response interception on the page to capture channel data from Hulu's guide APIs. As the live page loads, Hulu fetches program details from
806
800
  * guide.hulu.com/guide/details in batches and program schedules from guide.hulu.com/guide/listing. We intercept both responses to populate the unified channel
807
- * cache: listing data goes into the staging map (UUID → programs), then details data triggers populateHuluChannelCache which joins name → UUID with staged
801
+ * cache: listing data goes into the staging map (UUID -> programs), then details data triggers populateHuluChannelCache which joins name -> UUID with staged
808
802
  * programs. Also bridges in-page console signals (HULU-DIAG, HULU-CACHE, HULU-FAIL) to the Node.js LOG. Uses a WeakSet to prevent duplicate listener
809
803
  * registration.
810
804
  * @param page - The Puppeteer page object.
@@ -848,7 +842,7 @@ function setupDetailsResponseInterception(page) {
848
842
  }
849
843
  // Direct tune failure: the interceptor's hold period expired without resolving UUID+EAB for the target channel. The 503 response prevents Hulu from playing
850
844
  // the wrong channel. The guide grid binary search runs as the fallback. Since all cold tunes go through the guide grid (triggering full API expansion), this
851
- // path should rarely be reached — it provides defense-in-depth against silent false positives.
845
+ // path should rarely be reached - it provides defense-in-depth against silent false positives.
852
846
  if (text.startsWith("[HULU-FAIL]")) {
853
847
  LOG.warn(text);
854
848
  }
@@ -858,7 +852,7 @@ function setupDetailsResponseInterception(page) {
858
852
  if (response.status() !== 200) {
859
853
  return;
860
854
  }
861
- // Details API: populate the unified channel cache by joining name → UUID from this response with programs from the listing staging map.
855
+ // Details API: populate the unified channel cache by joining name -> UUID from this response with programs from the listing staging map.
862
856
  if (url.includes("guide.hulu.com/guide/details")) {
863
857
  void response.json().then((data) => {
864
858
  if (!Array.isArray(data.items)) {
@@ -904,7 +898,7 @@ function setupDetailsResponseInterception(page) {
904
898
  /**
905
899
  * Resolves a direct URL for Hulu channel tuning and installs a fetch interceptor that handles both warm and cold tunes. On warm cache (UUID and EAB known from
906
900
  * previous API responses), the interceptor has both values at install time and swaps the first playlist request immediately. On cold cache (no UUID), returns
907
- * null so the guide grid runs — the Channels tab click triggers full Details+Listing API expansion for all ~123 channels, and the interceptor captures UUID+EAB
901
+ * null so the guide grid runs - the Channels tab click triggers full Details+Listing API expansion for all ~123 channels, and the interceptor captures UUID+EAB
908
902
  * from those expanded responses to resolve the held playlist. Without the Channels tab click, the initial page load only provides data for ~10 visible channels.
909
903
  * On all tunes, the interceptor also expands listing and details API requests to populate the full cache for future warm tunes.
910
904
  * @param channelSelector - The channel selector string (e.g., "Fox", "CNN", "ESPN").
@@ -919,7 +913,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
919
913
  // during both the guide grid flow (cold cache) and the intercepted page load (warm cache).
920
914
  setupDetailsResponseInterception(page);
921
915
  // Look up the currently-airing EAB for the target channel (if UUID and programs are known). On warm cache (both UUID and EAB available), the interceptor has
922
- // both at install time and swaps immediately. On cold cache (no UUID), we return null below so the guide grid runs — the Channels tab click triggers full API
916
+ // both at install time and swaps immediately. On cold cache (no UUID), we return null below so the guide grid runs - the Channels tab click triggers full API
923
917
  // expansion.
924
918
  const cachedEab = (cachedEntry?.programs) ? findCurrentEabFromPrograms(cachedEntry.programs) : null;
925
919
  const isWarmCache = Boolean(cachedUuid && cachedEab);
@@ -927,7 +921,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
927
921
  LOG.debug("tuning:hulu", "resolveHuluDirectUrl: warm cache for %s (uuid=%s, eab=%s).", channelSelector, cachedUuid, cachedEab);
928
922
  }
929
923
  else if (!cachedUuid) {
930
- // On cold cache, all channels fall through to the guide grid — the Channels tab click triggers full Details+Listing API expansion for all ~123 channels.
924
+ // On cold cache, all channels fall through to the guide grid - the Channels tab click triggers full Details+Listing API expansion for all ~123 channels.
931
925
  // The affiliate/non-affiliate distinction is diagnostic only (both return null). Affiliates use position-based inference; non-affiliates use exact match.
932
926
  if (NETWORK_NAMES_WITH_AFFILIATES.has(normalizedName)) {
933
927
  LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s (local affiliate). Falling through to guide grid.", channelSelector);
@@ -960,7 +954,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
960
954
  /* Install the fetch interceptor before navigation on both warm and cold tunes. On warm tunes, it swaps channel_id and content_eab_id in playlist requests
961
955
  * immediately. On cold tunes, the guide grid's Channels tab click triggers full API expansion, and the interceptor holds the playlist request until both
962
956
  * UUID and EAB are captured from those responses. On all tunes, it captures listing API responses to build an in-page EAB map and expands subsequent
963
- * details API requests, populating the UUID cache to ~123 channels on a single page load. The script runs via evaluateOnNewDocument — it executes before
957
+ * details API requests, populating the UUID cache to ~123 channels on a single page load. The script runs via evaluateOnNewDocument - it executes before
964
958
  * any page JavaScript, patching window.fetch so Hulu's module-scoped fetch reference captures the interceptor. Each stream gets its own page via
965
959
  * createPageWithCapture(), so there's no persistence concern.
966
960
  */
@@ -969,7 +963,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
969
963
  // inject UUID+EAB via the fast path (resolving the held playlist) or release the hold before falling through to the click path.
970
964
  const attemptDirectTune = true;
971
965
  await page.evaluateOnNewDocument((initialUuid, initialEab, cachedUuids, cachedEabs, targetName, holdPlaylist) => {
972
- // evaluateOnNewDocument runs in every frame, including ad iframes. We only want to intercept fetches in the main frame — iframes don't make Hulu API
966
+ // evaluateOnNewDocument runs in every frame, including ad iframes. We only want to intercept fetches in the main frame - iframes don't make Hulu API
973
967
  // calls. The try/catch handles cross-origin iframes where accessing window.top throws a SecurityError.
974
968
  try {
975
969
  if (window.self !== window.top) {
@@ -991,24 +985,32 @@ async function resolveHuluDirectUrl(channelSelector, page) {
991
985
  // the click-based flow where the playlist should pass through immediately and capture the affiliate UUID via [HULU-CACHE].
992
986
  let holdActive = holdPlaylist;
993
987
  // Tracks whether the initial page-load playlist request has been seen. When holdActive is false (cold affiliate guide grid click fallback), the first live
994
- // playlist request carries the previously-playing channel's UUID — not the target's. We skip that one and only capture from subsequent requests, which
988
+ // playlist request carries the previously-playing channel's UUID - not the target's. We skip that one and only capture from subsequent requests, which
995
989
  // are triggered by the guide grid's play button click and carry the correct affiliate UUID.
996
990
  let initialPlaylistSeen = false;
997
991
  // eslint-disable-next-line no-console
998
992
  console.log("[HULU-DIAG] Fetch interceptor installed (" + ((uuid && eab) ? "warm" : "cold") + ")." + (uuid ? " uuid=" + uuid + " eab=" + (eab || "pending") : ""));
999
993
  // Promise that resolves when both UUID and EAB are available for the playlist swap. On warm cache, resolves immediately (both injected at install time).
1000
994
  // On cold cache, resolves when the in-page listing and details API response parsers have captured both values. The playlist handler awaits this Promise
1001
- // to hold the request until the target channel's data is ready.
995
+ // to hold the request until the target channel's data is ready. We use Promise.withResolvers() (Chrome 119+) for the deferred case so the resolver-extraction
996
+ // pattern matches inPageTimeout below rather than the older "new Promise(executor) + outer-scope variable" pattern.
1002
997
  let directTuneResolve = null;
1003
- const directTunePromise = (uuid && eab) ?
1004
- Promise.resolve() :
1005
- new Promise((resolve) => { directTuneResolve = resolve; });
998
+ let directTunePromise;
999
+ if (uuid && eab) {
1000
+ directTunePromise = Promise.resolve();
1001
+ }
1002
+ else {
1003
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Standard pattern for signal promises.
1004
+ const deferred = Promise.withResolvers();
1005
+ directTunePromise = deferred.promise;
1006
+ directTuneResolve = deferred.resolve;
1007
+ }
1006
1008
  // Tracks whether the direct tune has been successfully resolved (UUID+EAB available and playlist swap in progress). Set by tryResolveDirectTune when both
1007
1009
  // values are captured from API responses or injected externally. Warm cache starts resolved. Used by __prismcastIsDirectTuneResolved to let the guide grid
1008
1010
  // strategy detect that the interceptor already handled the tune autonomously, avoiding a redundant on-now cell click.
1009
1011
  let directTuneResolved = Boolean(uuid && eab);
1010
1012
  // Checks whether both UUID and EAB are now known and resolves directTunePromise if so. Called after each successful capture from listing or details API
1011
- // responses. Order-independent — handles both "listing first, details second" and "details first, listing second" sequences.
1013
+ // responses. Order-independent - handles both "listing first, details second" and "details first, listing second" sequences.
1012
1014
  function tryResolveDirectTune() {
1013
1015
  if (uuid && eab && directTuneResolve) {
1014
1016
  directTuneResolved = true;
@@ -1016,11 +1018,19 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1016
1018
  directTuneResolve = null;
1017
1019
  }
1018
1020
  }
1021
+ // Returns a Promise that resolves after the given delay. Used as the timeout side of Promise.race against a deferred Promise. Defined in-page because
1022
+ // node:timers/promises is not reachable from browser context, but Promise.withResolvers (Chrome 119+) is.
1023
+ async function inPageTimeout(ms) {
1024
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Standard pattern for signal promises.
1025
+ const { promise, resolve } = Promise.withResolvers();
1026
+ setTimeout(resolve, ms);
1027
+ return promise;
1028
+ }
1019
1029
  // Injection endpoint for the guide grid strategy's fast-path tune. After binary search identifies the target channel and the unified cache provides the
1020
1030
  // UUID and EAB, the strategy calls this function via page.evaluate to feed both values into the interceptor. The held playlist request then resumes with the
1021
1031
  // swapped channel_id and content_eab_id. Returns true if the injection was accepted (directTunePromise not yet resolved), false if the Promise already
1022
1032
  // resolved (self-resolution from API data, 8s timeout, or a previous injection).
1023
- window.__prismcastResolveDirectTune = (u, e) => {
1033
+ window["__prismcastResolveDirectTune"] = (u, e) => {
1024
1034
  if (!directTuneResolve) {
1025
1035
  return false;
1026
1036
  }
@@ -1032,7 +1042,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1032
1042
  // Release endpoint for the guide grid strategy's click-path fallback. When the fast-path injection can't proceed (UUID or EAB not in unified cache),
1033
1043
  // the strategy calls this to unblock the held playlist request and revert to the click-based flow. Sets holdActive to false so the playlist handler follows
1034
1044
  // the affiliate capture path ([HULU-CACHE]) on subsequent requests from the play button click.
1035
- window.__prismcastReleasePlaylist = () => {
1045
+ window["__prismcastReleasePlaylist"] = () => {
1036
1046
  holdActive = false;
1037
1047
  if (directTuneResolve) {
1038
1048
  directTuneResolve();
@@ -1040,39 +1050,38 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1040
1050
  }
1041
1051
  };
1042
1052
  // Query endpoint for the guide grid strategy to check if the interceptor has already resolved the direct tune. Returns true if the playlist was swapped
1043
- // (self-resolution from API data, external injection, or warm cache). The guide grid checks this after finding the target channel — if the interceptor
1053
+ // (self-resolution from API data, external injection, or warm cache). The guide grid checks this after finding the target channel - if the interceptor
1044
1054
  // already handled the tune, the on-now cell click is redundant and can be skipped.
1045
- window.__prismcastIsDirectTuneResolved = () => directTuneResolved;
1055
+ window["__prismcastIsDirectTuneResolved"] = () => directTuneResolved;
1046
1056
  // In-page EAB map built dynamically from listing API responses. On the first cold tune, cachedEabs is empty (no pre-computed data from Node.js), so this
1047
1057
  // map is the sole source of expansion data for details API requests. Populated asynchronously when the first listing response arrives.
1048
1058
  const capturedCurrentEabs = new Map();
1049
1059
  // Deferred Promise that resolves when captureListingData finishes parsing the first listing response. Details API requests await this Promise (with a 2s
1050
1060
  // timeout) before expanding, so even the very first details request gets expanded with listing-derived EABs. The listing response typically arrives
1051
- // ~200-600ms after the request fires, adding minimal latency to the details response — and the details API is not on the critical path for the channel
1061
+ // ~200-600ms after the request fires, adding minimal latency to the details response - and the details API is not on the critical path for the channel
1052
1062
  // grid that binary search needs (it only provides program info for the mini-guide overlay).
1053
- let listingCapturedResolve = null;
1054
- const listingCapturedPromise = new Promise((resolve) => {
1055
- listingCapturedResolve = resolve;
1056
- });
1063
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Standard pattern for signal promises.
1064
+ const { promise: listingCapturedPromise, resolve: signalListingCaptured } = Promise.withResolvers();
1065
+ let listingCaptureSignaled = false;
1057
1066
  // Fire-and-forget: parses a listing API response to build the in-page EAB map. For each channel, finds the currently-airing program by comparing airing
1058
1067
  // times against the current time, mirroring the server-side findCurrentEabFromPrograms() logic. Called on all listing return paths (expanded and passthrough).
1059
1068
  function captureListingData(response) {
1060
1069
  try {
1061
1070
  void response.clone().json().then((data) => {
1062
- const channels = data.channels;
1071
+ const channels = data["channels"];
1063
1072
  if (!Array.isArray(channels)) {
1064
1073
  return;
1065
1074
  }
1066
1075
  const now = Date.now();
1067
1076
  let captured = 0;
1068
1077
  for (const channel of channels) {
1069
- if ((typeof channel.id !== "string") || !Array.isArray(channel.programs)) {
1078
+ if ((typeof channel["id"] !== "string") || !Array.isArray(channel["programs"])) {
1070
1079
  continue;
1071
1080
  }
1072
- for (const program of channel.programs) {
1073
- if ((typeof program.eab === "string") && (typeof program.airingStart === "string") && (typeof program.airingEnd === "string")) {
1074
- if ((now >= new Date(program.airingStart).getTime()) && (now < new Date(program.airingEnd).getTime())) {
1075
- capturedCurrentEabs.set(channel.id, program.eab);
1081
+ for (const program of channel["programs"]) {
1082
+ if ((typeof program["eab"] === "string") && (typeof program["airingStart"] === "string") && (typeof program["airingEnd"] === "string")) {
1083
+ if ((now >= new Date(program["airingStart"]).getTime()) && (now < new Date(program["airingEnd"]).getTime())) {
1084
+ capturedCurrentEabs.set(channel["id"], program["eab"]);
1076
1085
  captured++;
1077
1086
  break;
1078
1087
  }
@@ -1084,7 +1093,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1084
1093
  console.log("[HULU-DIAG] Listing response captured: " + String(captured) + " current EABs for details expansion.");
1085
1094
  }
1086
1095
  // If the target UUID is already known (from a details response that arrived first), look up its EAB in the freshly captured listing data. This
1087
- // handles the "details first, listing second" ordering — the details parser set uuid but couldn't find the EAB yet.
1096
+ // handles the "details first, listing second" ordering - the details parser set uuid but couldn't find the EAB yet.
1088
1097
  if (uuid && !eab) {
1089
1098
  const capturedEab = capturedCurrentEabs.get(uuid);
1090
1099
  if (capturedEab) {
@@ -1093,18 +1102,18 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1093
1102
  }
1094
1103
  }
1095
1104
  // Signal that listing data is available. Any details request awaiting listingCapturedPromise will now proceed with expansion.
1096
- if (listingCapturedResolve) {
1097
- listingCapturedResolve();
1098
- listingCapturedResolve = null;
1105
+ if (!listingCaptureSignaled) {
1106
+ signalListingCaptured();
1107
+ listingCaptureSignaled = true;
1099
1108
  }
1100
1109
  }).catch(() => { });
1101
1110
  }
1102
1111
  catch {
1103
- // Response doesn't support clone() or json() — silently skip capture.
1112
+ // Response doesn't support clone() or json() - silently skip capture.
1104
1113
  }
1105
1114
  }
1106
1115
  // Fire-and-forget: parses a details API response to extract the target channel's UUID for cold direct tune. On warm cache (uuid already set at install
1107
- // time), this is a no-op — the UUID is already available. On cold cache, this is the primary mechanism for discovering the target's UUID: the expanded
1116
+ // time), this is a no-op - the UUID is already available. On cold cache, this is the primary mechanism for discovering the target's UUID: the expanded
1108
1117
  // details response contains channel_info for all ~123 channels, and we match by normalized name. After finding the UUID, looks up the EAB in the captured
1109
1118
  // listing data and calls tryResolveDirectTune to release the held playlist request.
1110
1119
  function captureDetailsData(response) {
@@ -1113,16 +1122,16 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1113
1122
  }
1114
1123
  try {
1115
1124
  void response.clone().json().then((data) => {
1116
- const items = data.items;
1125
+ const items = data["items"];
1117
1126
  if (!Array.isArray(items)) {
1118
1127
  return;
1119
1128
  }
1120
1129
  for (const item of items) {
1121
- const info = item.channel_info;
1122
- if (info && (typeof info.name === "string") && (typeof info.id === "string")) {
1123
- const name = info.name.trim().toLowerCase().replace(/\s+/g, " ");
1130
+ const info = item["channel_info"];
1131
+ if (info && (typeof info["name"] === "string") && (typeof info["id"] === "string")) {
1132
+ const name = info["name"].trim().toLowerCase().replace(/\s+/g, " ");
1124
1133
  if (name === targetName) {
1125
- uuid = info.id;
1134
+ uuid = info["id"];
1126
1135
  // eslint-disable-next-line no-console
1127
1136
  console.log("[HULU-DIAG] Details response: found UUID " + uuid + " for " + targetName + ".");
1128
1137
  // Look up the EAB for this UUID in already-captured listing data. If the listing response arrived before this details response, the EAB is
@@ -1139,7 +1148,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1139
1148
  }).catch(() => { });
1140
1149
  }
1141
1150
  catch {
1142
- // Response doesn't support clone() or json() — silently skip capture.
1151
+ // Response doesn't support clone() or json() - silently skip capture.
1143
1152
  }
1144
1153
  }
1145
1154
  // Extracts the request body from either the init options or a cloned Request object. Returns null if the body is not a string and the input is not a
@@ -1154,7 +1163,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1154
1163
  return null;
1155
1164
  }
1156
1165
  // Sends a fetch request with a modified body, reconstructing the request from a URL string when the input is a Request object. Using input.url as a
1157
- // string sidesteps the Fetch spec's body-lock check — getBodyText() locked the ReadableStream via input.clone().text(), and the spec checks lock status
1166
+ // string sidesteps the Fetch spec's body-lock check - getBodyText() locked the ReadableStream via input.clone().text(), and the spec checks lock status
1158
1167
  // on the first argument BEFORE applying init overrides, causing a TypeError even though we provide a replacement body.
1159
1168
  async function fetchWithBody(input, init, body) {
1160
1169
  if (input instanceof Request) {
@@ -1179,19 +1188,19 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1179
1188
  if (listingBody) {
1180
1189
  try {
1181
1190
  const listingObj = JSON.parse(listingBody);
1182
- if (Array.isArray(listingObj.channels)) {
1183
- const existing = new Set(listingObj.channels);
1191
+ if (Array.isArray(listingObj["channels"])) {
1192
+ const existing = new Set(listingObj["channels"]);
1184
1193
  for (const id of cachedUuids) {
1185
1194
  existing.add(id);
1186
1195
  }
1187
- listingObj.channels = [...existing];
1196
+ listingObj["channels"] = [...existing];
1188
1197
  const listingResponse = await fetchWithBody(input, init, JSON.stringify(listingObj));
1189
1198
  captureListingData(listingResponse);
1190
1199
  return listingResponse;
1191
1200
  }
1192
1201
  }
1193
1202
  catch {
1194
- // Body parse error — pass through unmodified.
1203
+ // Body parse error - pass through unmodified.
1195
1204
  }
1196
1205
  }
1197
1206
  const listingPassthrough = await originalFetch(input, init);
@@ -1201,20 +1210,20 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1201
1210
  // Expand details API requests to include EABs for all known channels. The mini-guide details request only covers ~7 visible channels (~19 EABs),
1202
1211
  // but we have EABs from two sources: pre-injected cachedEabs from Node.js (available on warm tunes and subsequent cold tunes), and dynamically
1203
1212
  // captured capturedCurrentEabs from the listing API response (the primary source on the first cold tune). Merging both ensures the details response
1204
- // returns channel_info (name→UUID mappings) for all ~123 channels on any tune, even the very first cold tune after a restart.
1213
+ // returns channel_info (name->UUID mappings) for all ~123 channels on any tune, even the very first cold tune after a restart.
1205
1214
  if (url.includes("guide.hulu.com") && url.includes("/guide/details")) {
1206
1215
  // If the in-page EAB map is empty and no pre-injected EABs are available, wait for the listing response to be parsed before proceeding. This holds
1207
- // the first details request for ~200-600ms until captureListingData resolves the Promise. The 2s timeout is a safety net — if the listing response
1216
+ // the first details request for ~200-600ms until captureListingData resolves the Promise. The 2s timeout is a safety net - if the listing response
1208
1217
  // never arrives, the details request proceeds without expansion (same behavior as before).
1209
1218
  if ((capturedCurrentEabs.size === 0) && (cachedEabs.length === 0)) {
1210
- await Promise.race([listingCapturedPromise, new Promise((resolve) => { setTimeout(resolve, 2000); })]);
1219
+ await Promise.race([listingCapturedPromise, inPageTimeout(2000)]);
1211
1220
  }
1212
1221
  const detailsBody = await getBodyText(input, init);
1213
1222
  if (detailsBody) {
1214
1223
  try {
1215
1224
  const detailsObj = JSON.parse(detailsBody);
1216
- if (Array.isArray(detailsObj.eabs)) {
1217
- const existing = new Set(detailsObj.eabs);
1225
+ if (Array.isArray(detailsObj["eabs"])) {
1226
+ const existing = new Set(detailsObj["eabs"]);
1218
1227
  // Add pre-injected EABs from Node.js cache (available on warm tunes and subsequent cold tunes with partial cache data).
1219
1228
  for (const id of cachedEabs) {
1220
1229
  existing.add(id);
@@ -1223,14 +1232,14 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1223
1232
  for (const capturedEab of capturedCurrentEabs.values()) {
1224
1233
  existing.add(capturedEab);
1225
1234
  }
1226
- detailsObj.eabs = [...existing];
1235
+ detailsObj["eabs"] = [...existing];
1227
1236
  const expandedResponse = await fetchWithBody(input, init, JSON.stringify(detailsObj));
1228
1237
  captureDetailsData(expandedResponse);
1229
1238
  return expandedResponse;
1230
1239
  }
1231
1240
  }
1232
1241
  catch {
1233
- // Body parse error — pass through unmodified.
1242
+ // Body parse error - pass through unmodified.
1234
1243
  }
1235
1244
  }
1236
1245
  const detailsPassthrough = await originalFetch(input, init);
@@ -1246,7 +1255,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1246
1255
  // interceptor returns a 503 (safety net). When holdActive is false (set by __prismcastReleasePlaylist after fast-path failure), the playlist passes
1247
1256
  // through with affiliate UUID capture for the click fallback.
1248
1257
  if (holdActive) {
1249
- await Promise.race([directTunePromise, new Promise((resolve) => { setTimeout(resolve, 8000); })]);
1258
+ await Promise.race([directTunePromise, inPageTimeout(8000)]);
1250
1259
  }
1251
1260
  if (!uuid || !eab) {
1252
1261
  // Affiliate guide grid click fallback: holdActive was set to false by __prismcastReleasePlaylist, or was never enabled. The guide grid handles channel
@@ -1257,18 +1266,18 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1257
1266
  if (affiliateBody) {
1258
1267
  try {
1259
1268
  const bodyObj = JSON.parse(affiliateBody);
1260
- if ((bodyObj.play_intent === "live") && (typeof bodyObj.channel_id === "string")) {
1269
+ if ((bodyObj["play_intent"] === "live") && (typeof bodyObj["channel_id"] === "string")) {
1261
1270
  if (initialPlaylistSeen) {
1262
1271
  // eslint-disable-next-line no-console
1263
- console.log("[HULU-CACHE] " + targetName + "=" + bodyObj.channel_id);
1272
+ console.log("[HULU-CACHE] " + targetName + "=" + bodyObj["channel_id"]);
1264
1273
  }
1265
1274
  initialPlaylistSeen = true;
1266
1275
  }
1267
1276
  }
1268
1277
  catch {
1269
- // Body parse error — skip capture.
1278
+ // Body parse error - skip capture.
1270
1279
  }
1271
- // Must use fetchWithBody — getBodyText may have locked the Request's ReadableStream via clone().
1280
+ // Must use fetchWithBody - getBodyText may have locked the Request's ReadableStream via clone().
1272
1281
  return fetchWithBody(input, init, affiliateBody);
1273
1282
  }
1274
1283
  return originalFetch(input, init);
@@ -1281,19 +1290,17 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1281
1290
  if (bodyText) {
1282
1291
  try {
1283
1292
  const bodyObj = JSON.parse(bodyText);
1284
- if (bodyObj.play_intent === "live") {
1285
- const originalChannelId = String(bodyObj.channel_id);
1286
- // eslint-disable-next-line camelcase
1287
- bodyObj.channel_id = uuid;
1288
- // eslint-disable-next-line camelcase
1289
- bodyObj.content_eab_id = eab;
1293
+ if (bodyObj["play_intent"] === "live") {
1294
+ const originalChannelId = String(bodyObj["channel_id"]);
1295
+ bodyObj["channel_id"] = uuid;
1296
+ bodyObj["content_eab_id"] = eab;
1290
1297
  // eslint-disable-next-line no-console
1291
1298
  console.log("[HULU-DIAG] Playlist swapped: channel_id " + originalChannelId + " -> " + uuid + ", eab -> " + eab);
1292
1299
  return await fetchWithBody(input, init, JSON.stringify(bodyObj));
1293
1300
  }
1294
1301
  }
1295
1302
  catch {
1296
- // Body parse error — pass through unmodified.
1303
+ // Body parse error - pass through unmodified.
1297
1304
  }
1298
1305
  }
1299
1306
  }
@@ -1305,7 +1312,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1305
1312
  LOG.debug("tuning:hulu", "Failed to install Hulu fetch interceptor: %s.", formatError(error));
1306
1313
  return null;
1307
1314
  }
1308
- // On cold cache (no UUID), fall through to the guide grid. The initial page load only provides Details API data for ~10 visible channels — the guide grid's
1315
+ // On cold cache (no UUID), fall through to the guide grid. The initial page load only provides Details API data for ~10 visible channels - the guide grid's
1309
1316
  // Channels tab click triggers full expansion for all ~123 channels, and the interceptor captures UUID+EAB from those expanded responses to resolve the held
1310
1317
  // playlist. Affiliates additionally need position-based inference because their channel_info.name uses call signs rather than network names.
1311
1318
  if (!cachedUuid) {
@@ -1341,7 +1348,7 @@ async function discoverHuluChannels(page) {
1341
1348
  return buildHuluDiscoveredChannels();
1342
1349
  }
1343
1350
  // Set up response interception BEFORE navigation so we capture the initial details and listing API responses during page load. These responses populate the
1344
- // unified channel cache with UUID, programs, and display names for all ~130 channels — warming the tuning cache as a side effect of discovery. The same
1351
+ // unified channel cache with UUID, programs, and display names for all ~130 channels - warming the tuning cache as a side effect of discovery. The same
1345
1352
  // setupDetailsResponseInterception function used by the tuning path ensures a single code path for all API response processing.
1346
1353
  setupDetailsResponseInterception(page);
1347
1354
  try {
@@ -1350,7 +1357,7 @@ async function discoverHuluChannels(page) {
1350
1357
  catch {
1351
1358
  return [];
1352
1359
  }
1353
- // Click the Channels tab to reveal the channel list and trigger full API expansion. Matches the tuning path's retry logic — if guide rows don't appear after
1360
+ // Click the Channels tab to reveal the channel list and trigger full API expansion. Matches the tuning path's retry logic - if guide rows don't appear after
1354
1361
  // the first tab click, retry once with a longer delay in case the first click fired during a transitional state before the guide was fully interactive.
1355
1362
  const listSelector = "#CHANNELS";
1356
1363
  try {
@@ -1415,8 +1422,8 @@ async function discoverHuluChannels(page) {
1415
1422
  // Reassign sequential domIndex values across the full accumulated list so that inferLocalAffiliate's position-based logic works correctly. The original
1416
1423
  // domIndex values are from individual scroll windows (0-12) and are not meaningful across the full channel list.
1417
1424
  const indexedChannels = allChannels.map((ch, i) => ({ ...ch, domIndex: i }));
1418
- // Build a callSign → networkName map by reusing inferLocalAffiliate for each broadcast network. This is the same position-based inference the tuning strategy
1419
- // uses during binary search — a call sign channel occupies the alphabetical position where its network name would sort.
1425
+ // Build a callSign -> networkName map by reusing inferLocalAffiliate for each broadcast network. This is the same position-based inference the tuning strategy
1426
+ // uses during binary search - a call sign channel occupies the alphabetical position where its network name would sort.
1420
1427
  const affiliateMap = new Map();
1421
1428
  for (const network of NETWORK_NAMES_WITH_AFFILIATES) {
1422
1429
  const callSign = inferLocalAffiliate(indexedChannels, network);
@@ -1447,7 +1454,7 @@ async function discoverHuluChannels(page) {
1447
1454
  }
1448
1455
  return { channelSelector: ch.displayName, name: ch.displayName };
1449
1456
  });
1450
- // Do not cache empty results — leave the flag false so subsequent calls retry the full walk. Empty results can indicate no Hulu + Live TV subscription.
1457
+ // Do not cache empty results - leave the flag false so subsequent calls retry the full walk. Empty results can indicate no Hulu + Live TV subscription.
1451
1458
  if (discovered.length > 0) {
1452
1459
  discovered.sort((a, b) => a.name.localeCompare(b.name));
1453
1460
  huluFullyDiscovered = true;