prismcast 1.9.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 (360) hide show
  1. package/dist/app.d.ts +1 -1
  2. package/dist/app.js +27 -9
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.js +7 -7
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +41 -11
  7. package/dist/browser/channelSelection.js +75 -24
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/display.d.ts +1 -1
  10. package/dist/browser/index.d.ts +8 -8
  11. package/dist/browser/index.js +42 -43
  12. package/dist/browser/index.js.map +1 -1
  13. package/dist/browser/login.d.ts +2 -2
  14. package/dist/browser/login.js +1 -1
  15. package/dist/browser/manifestInterceptor.d.ts +77 -0
  16. package/dist/browser/manifestInterceptor.js +323 -0
  17. package/dist/browser/manifestInterceptor.js.map +1 -0
  18. package/dist/browser/precaching.js +4 -4
  19. package/dist/browser/tuning/comcastPolymer.d.ts +1 -1
  20. package/dist/browser/tuning/cox.d.ts +1 -1
  21. package/dist/browser/tuning/directv.d.ts +1 -1
  22. package/dist/browser/tuning/directv.js +61 -62
  23. package/dist/browser/tuning/directv.js.map +1 -1
  24. package/dist/browser/tuning/fox.d.ts +1 -1
  25. package/dist/browser/tuning/fox.js +171 -14
  26. package/dist/browser/tuning/fox.js.map +1 -1
  27. package/dist/browser/tuning/hbo.d.ts +1 -1
  28. package/dist/browser/tuning/hbo.js +6 -6
  29. package/dist/browser/tuning/hbo.js.map +1 -1
  30. package/dist/browser/tuning/hulu.d.ts +1 -1
  31. package/dist/browser/tuning/hulu.js +117 -110
  32. package/dist/browser/tuning/hulu.js.map +1 -1
  33. package/dist/browser/tuning/shared.d.ts +3 -3
  34. package/dist/browser/tuning/shared.js +5 -5
  35. package/dist/browser/tuning/sling.d.ts +1 -1
  36. package/dist/browser/tuning/sling.js +28 -28
  37. package/dist/browser/tuning/sling.js.map +1 -1
  38. package/dist/browser/tuning/spectrum.d.ts +1 -1
  39. package/dist/browser/tuning/spectrum.js +21 -22
  40. package/dist/browser/tuning/spectrum.js.map +1 -1
  41. package/dist/browser/tuning/thumbnailRow.d.ts +1 -1
  42. package/dist/browser/tuning/thumbnailRow.js +1 -1
  43. package/dist/browser/tuning/tileClick.d.ts +1 -1
  44. package/dist/browser/tuning/tileClick.js +5 -5
  45. package/dist/browser/tuning/xfinity.d.ts +1 -1
  46. package/dist/browser/tuning/youtubeTv.d.ts +1 -1
  47. package/dist/browser/tuning/youtubeTv.js +17 -18
  48. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  49. package/dist/browser/video.d.ts +20 -6
  50. package/dist/browser/video.js +30 -38
  51. package/dist/browser/video.js.map +1 -1
  52. package/dist/channels/index.d.ts +1 -2
  53. package/dist/channels/index.js +73 -40
  54. package/dist/channels/index.js.map +1 -1
  55. package/dist/config/channelForm.d.ts +58 -0
  56. package/dist/config/channelForm.js +129 -0
  57. package/dist/config/channelForm.js.map +1 -0
  58. package/dist/config/consistencyProbe.d.ts +7 -0
  59. package/dist/config/consistencyProbe.js +143 -0
  60. package/dist/config/consistencyProbe.js.map +1 -0
  61. package/dist/config/health.d.ts +4 -3
  62. package/dist/config/health.js +75 -62
  63. package/dist/config/health.js.map +1 -1
  64. package/dist/config/index.d.ts +1 -1
  65. package/dist/config/index.js +19 -12
  66. package/dist/config/index.js.map +1 -1
  67. package/dist/config/paths.d.ts +9 -3
  68. package/dist/config/paths.js +12 -4
  69. package/dist/config/paths.js.map +1 -1
  70. package/dist/config/persistence.context.d.ts +8 -0
  71. package/dist/config/persistence.context.js +30 -0
  72. package/dist/config/persistence.context.js.map +1 -0
  73. package/dist/config/persistence.d.ts +110 -12
  74. package/dist/config/persistence.js +351 -32
  75. package/dist/config/persistence.js.map +1 -1
  76. package/dist/config/presets.d.ts +14 -4
  77. package/dist/config/presets.js +84 -75
  78. package/dist/config/presets.js.map +1 -1
  79. package/dist/config/profiles.d.ts +2 -2
  80. package/dist/config/profiles.js +7 -9
  81. package/dist/config/profiles.js.map +1 -1
  82. package/dist/config/servicePacks.d.ts +2 -2
  83. package/dist/config/servicePacks.js +28 -28
  84. package/dist/config/servicePacks.js.map +1 -1
  85. package/dist/config/services.d.ts +62 -36
  86. package/dist/config/services.js +150 -136
  87. package/dist/config/services.js.map +1 -1
  88. package/dist/config/sites.d.ts +3 -3
  89. package/dist/config/sites.js +16 -16
  90. package/dist/config/sites.js.map +1 -1
  91. package/dist/config/userChannels.d.ts +500 -41
  92. package/dist/config/userChannels.js +1201 -312
  93. package/dist/config/userChannels.js.map +1 -1
  94. package/dist/config/userConfig.d.ts +103 -10
  95. package/dist/config/userConfig.js +277 -117
  96. package/dist/config/userConfig.js.map +1 -1
  97. package/dist/config/userProfiles.d.ts +4 -2
  98. package/dist/config/userProfiles.js +72 -26
  99. package/dist/config/userProfiles.js.map +1 -1
  100. package/dist/hdhr/channelMap.js +8 -11
  101. package/dist/hdhr/channelMap.js.map +1 -1
  102. package/dist/hdhr/deviceId.js +10 -9
  103. package/dist/hdhr/deviceId.js.map +1 -1
  104. package/dist/hdhr/discover.js +4 -5
  105. package/dist/hdhr/discover.js.map +1 -1
  106. package/dist/hdhr/index.js +6 -10
  107. package/dist/hdhr/index.js.map +1 -1
  108. package/dist/index.js +12 -12
  109. package/dist/index.js.map +1 -1
  110. package/dist/native/codecInference.d.ts +44 -0
  111. package/dist/native/codecInference.js +327 -0
  112. package/dist/native/codecInference.js.map +1 -0
  113. package/dist/native/decrypt.d.ts +1 -1
  114. package/dist/native/index.d.ts +5 -5
  115. package/dist/native/index.js +42 -64
  116. package/dist/native/index.js.map +1 -1
  117. package/dist/native/probe.d.ts +27 -9
  118. package/dist/native/probe.js +158 -67
  119. package/dist/native/probe.js.map +1 -1
  120. package/dist/native/proxy.d.ts +4 -2
  121. package/dist/native/proxy.js +52 -49
  122. package/dist/native/proxy.js.map +1 -1
  123. package/dist/native/tokenExpiry.d.ts +22 -0
  124. package/dist/native/tokenExpiry.js +153 -0
  125. package/dist/native/tokenExpiry.js.map +1 -0
  126. package/dist/routes/auth.js +6 -10
  127. package/dist/routes/auth.js.map +1 -1
  128. package/dist/routes/components.js +2 -2
  129. package/dist/routes/components.js.map +1 -1
  130. package/dist/routes/config/channels/endpoints/browse.d.ts +6 -0
  131. package/dist/routes/config/channels/endpoints/browse.js +195 -0
  132. package/dist/routes/config/channels/endpoints/browse.js.map +1 -0
  133. package/dist/routes/config/channels/endpoints/bulk.d.ts +6 -0
  134. package/dist/routes/config/channels/endpoints/bulk.js +128 -0
  135. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -0
  136. package/dist/routes/config/channels/endpoints/crud.d.ts +6 -0
  137. package/dist/routes/config/channels/endpoints/crud.js +504 -0
  138. package/dist/routes/config/channels/endpoints/crud.js.map +1 -0
  139. package/dist/routes/config/channels/endpoints/importExport.d.ts +6 -0
  140. package/dist/routes/config/channels/endpoints/importExport.js +131 -0
  141. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -0
  142. package/dist/routes/config/channels/endpoints/predefined.d.ts +6 -0
  143. package/dist/routes/config/channels/endpoints/predefined.js +78 -0
  144. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -0
  145. package/dist/routes/config/channels/endpoints/prefs.d.ts +6 -0
  146. package/dist/routes/config/channels/endpoints/prefs.js +58 -0
  147. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -0
  148. package/dist/routes/config/channels/endpoints/service.d.ts +6 -0
  149. package/dist/routes/config/channels/endpoints/service.js +149 -0
  150. package/dist/routes/config/channels/endpoints/service.js.map +1 -0
  151. package/dist/routes/config/channels/endpoints/tags.d.ts +6 -0
  152. package/dist/routes/config/channels/endpoints/tags.js +185 -0
  153. package/dist/routes/config/channels/endpoints/tags.js.map +1 -0
  154. package/dist/routes/config/channels/http/handler.d.ts +10 -0
  155. package/dist/routes/config/channels/http/handler.js +20 -0
  156. package/dist/routes/config/channels/http/handler.js.map +1 -0
  157. package/dist/routes/config/channels/http/playlistHint.d.ts +25 -0
  158. package/dist/routes/config/channels/http/playlistHint.js +40 -0
  159. package/dist/routes/config/channels/http/playlistHint.js.map +1 -0
  160. package/dist/routes/config/channels/http/serviceWarning.d.ts +10 -0
  161. package/dist/routes/config/channels/http/serviceWarning.js +28 -0
  162. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -0
  163. package/dist/routes/config/channels/index.d.ts +3 -3
  164. package/dist/routes/config/channels/index.js +1 -1
  165. package/dist/routes/config/channels/index.js.map +1 -1
  166. package/dist/routes/config/channels/{routes.d.ts → setup.d.ts} +1 -1
  167. package/dist/routes/config/channels/setup.js +23 -0
  168. package/dist/routes/config/channels/setup.js.map +1 -0
  169. package/dist/routes/config/channels/table.d.ts +9 -9
  170. package/dist/routes/config/channels/table.js +124 -76
  171. package/dist/routes/config/channels/table.js.map +1 -1
  172. package/dist/routes/config/http/envelope.d.ts +111 -0
  173. package/dist/routes/config/http/envelope.js +103 -0
  174. package/dist/routes/config/http/envelope.js.map +1 -0
  175. package/dist/routes/config/index.d.ts +9 -9
  176. package/dist/routes/config/index.js +2 -2
  177. package/dist/routes/config/index.js.map +1 -1
  178. package/dist/routes/config/services.js +69 -68
  179. package/dist/routes/config/services.js.map +1 -1
  180. package/dist/routes/config/settings.d.ts +1 -1
  181. package/dist/routes/config/settings.js +45 -38
  182. package/dist/routes/config/settings.js.map +1 -1
  183. package/dist/routes/debug.js +12 -11
  184. package/dist/routes/debug.js.map +1 -1
  185. package/dist/routes/icons.js +2 -2
  186. package/dist/routes/icons.js.map +1 -1
  187. package/dist/routes/logs.js +19 -30
  188. package/dist/routes/logs.js.map +1 -1
  189. package/dist/routes/mpegts.js +1 -1
  190. package/dist/routes/playlist.d.ts +1 -1
  191. package/dist/routes/playlist.js +37 -26
  192. package/dist/routes/playlist.js.map +1 -1
  193. package/dist/routes/root/content.js +27 -6
  194. package/dist/routes/root/content.js.map +1 -1
  195. package/dist/routes/root/index.js +2 -5
  196. package/dist/routes/root/index.js.map +1 -1
  197. package/dist/routes/root/scripts/channels.js +16 -16
  198. package/dist/routes/root/scripts/channels.js.map +1 -1
  199. package/dist/routes/root/scripts/config.js +59 -59
  200. package/dist/routes/root/scripts/config.js.map +1 -1
  201. package/dist/routes/root/scripts/index.d.ts +4 -4
  202. package/dist/routes/root/scripts/shared.js +24 -10
  203. package/dist/routes/root/scripts/shared.js.map +1 -1
  204. package/dist/routes/root/scripts/status.d.ts +4 -0
  205. package/dist/routes/root/scripts/status.handlers.d.ts +182 -0
  206. package/dist/routes/root/scripts/status.handlers.js +713 -0
  207. package/dist/routes/root/scripts/status.handlers.js.map +1 -0
  208. package/dist/routes/root/scripts/status.js +86 -488
  209. package/dist/routes/root/scripts/status.js.map +1 -1
  210. package/dist/routes/root/styles.js +5 -5
  211. package/dist/routes/services.js +18 -15
  212. package/dist/routes/services.js.map +1 -1
  213. package/dist/routes/sse.d.ts +16 -0
  214. package/dist/routes/sse.js +29 -0
  215. package/dist/routes/sse.js.map +1 -0
  216. package/dist/routes/streams.js +10 -23
  217. package/dist/routes/streams.js.map +1 -1
  218. package/dist/routes/ui.d.ts +2 -2
  219. package/dist/routes/upgrade.js +6 -7
  220. package/dist/routes/upgrade.js.map +1 -1
  221. package/dist/service/commands.context.d.ts +6 -0
  222. package/dist/service/commands.context.js +47 -0
  223. package/dist/service/commands.context.js.map +1 -0
  224. package/dist/service/commands.d.ts +53 -8
  225. package/dist/service/commands.js +171 -188
  226. package/dist/service/commands.js.map +1 -1
  227. package/dist/service/generators.context.d.ts +6 -0
  228. package/dist/service/generators.context.js +45 -0
  229. package/dist/service/generators.context.js.map +1 -0
  230. package/dist/service/generators.d.ts +56 -16
  231. package/dist/service/generators.js +499 -383
  232. package/dist/service/generators.js.map +1 -1
  233. package/dist/service/index.d.ts +2 -2
  234. package/dist/streaming/clients.d.ts +2 -2
  235. package/dist/streaming/clients.js +2 -2
  236. package/dist/streaming/codec.d.ts +2 -2
  237. package/dist/streaming/codec.js +0 -1
  238. package/dist/streaming/codec.js.map +1 -1
  239. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  240. package/dist/streaming/fmp4Segmenter.js +12 -12
  241. package/dist/streaming/hls.d.ts +3 -3
  242. package/dist/streaming/hls.js +81 -70
  243. package/dist/streaming/hls.js.map +1 -1
  244. package/dist/streaming/hlsResume.d.ts +3 -3
  245. package/dist/streaming/hlsResume.js +5 -5
  246. package/dist/streaming/hlsSegments.d.ts +5 -2
  247. package/dist/streaming/hlsSegments.js +14 -9
  248. package/dist/streaming/hlsSegments.js.map +1 -1
  249. package/dist/streaming/monitor.d.ts +12 -12
  250. package/dist/streaming/monitor.js +80 -54
  251. package/dist/streaming/monitor.js.map +1 -1
  252. package/dist/streaming/mp4Parser.d.ts +34 -4
  253. package/dist/streaming/mp4Parser.js +111 -29
  254. package/dist/streaming/mp4Parser.js.map +1 -1
  255. package/dist/streaming/mpegts.d.ts +1 -1
  256. package/dist/streaming/mpegts.js +13 -10
  257. package/dist/streaming/mpegts.js.map +1 -1
  258. package/dist/streaming/playlistBuilder.d.ts +1 -1
  259. package/dist/streaming/playlistBuilder.js +2 -2
  260. package/dist/streaming/preroll.d.ts +9 -9
  261. package/dist/streaming/preroll.js +39 -41
  262. package/dist/streaming/preroll.js.map +1 -1
  263. package/dist/streaming/pretune.js +4 -4
  264. package/dist/streaming/pretune.js.map +1 -1
  265. package/dist/streaming/recovery.d.ts +3 -3
  266. package/dist/streaming/recovery.js +4 -4
  267. package/dist/streaming/registry.d.ts +6 -6
  268. package/dist/streaming/setup.d.ts +9 -8
  269. package/dist/streaming/setup.js +97 -49
  270. package/dist/streaming/setup.js.map +1 -1
  271. package/dist/streaming/showInfo.d.ts +9 -3
  272. package/dist/streaming/showInfo.js +26 -18
  273. package/dist/streaming/showInfo.js.map +1 -1
  274. package/dist/streaming/statusEmitter.d.ts +4 -4
  275. package/dist/streaming/statusEmitter.js +2 -2
  276. package/dist/types/channels.d.ts +133 -34
  277. package/dist/types/channels.js +39 -1
  278. package/dist/types/channels.js.map +1 -1
  279. package/dist/types/config.d.ts +20 -1
  280. package/dist/types/index.d.ts +9 -8
  281. package/dist/types/index.js +1 -0
  282. package/dist/types/index.js.map +1 -1
  283. package/dist/types/profiles.d.ts +5 -4
  284. package/dist/types/selection.d.ts +48 -4
  285. package/dist/types/streaming.d.ts +1 -1
  286. package/dist/upgrade/commands.context.d.ts +6 -0
  287. package/dist/upgrade/commands.context.js +29 -0
  288. package/dist/upgrade/commands.context.js.map +1 -0
  289. package/dist/upgrade/commands.d.ts +29 -2
  290. package/dist/upgrade/commands.js +87 -134
  291. package/dist/upgrade/commands.js.map +1 -1
  292. package/dist/upgrade/detection.context.d.ts +6 -0
  293. package/dist/upgrade/detection.context.js +24 -0
  294. package/dist/upgrade/detection.context.js.map +1 -0
  295. package/dist/upgrade/detection.d.ts +140 -10
  296. package/dist/upgrade/detection.js +153 -66
  297. package/dist/upgrade/detection.js.map +1 -1
  298. package/dist/upgrade/index.d.ts +2 -2
  299. package/dist/utils/chromeFetch.d.ts +1 -1
  300. package/dist/utils/chromeFetch.js +1 -1
  301. package/dist/utils/clock.d.ts +14 -0
  302. package/dist/utils/clock.js +28 -0
  303. package/dist/utils/clock.js.map +1 -0
  304. package/dist/utils/debugFilter.d.ts +1 -1
  305. package/dist/utils/debugFilter.js +6 -4
  306. package/dist/utils/debugFilter.js.map +1 -1
  307. package/dist/utils/delay.d.ts +2 -3
  308. package/dist/utils/delay.js +10 -16
  309. package/dist/utils/delay.js.map +1 -1
  310. package/dist/utils/evaluate.js +12 -14
  311. package/dist/utils/evaluate.js.map +1 -1
  312. package/dist/utils/ffmpeg.context.d.ts +6 -0
  313. package/dist/utils/ffmpeg.context.js +31 -0
  314. package/dist/utils/ffmpeg.context.js.map +1 -0
  315. package/dist/utils/ffmpeg.d.ts +85 -29
  316. package/dist/utils/ffmpeg.js +126 -129
  317. package/dist/utils/ffmpeg.js.map +1 -1
  318. package/dist/utils/fileLogger.d.ts +18 -2
  319. package/dist/utils/fileLogger.js +58 -30
  320. package/dist/utils/fileLogger.js.map +1 -1
  321. package/dist/utils/format.d.ts +1 -1
  322. package/dist/utils/format.js +2 -2
  323. package/dist/utils/format.js.map +1 -1
  324. package/dist/utils/html.js +1 -3
  325. package/dist/utils/html.js.map +1 -1
  326. package/dist/utils/index.d.ts +21 -20
  327. package/dist/utils/index.js +1 -0
  328. package/dist/utils/index.js.map +1 -1
  329. package/dist/utils/logger.js +16 -20
  330. package/dist/utils/logger.js.map +1 -1
  331. package/dist/utils/m3u.d.ts +18 -3
  332. package/dist/utils/m3u.js +28 -14
  333. package/dist/utils/m3u.js.map +1 -1
  334. package/dist/utils/memo.d.ts +19 -0
  335. package/dist/utils/memo.js +53 -0
  336. package/dist/utils/memo.js.map +1 -0
  337. package/dist/utils/morganStream.js +2 -2
  338. package/dist/utils/morganStream.js.map +1 -1
  339. package/dist/utils/pid.d.ts +2 -2
  340. package/dist/utils/pid.js +2 -2
  341. package/dist/utils/platform.d.ts +1 -1
  342. package/dist/utils/platform.js +3 -3
  343. package/dist/utils/platform.js.map +1 -1
  344. package/dist/utils/retry.d.ts +2 -0
  345. package/dist/utils/retry.js +9 -7
  346. package/dist/utils/retry.js.map +1 -1
  347. package/dist/utils/sanitize.d.ts +2 -2
  348. package/dist/utils/sanitize.js +4 -4
  349. package/dist/utils/timing.d.ts +6 -3
  350. package/dist/utils/timing.js +10 -6
  351. package/dist/utils/timing.js.map +1 -1
  352. package/dist/utils/version.d.ts +3 -2
  353. package/dist/utils/version.js +14 -27
  354. package/dist/utils/version.js.map +1 -1
  355. package/package.json +13 -7
  356. package/dist/native/intercept.d.ts +0 -35
  357. package/dist/native/intercept.js +0 -181
  358. package/dist/native/intercept.js.map +0 -1
  359. package/dist/routes/config/channels/routes.js +0 -1505
  360. package/dist/routes/config/channels/routes.js.map +0 -1
@@ -1,17 +1,15 @@
1
- /* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
- *
3
- * services.ts: Service group management for multi-service channels.
4
- */
5
- import { CHANNEL_IDENTITY_FIELDS, PREDEFINED_CHANNELS } from "../channels/index.js";
6
1
  import { DOMAIN_CONFIG, getDomainConfig } from "./sites.js";
7
2
  import { LOG, extractDomain } from "../utils/index.js";
8
- import { getChannelEffectiveTags } from "./userChannels.js";
3
+ import { getChannelEffectiveTags, getEffectiveHdhrEnabled, mutateChannels, pickIdentity } from "./userChannels.js";
4
+ import { CONFIG } from "./index.js";
5
+ import { PREDEFINED_CHANNELS } from "../channels/index.js";
9
6
  import { getProfileForChannel } from "./profiles.js";
10
7
  import { getUserDomains } from "./userProfiles.js";
8
+ import { mutateConfig } from "./userConfig.js";
11
9
  /* Service groups allow multiple streaming services to offer the same content. For example, ESPN can be watched via ESPN.com (native) or Disney+.
12
10
  *
13
- * All variant relationships — predefined and user-defined — are expressed via the canonicalKey field on Channel. The flattener sets canonicalKey on predefined
14
- * variant entries, the browse modal sets it on user variant entries, and the one-time migration stamps it on pre-existing user entries. buildServiceGroups
11
+ * All variant relationships - predefined and user-defined - are expressed via the canonicalKey field on Channel. The flattener sets canonicalKey on predefined
12
+ * variant entries, the browse modal sets it on user variant entries, and the schema-version migration stamps it on entries that lack it. buildServiceGroups
15
13
  * scans all channels once and groups by canonicalKey. One field, one mechanism, one code path.
16
14
  *
17
15
  * User overrides: When a user defines a channel with the same key as a predefined channel, both versions appear in the service dropdown. The user's custom version
@@ -21,11 +19,12 @@ import { getUserDomains } from "./userProfiles.js";
21
19
  * User selections are stored in channels.json (in the data directory) under the `serviceSelections` key and persist across restarts.
22
20
  */
23
21
  // Suffix appended to channel keys to reference the original predefined channel when a user has overridden it. For example, "espn:predefined" references the original
24
- // predefined ESPN channel when the user has created a custom "espn" entry.
25
- const PREDEFINED_SUFFIX = ":predefined";
22
+ // predefined ESPN channel when the user has created a custom "espn" entry. Exported so other modules (e.g., channelForm) can detect synthetic entries via the
23
+ // canonical constant instead of stringly-typed substring checks.
24
+ export const PREDEFINED_SUFFIX = ":predefined";
26
25
  /**
27
26
  * Strips the :predefined suffix from a channel key if present, returning the base key. Synthetic keys like "pbs:predefined" are created when a user overrides a
28
- * predefined channel — the original predefined entry gets this suffix to coexist with the user's custom version in the service dropdown. Functions that look up
27
+ * predefined channel - the original predefined entry gets this suffix to coexist with the user's custom version in the service dropdown. Functions that look up
29
28
  * channel data by key must strip the suffix to find the actual channel entry.
30
29
  * @param key - The channel key, possibly with :predefined suffix.
31
30
  * @returns The base key without the suffix.
@@ -35,7 +34,8 @@ function stripPredefinedSuffix(key) {
35
34
  }
36
35
  // Module-level storage for service groups, keyed by canonical channel key.
37
36
  const serviceGroups = new Map();
38
- // Reference to the channels map for inheritance resolution.
37
+ // Reference to the resolved channels map (post canonical+variant inheritance). All consumer reads go through here, so it stores ResolvedChannel values where
38
+ // every entry has both identity and binding populated.
39
39
  let channelsRef = {};
40
40
  // User's service selections, keyed by canonical channel key. Values are the selected service key (e.g., "espn-disneyplus").
41
41
  let serviceSelections = new Map();
@@ -63,7 +63,7 @@ function resolveServiceTag(channel) {
63
63
  /**
64
64
  * Gets the service tag for a channel key. For channels in a service group, reads the pre-computed tag from the group variant entry (computed at group-building
65
65
  * time by buildServiceGroups). For standalone channels not in any group, derives the tag from the channel's URL domain. This function should not be called with
66
- * :predefined synthetic keys — those only exist inside service groups and their tags are available via the group's variant entries.
66
+ * :predefined synthetic keys - those only exist inside service groups and their tags are available via the group's variant entries.
67
67
  * @param key - The channel key.
68
68
  * @returns The service tag string.
69
69
  */
@@ -79,14 +79,13 @@ export function getServiceTagForChannel(key) {
79
79
  }
80
80
  // For standalone channels not in any group, derive from the channel's URL domain.
81
81
  const channel = channelsRef[effectiveKey] ?? PREDEFINED_CHANNELS[effectiveKey];
82
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
83
82
  if (!channel) {
84
83
  return "direct";
85
84
  }
86
85
  return resolveServiceTag(channel);
87
86
  }
88
87
  /**
89
- * Returns the auth domain for a channel key. Domain is the natural auth boundary — browser cookies and sessions scope to it. Multi-channel services work correctly
88
+ * Returns the auth domain for a channel key. Domain is the natural auth boundary - browser cookies and sessions scope to it. Multi-channel services work correctly
90
89
  * because all their channels share one domain, and canonical channels work correctly because each has its own domain.
91
90
  * @param key - The channel key.
92
91
  * @returns The extracted domain from the channel's URL, or empty string if the channel or URL cannot be resolved.
@@ -94,7 +93,6 @@ export function getServiceTagForChannel(key) {
94
93
  export function getAuthDomainForChannel(key) {
95
94
  const effectiveKey = stripPredefinedSuffix(key);
96
95
  const channel = channelsRef[effectiveKey] ?? PREDEFINED_CHANNELS[effectiveKey];
97
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
98
96
  if (!channel?.url) {
99
97
  return "";
100
98
  }
@@ -111,7 +109,7 @@ export function getChannelServiceTags(canonicalKey) {
111
109
  if (group) {
112
110
  const tags = new Set();
113
111
  for (const variant of group.variants) {
114
- // Skip predefined suffix variants — they share the canonical's tag (but derived from the predefined URL, which may differ from the user override).
112
+ // Skip predefined suffix variants - they share the canonical's tag (but derived from the predefined URL, which may differ from the user override).
115
113
  if (variant.key.endsWith(PREDEFINED_SUFFIX)) {
116
114
  continue;
117
115
  }
@@ -119,7 +117,7 @@ export function getChannelServiceTags(canonicalKey) {
119
117
  }
120
118
  return [...tags];
121
119
  }
122
- // Standalone channel — derive tag from the channel directly.
120
+ // Standalone channel - derive tag from the channel directly.
123
121
  return [getServiceTagForChannel(canonicalKey)];
124
122
  }
125
123
  /**
@@ -132,7 +130,7 @@ export function getAllServiceTags() {
132
130
  // Scan all channels (not just grouped ones) to find all service tags.
133
131
  const allKeys = new Set([...Object.keys(channelsRef), ...Object.keys(PREDEFINED_CHANNELS)]);
134
132
  for (const key of allKeys) {
135
- // Skip variant keys — they are covered by getChannelServiceTags() on the canonical.
133
+ // Skip variant keys - they are covered by getChannelServiceTags() on the canonical.
136
134
  const group = serviceGroups.get(key);
137
135
  if (group && (group.canonicalKey !== key)) {
138
136
  continue;
@@ -189,11 +187,31 @@ export function getEnabledServices() {
189
187
  return [...enabledServices];
190
188
  }
191
189
  /**
192
- * Sets the enabled service tags. Empty array means "no filter" (all services shown).
193
- * @param tags - The service tags to enable.
190
+ * Hydrates the in-memory enabled-services cache from a serialized list. Called by initializeUserChannels at startup with the validated tags list and by the
191
+ * consistency probe's auto-fix after stripping unknown tags. Never called by route code directly - mutations go through mutateEnabledServices, which is the
192
+ * single async path that updates both the cache and the persisted file in one atomic operation.
193
+ * @param tags - The service tags to load into the cache. Empty array means "no filter" (all services shown).
194
194
  */
195
195
  export function setEnabledServices(tags) {
196
196
  enabledServices = [...tags];
197
+ CONFIG.channels.enabledServices = [...tags];
198
+ }
199
+ /**
200
+ * Persists a new enabled-services list through the file store. Goes through mutateConfig so the file write, snapshot machinery, and post-mutate cache update
201
+ * all run uniformly - after the call returns, both disk and the module-state cache reflect the new value. Empty array means "no filter" (all services shown).
202
+ * @param tags - The new enabled service tags.
203
+ * @throws FileStoreParseError if config.json contains invalid JSON and the .bak rotation is also unparseable.
204
+ */
205
+ export async function mutateEnabledServices(tags) {
206
+ const next = [...tags];
207
+ await mutateConfig((config) => {
208
+ config.channels ??= {};
209
+ config.channels.enabledServices = next;
210
+ });
211
+ // Module-state cache hydration after successful write. Mirrors the post-mutate hydration done in mutateChannels for serviceSelections and tagRegistry,
212
+ // keeping the in-memory view consistent with what was just persisted.
213
+ enabledServices = next;
214
+ CONFIG.channels.enabledServices = next;
197
215
  }
198
216
  /**
199
217
  * Checks if a service tag is currently enabled. Returns true if the tag is enabled, if no filter is active (empty set), or if the tag is "direct".
@@ -201,7 +219,7 @@ export function setEnabledServices(tags) {
201
219
  * @returns True if the service is available.
202
220
  */
203
221
  export function isServiceTagEnabled(tag) {
204
- // No filter active — all services are enabled.
222
+ // No filter active - all services are enabled.
205
223
  if (enabledServices.length === 0) {
206
224
  return true;
207
225
  }
@@ -217,7 +235,7 @@ export function isServiceTagEnabled(tag) {
217
235
  * @returns True if the channel passes the service filter.
218
236
  */
219
237
  export function isChannelAvailableByService(canonicalKey) {
220
- // No filter active — all channels are available.
238
+ // No filter active - all channels are available.
221
239
  if (enabledServices.length === 0) {
222
240
  return true;
223
241
  }
@@ -225,7 +243,7 @@ export function isChannelAvailableByService(canonicalKey) {
225
243
  return tags.some((tag) => isServiceTagEnabled(tag));
226
244
  }
227
245
  /**
228
- * Checks if a channel in the merged map is a user override of a predefined channel. This uses object reference comparison — getAllChannels() spreads
246
+ * Checks if a channel in the merged map is a user override of a predefined channel. This uses object reference comparison - getAllChannels() spreads
229
247
  * PREDEFINED_CHANNELS directly into the result, so if the reference differs, a user channel has replaced the predefined one.
230
248
  * @param key - The channel key to check.
231
249
  * @param channels - The merged channel map.
@@ -237,16 +255,14 @@ function isUserOverride(key, channels) {
237
255
  return Boolean(predefined) && (channels[key] !== predefined);
238
256
  }
239
257
  /**
240
- * Builds service groups by scanning all channels and grouping by canonicalKey. Variant entries declare their canonical via the canonicalKey field (set by the
241
- * flattener for predefined channels, by the browse modal for user channels, and by the one-time migration for pre-existing user entries). This is a single
242
- * pass over the merged channel map — one field, one mechanism for both predefined and user-defined variant relationships.
258
+ * Builds service groups by scanning all channels and grouping by canonicalKey. The flattener sets canonicalKey on predefined variants, the browse modal sets it
259
+ * on user variants, and the schema-version migration stamps it on entries that lack it. One field, one mechanism, one pass.
243
260
  *
244
261
  * User overrides of predefined channels (same key, different object reference) produce a two-entry group with "Custom" and the original predefined version,
245
262
  * even for single-service channels that don't have canonicalKey-based variants.
246
263
  *
247
- * After building groups, validates stored service selections against the new variant structure and reverts any stale selections to the canonical default. This
248
- * catches all staleness sources in one place: service key renames across versions, variant removal in code, user-deleted custom variants, and reverted
249
- * predefined overrides.
264
+ * After grouping, every stored service selection is validated against the rebuilt variant structure. Selections that no longer correspond to a real variant are
265
+ * reverted to the canonical default. This is the single resolution boundary for stale selections; read-side resolvers stay pure.
250
266
  * @param channels - The merged channel map (predefined + user channels).
251
267
  * @returns Canonical keys whose service selections were stale and reverted. Empty array if all selections are valid. The caller decides whether to persist.
252
268
  */
@@ -270,20 +286,24 @@ export function buildServiceGroups(channels) {
270
286
  // Pass 2: Build groups from the collected variants.
271
287
  for (const [canonicalKey, variantKeys] of variantsByCanonical) {
272
288
  const canonical = channels[canonicalKey];
273
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
274
289
  if (!canonical) {
275
290
  continue;
276
291
  }
277
292
  const variants = [];
278
293
  // Handle user override of the canonical entry. Two scenarios: (A) the user customized properties (station ID, tags, etc.) but the URL still matches a known
279
- // service domain — no "Custom" variant needed, just the normal service label with a visual override indicator in the table; (B) the user set a genuinely
280
- // non-standard URL — "Custom (domain)" is a real service variant and the :predefined entry gives access to the original service URL.
281
- if (isUserOverride(canonicalKey, channels)) {
282
- const predefined = PREDEFINED_CHANNELS[canonicalKey];
294
+ // service domain - no "Custom" variant needed, just the normal service label with a visual override indicator in the table; (B) the user set a genuinely
295
+ // non-standard URL - "Custom (domain)" is a real service variant and the :predefined entry gives access to the original service URL.
296
+ // isUserOverride returned true means predefined exists for canonicalKey (it's defined as `Boolean(predefined) && (channels[key] !== predefined)`). Look it
297
+ // up here and narrow away the undefined so the scenarios below can rely on the predefined reference.
298
+ const predefined = isUserOverride(canonicalKey, channels) ? PREDEFINED_CHANNELS[canonicalKey] : undefined;
299
+ if (predefined) {
283
300
  const userDomain = extractDomain(canonical.url);
284
301
  const knownDomains = new Set([extractDomain(predefined.url)]);
285
302
  for (const variantKey of variantKeys) {
286
- knownDomains.add(extractDomain(channels[variantKey].url));
303
+ const variantChannel = channels[variantKey];
304
+ if (variantChannel) {
305
+ knownDomains.add(extractDomain(variantChannel.url));
306
+ }
287
307
  }
288
308
  if (knownDomains.has(userDomain)) {
289
309
  // Scenario A: property override on a known service. The canonical gets the same label as if it weren't overridden. The modified-dot indicator in the
@@ -304,7 +324,10 @@ export function buildServiceGroups(channels) {
304
324
  variantKeys.sort();
305
325
  for (const variantKey of variantKeys) {
306
326
  const variant = channels[variantKey];
307
- variants.push({ key: variantKey, label: getChannelServiceLabel(variant), tag: resolveServiceTag(variant) });
327
+ // Defensive: every variantKey in variantKeys came from Pass 1's scan of this same channels map, so the lookup succeeds. The narrowing keeps the types honest.
328
+ if (variant) {
329
+ variants.push({ key: variantKey, label: getChannelServiceLabel(variant), tag: resolveServiceTag(variant) });
330
+ }
308
331
  }
309
332
  const group = { canonicalKey, variants };
310
333
  // Map canonical and all variant keys to this group for easy lookup.
@@ -314,7 +337,7 @@ export function buildServiceGroups(channels) {
314
337
  }
315
338
  LOG.debug("config:general", "Service group '%s': variants=%s.", canonicalKey, variants.map((v) => v.key).join(", "));
316
339
  }
317
- // Pass 3: Create groups for user overrides of single-service predefined channels. Only Scenario B (genuinely custom URL) gets a service group — the user needs
340
+ // Pass 3: Create groups for user overrides of single-service predefined channels. Only Scenario B (genuinely custom URL) gets a service group - the user needs
318
341
  // a dropdown to switch between their custom URL and the predefined service. Scenario A (property override on the same domain) skips group creation entirely
319
342
  // because there is only one service and no dropdown is needed; the modified-dot indicator in the table renderer signals the override.
320
343
  for (const key of Object.keys(channels)) {
@@ -326,7 +349,12 @@ export function buildServiceGroups(channels) {
326
349
  }
327
350
  const userChannel = channels[key];
328
351
  const predefined = PREDEFINED_CHANNELS[key];
329
- // Scenario A: URL domain matches the predefined service. No service group needed — renders as a single-service channel with a modified-dot indicator.
352
+ // Defensive: isUserOverride returning true means both predefined and the user entry exist. The narrowing here satisfies the type-checker and guards
353
+ // against an impossible edge case.
354
+ if (!userChannel || !predefined) {
355
+ continue;
356
+ }
357
+ // Scenario A: URL domain matches the predefined service. No service group needed - renders as a single-service channel with a modified-dot indicator.
330
358
  if (extractDomain(userChannel.url) === extractDomain(predefined.url)) {
331
359
  continue;
332
360
  }
@@ -343,7 +371,7 @@ export function buildServiceGroups(channels) {
343
371
  // channel. Scans only canonical entries in PREDEFINED_CHANNELS (variants share the canonical's URL domain and would produce duplicate results).
344
372
  predefinedByDomain.clear();
345
373
  for (const [key, channel] of Object.entries(PREDEFINED_CHANNELS)) {
346
- if (channel.canonicalKey) {
374
+ if (channel.canonicalKey !== undefined) {
347
375
  continue;
348
376
  }
349
377
  const domain = extractDomain(channel.url);
@@ -357,9 +385,8 @@ export function buildServiceGroups(channels) {
357
385
  predefinedByDomain.set(domain, [entry]);
358
386
  }
359
387
  }
360
- // Validate stored service selections against the rebuilt groups. Any selection whose variant key no longer exists in the group is stale and reverted to the
361
- // canonical default. This handles all staleness sources: service key renames across versions, variant removal, user-deleted custom variants, and reverted
362
- // predefined overrides. The caller decides whether to persist based on whether any keys were cleaned.
388
+ // Validate stored service selections against the rebuilt groups. Any selection whose variant key is not present in the group is stale and reverted to the
389
+ // canonical default. The caller decides whether to persist based on whether any keys were cleaned.
363
390
  const staleKeys = [];
364
391
  for (const [canonicalKey, selection] of serviceSelections) {
365
392
  const group = serviceGroups.get(canonicalKey);
@@ -400,7 +427,7 @@ export function getPredefinedDomainMap() {
400
427
  /**
401
428
  * Resolves a URL to a friendly service display name. Checks built-in DOMAIN_CONFIG first for a stable, well-known service name, then falls back to
402
429
  * getDomainConfig() which includes user domain mappings. This ordering prevents user domain overrides from corrupting display labels for predefined channel
403
- * variants — a user mapping a built-in domain to a custom profile should not rename every service dropdown entry that uses that domain.
430
+ * variants - a user mapping a built-in domain to a custom profile should not rename every service dropdown entry that uses that domain.
404
431
  * @param url - The URL to resolve a service display name for.
405
432
  * @returns The service display name, or the concise domain if no service name is configured.
406
433
  */
@@ -410,19 +437,17 @@ export function getServiceDisplayName(url) {
410
437
  try {
411
438
  const hostname = new URL(url).hostname;
412
439
  const builtinFull = DOMAIN_CONFIG[hostname];
413
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
414
440
  if (builtinFull?.service) {
415
441
  return builtinFull.service;
416
442
  }
417
443
  const concise = extractDomain(url);
418
444
  const builtinConcise = DOMAIN_CONFIG[concise];
419
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
420
445
  if (builtinConcise?.service) {
421
446
  return builtinConcise.service;
422
447
  }
423
448
  }
424
449
  catch {
425
- // Invalid URL — fall through to getDomainConfig.
450
+ // Invalid URL - fall through to getDomainConfig.
426
451
  }
427
452
  // For domains not in DOMAIN_CONFIG, fall back to getDomainConfig() which includes user domain mappings.
428
453
  const config = getDomainConfig(url);
@@ -430,7 +455,7 @@ export function getServiceDisplayName(url) {
430
455
  }
431
456
  /**
432
457
  * Resolves service identity (tag and display name) for a user-defined profile by scanning its domain mappings. Returns the first matching domain config's
433
- * serviceTag and service name. This is the single source of truth for "profile key → service identity" resolution, used by both tag and label lookups to avoid
458
+ * serviceTag and service name. This is the single source of truth for "profile key -> service identity" resolution, used by both tag and label lookups to avoid
434
459
  * duplicating the domain scan logic.
435
460
  * @param profileKey - The user profile key to resolve.
436
461
  * @returns The service identity from the profile's domain mappings, or undefined if no matching domain mapping exists.
@@ -477,7 +502,7 @@ export const VALID_SORT_FIELDS = new Set(["channelNumber", "channelSelector", "h
477
502
  * @returns A lowercase string suitable for comparison-based sorting.
478
503
  */
479
504
  export function getChannelSortKey(channel, key, field) {
480
- // Resolve the selected service variant so all sort keys reflect the user's service selection. For URL-dependent fields (profile, service), this is essential —
505
+ // Resolve the selected service variant so all sort keys reflect the user's service selection. For URL-dependent fields (profile, service), this is essential -
481
506
  // a canonical's URL may differ from the selected variant's (e.g., bbcnews canonical uses cox but the user selected the directv variant). For identity fields
482
507
  // (name, stationId, channelNumber), the flattener eagerly sets these on all entries, so the resolved channel has identical values regardless of variant.
483
508
  const effective = getResolvedChannel(resolveServiceKey(key)) ?? channel;
@@ -490,8 +515,9 @@ export function getChannelSortKey(channel, key, field) {
490
515
  return (effective.channelSelector ?? "").toLowerCase();
491
516
  }
492
517
  case "hdhrEnabled": {
493
- // Sort enabled channels before disabled. "0" (enabled/absent) sorts before "1" (disabled).
494
- return (effective.hdhrEnabled === false) ? "1" : "0";
518
+ // Sort enabled channels before disabled. "0" (enabled/absent) sorts before "1" (disabled). The effective-view helper centralizes the implicit-true
519
+ // convention so the sort key here, the table's checked attribute, and every other consumer agree on the meaning of an absent value.
520
+ return getEffectiveHdhrEnabled(effective) ? "0" : "1";
495
521
  }
496
522
  case "key": {
497
523
  return key.toLowerCase();
@@ -531,8 +557,8 @@ export function getChannelSortKey(channel, key, field) {
531
557
  }
532
558
  /**
533
559
  * Compares two channels for sorting by the specified field and direction with a built-in channel name tiebreaker. The tiebreaker is always ascending so that rows
534
- * within each group maintain a consistent alphabetical order regardless of the primary sort direction. This is the single comparator for all sort sites — server HTML
535
- * render, client re-sort, and M3U playlist — to prevent ordering divergence.
560
+ * within each group maintain a consistent alphabetical order regardless of the primary sort direction. This is the single comparator for all sort sites - server HTML
561
+ * render, client re-sort, and M3U playlist - to prevent ordering divergence.
536
562
  * @param channelA - First channel definition.
537
563
  * @param keyA - First channel key.
538
564
  * @param channelB - Second channel definition.
@@ -592,14 +618,15 @@ export function getCanonicalKey(key) {
592
618
  return group?.canonicalKey ?? baseKey;
593
619
  }
594
620
  /**
595
- * Sets the user's service selections. Called when loading from channels.json.
621
+ * Hydrates the in-memory selections cache from a serialized record. Called by mutateChannels' post-write hook (with the freshly-written disk state) and by
622
+ * initializeUserChannels at startup. Never called by route code directly - mutations go through setServiceSelection (single) or mutateServiceSelections (bulk).
596
623
  * @param selections - Service selections keyed by canonical channel key.
597
624
  */
598
625
  export function setServiceSelections(selections) {
599
626
  serviceSelections = new Map(Object.entries(selections));
600
627
  }
601
628
  /**
602
- * Gets all service selections.
629
+ * Gets all service selections from the in-memory cache. Cache is hydrated from disk on every successful mutate, so this is always consistent with the file.
603
630
  * @returns Copy of the service selections object.
604
631
  */
605
632
  export function getServiceSelections() {
@@ -614,39 +641,66 @@ export function getServiceSelection(canonicalKey) {
614
641
  return serviceSelections.get(canonicalKey);
615
642
  }
616
643
  /**
617
- * Sets the service selection for a channel.
644
+ * Persists a single service selection through the file store. Goes through mutateChannels so the file write, integrity validation, and post-mutate cache
645
+ * hydration all run uniformly - after the call returns, both disk and the module-state Map reflect the new value. Selecting the canonical key itself (the
646
+ * default service) deletes the selection rather than storing a redundant entry.
647
+ *
648
+ * For bulk updates, prefer mutateServiceSelections to coalesce multiple changes into a single atomic write.
618
649
  * @param canonicalKey - The canonical channel key.
619
- * @param serviceKey - The selected service key.
650
+ * @param serviceKey - The selected service key. When equal to canonicalKey, the selection is removed.
651
+ * @throws FileStoreParseError if channels.json contains invalid JSON and the .bak rotation is also unparseable.
620
652
  */
621
- export function setServiceSelection(canonicalKey, serviceKey) {
622
- // If selecting the canonical (default), remove the selection instead of storing it.
623
- if (serviceKey === canonicalKey) {
624
- serviceSelections.delete(canonicalKey);
625
- }
626
- else {
627
- serviceSelections.set(canonicalKey, serviceKey);
628
- }
653
+ export async function setServiceSelection(canonicalKey, serviceKey) {
654
+ await mutateChannels((data) => {
655
+ if (serviceKey === canonicalKey) {
656
+ Reflect.deleteProperty(data.serviceSelections, canonicalKey);
657
+ }
658
+ else {
659
+ data.serviceSelections[canonicalKey] = serviceKey;
660
+ }
661
+ });
662
+ }
663
+ /**
664
+ * Bulk variant of setServiceSelection. Applies multiple selection changes inside a single mutate transaction so all changes land atomically with one disk
665
+ * write. Use this whenever a single user action (e.g., browse-modal submit, bulk service assignment) updates more than one selection - serial awaits over
666
+ * setServiceSelection would produce N writes and N intermediate disk states.
667
+ *
668
+ * Each entry follows the same canonical-key-equals-service-key convention as setServiceSelection: when the value matches the key, the selection is removed.
669
+ * @param updates - Object mapping canonical channel keys to their new service keys.
670
+ * @throws FileStoreParseError if channels.json contains invalid JSON and the .bak rotation is also unparseable.
671
+ */
672
+ export async function mutateServiceSelections(updates) {
673
+ await mutateChannels((data) => {
674
+ for (const [canonicalKey, serviceKey] of Object.entries(updates)) {
675
+ if (serviceKey === canonicalKey) {
676
+ Reflect.deleteProperty(data.serviceSelections, canonicalKey);
677
+ }
678
+ else {
679
+ data.serviceSelections[canonicalKey] = serviceKey;
680
+ }
681
+ }
682
+ });
629
683
  }
630
684
  /**
631
685
  * Resolves a canonical channel key to the actual channel key based on user selection. If the user has selected a specific service for this channel, returns that
632
686
  * service's key. Otherwise returns the canonical key (default service). When the service filter is active, falls back to the first enabled variant if the stored
633
687
  * selection's service is filtered out.
634
688
  *
635
- * This function is a pure resolver with no side effects. Stale selection cleanup is handled by buildServiceGroups(), which validates all stored selections
636
- * against the rebuilt variant structure every time groups are rebuilt - both at startup and after runtime channel mutations.
689
+ * Pure resolver with no side effects. Stale selection cleanup belongs to buildServiceGroups(), which validates all stored selections against the rebuilt variant
690
+ * structure on startup and after runtime channel mutations. Reads are safe to call from any context, including inside other mutations.
637
691
  * @param canonicalKey - The canonical channel key.
638
692
  * @returns The resolved service key to use for streaming.
639
693
  */
640
694
  export function resolveServiceKey(canonicalKey) {
641
695
  const selection = serviceSelections.get(canonicalKey);
642
- // No selection stored — use the canonical key (default service). If the canonical's service tag is filtered out, fall back to the first enabled variant.
696
+ // No selection stored - use the canonical key (default service). If the canonical's service tag is filtered out, fall back to the first enabled variant.
643
697
  if (!selection) {
644
698
  if ((enabledServices.length > 0) && !isServiceTagEnabled(getServiceTagForChannel(canonicalKey))) {
645
699
  return findFirstEnabledVariant(canonicalKey) ?? canonicalKey;
646
700
  }
647
701
  return canonicalKey;
648
702
  }
649
- // Valid selection — if its service tag is filtered out, fall back to the first enabled variant.
703
+ // Valid selection - if its service tag is filtered out, fall back to the first enabled variant.
650
704
  if ((enabledServices.length > 0) && !isServiceTagEnabled(getServiceTagForChannel(selection))) {
651
705
  return findFirstEnabledVariant(canonicalKey) ?? selection;
652
706
  }
@@ -674,85 +728,45 @@ function findFirstEnabledVariant(canonicalKey) {
674
728
  return undefined;
675
729
  }
676
730
  /**
677
- * Applies variant inheritance: the variant contributes service-specific fields (url, channelSelector, profile, etc.) while identity fields always come from the
678
- * canonical base. Identity fields describe what the channel IS — name, station ID, tags, channel number — and are independent of which service serves it. The
679
- * spread brings in all variant fields, then identity fields are unconditionally overwritten from the canonical. This ensures user overrides on the canonical
680
- * (e.g., renaming a channel or adding tags) propagate to all service variants, rather than being masked by stale flattener-copied values on the variant.
681
- * The field list comes from CHANNEL_IDENTITY_FIELDS — the single source of truth for the identity/service-specific separation.
682
- * @param variant - The variant channel definition (service-specific fields).
683
- * @param base - The canonical (base) channel with user overrides applied (identity fields).
684
- * @returns A new Channel with identity fields from the canonical and service-specific fields from the variant.
685
- */
686
- function applyVariantInheritance(variant, base) {
687
- const result = { ...variant };
688
- for (const field of CHANNEL_IDENTITY_FIELDS) {
689
- copyField(result, base, field);
690
- }
691
- return result;
692
- }
693
- // Type-safe field copy using a generic to preserve the field-value relationship. TypeScript can't prove that result[field] and base[field] have compatible types
694
- // when field is a union of literal keys (each key maps to a different type). The generic K narrows to a single key per call, making the assignment type-safe.
695
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- K is necessary to correlate the key type between target and source assignments.
696
- function copyField(target, source, key) {
697
- target[key] = source[key];
698
- }
699
- /**
700
- * Gets a channel with inheritance applied. For service variants, this merges the variant's properties with inherited properties from the canonical entry
701
- * using the live channel data (which includes user overrides). Use `resolvePredefinedVariant()` when you need resolution against pure predefined data.
702
- * @param key - The channel key (canonical or variant).
703
- * @returns The complete channel with inheritance applied, or undefined if the channel doesn't exist.
731
+ * Gets a channel with inheritance applied. Variant inheritance is resolved at load time by resolveStoredChannel in userChannels.ts - entries in channelsRef
732
+ * are already fully merged with their canonical (variant values win when set, canonical fills in the rest). This function is a thin accessor that also
733
+ * handles the synthetic :predefined suffix used when a user overrides a canonical but the service dropdown references the original predefined variant.
734
+ * @param key - The channel key (canonical, variant, or :predefined suffix).
735
+ * @returns The complete channel, or undefined if the channel doesn't exist.
704
736
  */
705
737
  export function getResolvedChannel(key) {
706
- // Handle predefined suffix — return the original predefined channel when user has overridden the canonical but selects the predefined service.
738
+ // Handle the :predefined suffix - return the original predefined channel when the user has overridden the canonical but selects the predefined service. The
739
+ // suffix is only meaningful for canonical keys, so the lookup target is structurally a CanonicalChannel (which is a valid ResolvedChannel).
707
740
  if (key.endsWith(PREDEFINED_SUFFIX)) {
708
- const baseKey = key.slice(0, -PREDEFINED_SUFFIX.length);
709
- return PREDEFINED_CHANNELS[baseKey];
710
- }
711
- const channel = channelsRef[key];
712
- // Runtime check needed even though TypeScript thinks channel is always defined (Record indexing quirk).
713
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
714
- if (!channel) {
715
- return undefined;
716
- }
717
- const group = serviceGroups.get(key);
718
- // If not part of a group or is the canonical entry, return as-is.
719
- if (!group || (group.canonicalKey === key)) {
720
- return channel;
721
- }
722
- // This is a variant — merge with canonical entry.
723
- const canonical = channelsRef[group.canonicalKey];
724
- // Runtime check — canonical entry should exist if the group exists, but we check defensively.
725
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
726
- if (!canonical) {
727
- // Canonical entry missing (shouldn't happen), return variant as-is.
728
- return channel;
741
+ const predefined = PREDEFINED_CHANNELS[key.slice(0, -PREDEFINED_SUFFIX.length)];
742
+ return (predefined && (predefined.canonicalKey === undefined)) ? predefined : undefined;
729
743
  }
730
- return applyVariantInheritance(channel, canonical);
744
+ return channelsRef[key];
731
745
  }
732
746
  /**
733
- * Resolves a variant channel key against pure predefined data (ignoring user overrides). This is used for revert detection — when the user's edits match a
734
- * variant's predefined definition, the custom override can be removed and the service selection switched to that variant. For canonical keys, returns the raw
735
- * predefined channel. For variant keys, applies the same inheritance rules as `getResolvedChannel()` but against `PREDEFINED_CHANNELS` instead of `channelsRef`.
747
+ * Resolves a variant channel key against pure predefined data (ignoring user overrides). Used for revert detection: when an edit's values match a variant's
748
+ * predefined definition, the custom override can be dropped and the service selection switched to that variant. Predefined variant entries carry only
749
+ * service-specific fields and canonicalKey; identity inherits from the canonical. This resolver layers the variant's service fields onto the canonical so
750
+ * findMatchingVariant can compare form values against a fully-populated variant view.
736
751
  * @param key - The channel key (canonical or variant).
737
- * @returns The channel with inheritance applied against predefined data, or undefined if the key has no predefined definition.
752
+ * @returns The resolved predefined channel, or undefined when the key has no predefined definition.
738
753
  */
739
754
  export function resolvePredefinedVariant(key) {
740
- const channel = PREDEFINED_CHANNELS[key];
741
- // Runtime check — the key may not exist in PREDEFINED_CHANNELS.
742
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
743
- if (!channel) {
755
+ const entry = PREDEFINED_CHANNELS[key];
756
+ if (!entry) {
744
757
  return undefined;
745
758
  }
746
- const group = serviceGroups.get(key);
747
- // If not part of a group or is the canonical entry, return the predefined channel as-is.
748
- if (!group || (group.canonicalKey === key)) {
749
- return channel;
759
+ // Canonical entries (and standalones, but those are not in PREDEFINED_CHANNELS) carry full identity already. Narrow on the canonicalKey discriminator to
760
+ // confirm: undefined canonicalKey means CanonicalChannel.
761
+ if (entry.canonicalKey === undefined) {
762
+ return entry;
750
763
  }
751
- const canonical = PREDEFINED_CHANNELS[group.canonicalKey];
752
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
753
- if (!canonical) {
754
- return channel;
764
+ // Predefined variant: identity inherits from the canonical, binding comes from the variant. We start with canonical identity ONLY (via pickIdentity) so the
765
+ // canonical service's binding does not leak into a different service's variant. The variant's own binding fields then populate via spread.
766
+ const canonical = PREDEFINED_CHANNELS[entry.canonicalKey];
767
+ if (!canonical || (canonical.canonicalKey !== undefined)) {
768
+ return undefined;
755
769
  }
756
- return applyVariantInheritance(channel, canonical);
770
+ return { ...pickIdentity(canonical), ...entry };
757
771
  }
758
772
  //# sourceMappingURL=services.js.map