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
@@ -2,13 +2,15 @@
2
2
  *
3
3
  * userChannels.ts: User channel file management for PrismCast.
4
4
  */
5
- import { CHANNEL_IDENTITY_FIELDS, PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../channels/index.js";
5
+ import { CHANNEL_BINDING_KEYS, CHANNEL_IDENTITY_KEYS, DELTA_ELIGIBLE_BINDING_KEYS, DELTA_ELIGIBLE_IDENTITY_KEYS } from "../types/index.js";
6
6
  import { FileStoreParseError, createFileStore } from "./persistence.js";
7
7
  import { LOG, containsNonPrintable, sanitizeString } from "../utils/index.js";
8
- import { buildServiceGroups, getAllServiceTags, getResolvedChannel, getServiceSelections, isChannelAvailableByService, isServiceVariant, resolveServiceKey, setEnabledServices, setServiceSelections } from "./services.js";
8
+ import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../channels/index.js";
9
+ import { buildServiceGroups, getAllServiceTags, getResolvedChannel, isChannelAvailableByService, isServiceVariant, resolveServiceKey, setEnabledServices, setServiceSelections } from "./services.js";
9
10
  import { CONFIG } from "./index.js";
10
11
  import fs from "node:fs";
11
12
  import { getChannelsFilePath } from "./paths.js";
13
+ import { isDeepStrictEqual } from "node:util";
12
14
  import { mutateConfig } from "./userConfig.js";
13
15
  const { promises: fsPromises } = fs;
14
16
  /* The channels file path is resolved via the centralized paths module (config/paths.ts). The data directory is initialized at startup before channel loading.
@@ -43,9 +45,27 @@ export function hasChannelsParseError() {
43
45
  export function getChannelsParseErrorMessage() {
44
46
  return userChannelsParseErrorMessage;
45
47
  }
48
+ /* Transactional store for channels.json. The store uses a compound type that carries channel data alongside metadata (serviceSelections, tagRegistry). The parse
49
+ * function extracts all three from the JSON file. The mutateChannels() wrapper normalizes predefined channel deltas before the write and adapts the compound
50
+ * type so callers work directly with StoredChannelMap. The beforeWrite hook injects metadata from module state.
51
+ */
52
+ /* Current schema version for channels.json. Migrations are declared in channelsMigrations below; the framework runs them in order from the file's stored
53
+ * version up to this constant, stamps the new version after each, and records the audit trail in migrationsApplied.
54
+ *
55
+ * Version history:
56
+ * 1 - Pre-delta-model user variants. Identity fields stored redundantly on variants and stripped eagerly at load time.
57
+ * 2 - Delta-model user variants. Variants store only fields that differ from their canonical. The v1 -> v2 migration converts legacy-shaped variants by
58
+ * stamping canonicalKey on entries whose identity fields match the canonical (shape-compatible with a legitimate variant) and skipping entries whose
59
+ * fields differ (user standalones that happened to share a hyphenated key with a predefined canonical).
60
+ * 3 - Foxone-era service naming. The v2 -> v3 migration renames "foxcom" -> "foxone" in service selections and channel keys, and the special-case
61
+ * "fox-site" -> "fox-foxone" rename for the FoxOne variant briefly mis-keyed in v1.8.0. Bumping this version also flushes parser-side legacy field
62
+ * cleanups (provider -> service field, providerSelections -> serviceSelections key) to disk on the same write that lands the foxone rename.
63
+ */
64
+ const CURRENT_CHANNELS_SCHEMA_VERSION = 3;
46
65
  /**
47
- * Parses raw channels.json content into the compound data type. Extracts channel entries, service selections, and tag registry from the top-level JSON object.
48
- * Also applies the legacy "provider" to "service" field migration.
66
+ * Parses raw channels.json content into the compound data type. Extracts channel entries, service selections, tag registry, and framework metadata
67
+ * (schemaVersion, migrationsApplied) from the top-level JSON object. Also performs structural normalization for legacy field/key naming so consumer code only
68
+ * sees the current shape; the schemaVersion progression governs when the cleanup is persisted to disk via the migration framework.
49
69
  * @param raw - The raw JSON string from the file.
50
70
  * @returns The parsed compound data.
51
71
  */
@@ -54,9 +74,29 @@ function parseChannelsFile(raw) {
54
74
  const channels = {};
55
75
  const serviceSelections = {};
56
76
  const tagRegistry = { deletedTags: [], tags: [] };
77
+ const migrationsApplied = [];
78
+ // Files predating the schemaVersion field are treated as version 1. The migration runner in the file store framework upgrades them to the current version.
79
+ let schemaVersion = 1;
57
80
  for (const [key, value] of Object.entries(parsed)) {
58
- if ((key === "serviceSelections") || (key === "providerSelections")) {
59
- // Copy service selections if it's an object. Accepts the legacy "providerSelections" key for backward compatibility.
81
+ if (key === "schemaVersion") {
82
+ // Tolerate bad data by falling back to version 1 - any plausibly-legacy value is fine, since migrations are idempotent when no work is needed.
83
+ if ((typeof value === "number") && Number.isFinite(value) && (value >= 1)) {
84
+ schemaVersion = Math.floor(value);
85
+ }
86
+ }
87
+ else if (key === "migrationsApplied") {
88
+ // Audit trail of migrations that have already been applied to this file. Strings only; non-string entries are dropped silently.
89
+ if (Array.isArray(value)) {
90
+ for (const entry of value) {
91
+ if (typeof entry === "string") {
92
+ migrationsApplied.push(entry);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ else if ((key === "serviceSelections") || (key === "providerSelections")) {
98
+ // Copy service selections if it is an object. Accepts the legacy "providerSelections" key for structural compatibility - the v2 -> v3 schema migration
99
+ // ensures the file is rewritten under the current key on next persist.
60
100
  if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
61
101
  for (const [selKey, selValue] of Object.entries(value)) {
62
102
  if (typeof selValue === "string") {
@@ -69,84 +109,333 @@ function parseChannelsFile(raw) {
69
109
  // Extract tag registry with defensive validation. String array fields (tags, deletedTags) drop non-string elements silently.
70
110
  if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
71
111
  const rawRegistry = value;
72
- if (Array.isArray(rawRegistry.tags)) {
73
- tagRegistry.tags = rawRegistry.tags.filter((t) => typeof t === "string").sort();
112
+ if (Array.isArray(rawRegistry["tags"])) {
113
+ tagRegistry.tags = rawRegistry["tags"].filter((t) => typeof t === "string").sort();
74
114
  }
75
- if (Array.isArray(rawRegistry.deletedTags)) {
76
- tagRegistry.deletedTags = rawRegistry.deletedTags.filter((t) => typeof t === "string").sort();
115
+ if (Array.isArray(rawRegistry["deletedTags"])) {
116
+ tagRegistry.deletedTags = rawRegistry["deletedTags"].filter((t) => typeof t === "string").sort();
77
117
  }
78
118
  }
79
119
  }
80
120
  else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
81
- // It's a channel definition or delta override.
121
+ // It is a channel definition or delta override.
82
122
  channels[key] = value;
83
123
  }
84
124
  }
85
- // Silent migration: rename legacy "provider" field to "service" on channel entries. The old field was used as a display name override for the service selection
86
- // dropdown. New installs always write "service". This migration ensures existing channels.json files are upgraded transparently.
125
+ // Structural normalization: rename legacy "provider" field to "service" on channel entries. The old field was used as a display name override for the
126
+ // service selection dropdown; new installs always write "service". The schema migration to v3 stamps the version after this normalization is in memory,
127
+ // ensuring the persist on next write drops the legacy field from disk.
87
128
  for (const channel of Object.values(channels)) {
88
129
  const legacy = channel;
89
- if (("provider" in legacy) && !("service" in legacy)) {
90
- legacy.service = legacy.provider;
130
+ if ("provider" in legacy) {
131
+ if (!("service" in legacy)) {
132
+ legacy["service"] = legacy["provider"];
133
+ }
134
+ Reflect.deleteProperty(legacy, "provider");
91
135
  }
92
- Reflect.deleteProperty(legacy, "provider");
93
136
  }
94
- return { channels, serviceSelections, tagRegistry };
137
+ return { channels, migrationsApplied, schemaVersion, serviceSelections, tagRegistry };
95
138
  }
139
+ /* Array-valued ChannelDelta fields whose equality requires canonical case-insensitive ordering before JSON.stringify. The set declares which array fields are
140
+ * unordered-set semantics rather than order-significant lists; equality for these fields canonical-sorts both sides so authoring-order or case differences do
141
+ * not defeat the match. Tags are the only such field today, but the property-based framing is the architectural truth: any unordered-set array field belongs in
142
+ * this set. The `satisfies` constraint keeps the list in sync with ChannelDelta's actual keys at compile time - renaming or removing a field forces this tuple
143
+ * to be updated.
144
+ */
145
+ const CANONICAL_SORTED_ARRAY_FIELDS = new Set(["tags"]);
96
146
  /**
97
- * Normalizes predefined channel deltas by stripping no-op fields and strips null fields from user channels. A delta field is a no-op if: (1) its value is null
98
- * and the predefined doesn't have the field, (2) its value is undefined, or (3) its value matches the predefined's value exactly. After stripping, deltas with
99
- * no remaining fields are removed entirely.
100
- * @param channels - The raw channel entries to normalize.
101
- * @returns The normalized channel map.
147
+ * Normalizes a single stored entry as a delta against a base channel. Delta fields are dropped when they are no-ops: undefined values, nulls against a base
148
+ * that has no such field, and values that match the base exactly. Array-valued fields use JSON.stringify for equality with case-insensitive canonical sorting
149
+ * for the fields listed in CANONICAL_SORTED_ARRAY_FIELDS so authoring-order differences do not defeat the match. Non-delta fields (e.g., canonicalKey) pass
150
+ * through unchanged so the stored entry retains its relationship metadata.
151
+ * @param stored - The raw stored entry.
152
+ * @param base - The base Channel to diff against (predefined definition for overrides, resolved canonical for variants).
153
+ * @returns The normalized entry, or null when every field is a no-op and the entry would carry no information.
102
154
  */
103
- function normalizeChannelDeltas(channels) {
104
- const filtered = {};
105
- for (const [key, stored] of Object.entries(channels)) {
106
- if (key in PREDEFINED_CHANNELS) {
107
- const predefined = PREDEFINED_CHANNELS[key];
108
- const cleaned = {};
109
- let hasFields = false;
110
- for (const [field, value] of Object.entries(stored)) {
111
- // Skip non-delta fields (like canonicalKey) that aren't in the allowlist.
112
- if (!DELTA_ALLOWED_FIELDS.has(field)) {
113
- continue;
114
- }
115
- // Skip undefined values - they have no effect.
116
- if (value === undefined) {
117
- continue;
118
- }
119
- // Skip null values when the predefined doesn't have the field - clearing a nonexistent field is a no-op.
120
- if ((value === null) && !(field in predefined)) {
121
- continue;
122
- }
123
- // Skip values that match the predefined exactly - redundant copies. Array fields (like tags) use JSON.stringify for comparison since reference equality
124
- // always fails for arrays. Tags arrays are always sorted and lowercase, making JSON.stringify deterministic.
125
- if (value !== null) {
126
- const predefinedValue = predefined[field];
127
- if (Array.isArray(value) ? (JSON.stringify(value) === JSON.stringify(predefinedValue)) : (predefinedValue === value)) {
128
- continue;
129
- }
130
- }
155
+ function normalizeEntryAgainstBase(stored, base) {
156
+ const cleaned = {};
157
+ let hasFields = false;
158
+ for (const [field, value] of Object.entries(stored)) {
159
+ // Non-delta fields (like canonicalKey) pass through so the stored entry keeps its relationship metadata - except when the base already declares the same
160
+ // value. For example, a user override of a predefined variant doesn't need to store canonicalKey because the predefined variant entry already declares it;
161
+ // stripping the redundant copy keeps the stored record minimal and lets an empty override collapse to nothing.
162
+ if (!DELTA_ALLOWED_FIELDS.has(field)) {
163
+ if (value === undefined) {
164
+ continue;
165
+ }
166
+ const baseValue = base[field];
167
+ if (baseValue !== value) {
131
168
  cleaned[field] = value;
132
169
  hasFields = true;
133
170
  }
134
- // Preserve non-delta fields (like canonicalKey) that were on the stored entry.
135
- for (const [field, value] of Object.entries(stored)) {
136
- if (!DELTA_ALLOWED_FIELDS.has(field) && (value !== undefined)) {
137
- cleaned[field] = value;
138
- hasFields = true;
139
- }
171
+ continue;
172
+ }
173
+ // Undefined delta values are no-ops.
174
+ if (value === undefined) {
175
+ continue;
176
+ }
177
+ // Null means "clear this field on the base." When the base does not have the field, the clear is a no-op and gets dropped. Otherwise null is preserved so
178
+ // the clear survives.
179
+ if (value === null) {
180
+ if (field in base) {
181
+ cleaned[field] = null;
182
+ hasFields = true;
140
183
  }
141
- if (hasFields) {
142
- filtered[key] = cleaned;
184
+ continue;
185
+ }
186
+ // Values that match the base exactly are redundant. Arrays compare by JSON.stringify; fields in CANONICAL_SORTED_ARRAY_FIELDS are sorted on both sides
187
+ // first so the equality check is order-independent.
188
+ const baseValue = base[field];
189
+ if (Array.isArray(value)) {
190
+ const canonical = CANONICAL_SORTED_ARRAY_FIELDS.has(field);
191
+ const left = canonical ? sortTags(value) : value;
192
+ const right = canonical ? sortTags((baseValue ?? [])) : baseValue;
193
+ if (isDeepStrictEqual(left, right)) {
194
+ continue;
143
195
  }
144
196
  }
197
+ else if (baseValue === value) {
198
+ continue;
199
+ }
200
+ cleaned[field] = value;
201
+ hasFields = true;
202
+ }
203
+ return hasFields ? (cleaned) : null;
204
+ }
205
+ /**
206
+ * Classifies a channel entry by consulting both the predefined catalog and the user's stored map. The kind determines how the entry is resolved, normalized, and
207
+ * displayed.
208
+ *
209
+ * Precondition: at least one of the predefined catalog or the stored map must have the key. Callers iterate over the union of key sets and therefore always
210
+ * satisfy this. Violating the precondition throws rather than returning a nullable - the alternative is a defensive undefined check at every call site for a
211
+ * case that never occurs, which is both dead code and a misleading contract.
212
+ * @param key - The channel key.
213
+ * @param stored - The stored entry from the user's channels map (if any).
214
+ * @returns The classification. Never undefined; see the precondition.
215
+ * @throws Error when neither PREDEFINED_CHANNELS nor the caller's stored map contains the key.
216
+ */
217
+ function classifyEntry(key, stored) {
218
+ const predefined = PREDEFINED_CHANNELS[key];
219
+ const userCanonicalKey = stored ? stored.canonicalKey : undefined;
220
+ const canonicalKey = userCanonicalKey ?? predefined?.canonicalKey;
221
+ // Variant: either source declares canonicalKey pointing at a different entry.
222
+ if (canonicalKey && (canonicalKey !== key)) {
223
+ return { canonicalKey, kind: "variant", predefined, stored };
224
+ }
225
+ // Canonical: a predefined entry exists (optionally with a user override on top).
226
+ if (predefined) {
227
+ return { kind: "canonical", predefined, stored };
228
+ }
229
+ // Standalone: user-only entry. Stored must be defined here; predefined is falsy (else canonical branch caught) and the neither-side-present state violates
230
+ // the precondition.
231
+ if (stored) {
232
+ return { kind: "standalone", stored };
233
+ }
234
+ throw new Error("classifyEntry called with key '" + key + "' that exists in neither PREDEFINED_CHANNELS nor the stored map.");
235
+ }
236
+ /**
237
+ * Computes the field-name allowlist for a stored entry's expected shape. Stored entries fall into two structural shapes determined by classification:
238
+ *
239
+ * - Canonical-shaped (kind = canonical or standalone): allowed fields are DELTA_ELIGIBLE_IDENTITY_KEYS ∪ DELTA_ELIGIBLE_BINDING_KEYS. The full delta surface.
240
+ * - Variant-shaped (kind = variant): allowed fields are DELTA_ELIGIBLE_BINDING_KEYS plus the structural canonicalKey discriminator. Identity inherits from the
241
+ * canonical and must not appear on variants per the architectural principle.
242
+ *
243
+ * Single source of truth for the shape rule. Used by filterToDeltaSurface (normalizer pre-strip) and by the customization accessor's per-pass walks. Routes
244
+ * shape determination through classifyEntry so any caller automatically picks up classifier improvements - no parallel logic for "is this a variant?".
245
+ * @param key - The channel key for classification.
246
+ * @param stored - The stored entry for classification.
247
+ * @returns The set of field names allowed for this entry's shape.
248
+ */
249
+ function getAllowedFieldsForShape(key, stored) {
250
+ const isVariant = classifyEntry(key, stored).kind === "variant";
251
+ if (isVariant) {
252
+ return new Set([...DELTA_ELIGIBLE_BINDING_KEYS, "canonicalKey"]);
253
+ }
254
+ return new Set([...DELTA_ELIGIBLE_IDENTITY_KEYS, ...DELTA_ELIGIBLE_BINDING_KEYS]);
255
+ }
256
+ /**
257
+ * Filters a stored entry to its expected delta surface, stripping any field outside the allowed shape. Returns both the filtered entry and the names of any
258
+ * fields that were stripped, so callers can surface diagnostics.
259
+ *
260
+ * The shape rule is enforced here at storage-write time (called from normalizeChannelDeltas) so legacy data with orphan fields - non-delta-eligible identity
261
+ * (pacificStationId), non-delta-eligible binding (DOM hooks), or identity-on-variants - cleans up automatically on next save. The `stripped` array lets the
262
+ * caller log a warning when cleanup happens, giving operators visibility into the violation.
263
+ * @param key - The channel key for shape classification.
264
+ * @param stored - The stored entry to filter.
265
+ * @returns The filtered entry plus the list of stripped field names.
266
+ */
267
+ function filterToDeltaSurface(key, stored) {
268
+ const allowed = getAllowedFieldsForShape(key, stored);
269
+ const filtered = {};
270
+ const stripped = [];
271
+ for (const [field, value] of Object.entries(stored)) {
272
+ if (allowed.has(field)) {
273
+ filtered[field] = value;
274
+ }
145
275
  else {
146
- // User channels: strip null fields. Null is a delta convention for predefined channels ("clear this field") and has no meaning on full Channel
147
- // definitions...the Channel type uses T | undefined, never T | null. This allows callers to uniformly use null for "empty/clear" without needing to
148
- // know the storage convention.
149
- filtered[key] = Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
276
+ stripped.push(field);
277
+ }
278
+ }
279
+ return { filtered, stripped };
280
+ }
281
+ /**
282
+ * Resolves a variant by layering canonical identity, the predefined variant entry (service binding), and the user delta in order. The canonical contributes
283
+ * ONLY identity (name, stationId, channelNumber, tags, etc.) - its service binding is for the canonical service and would be structurally wrong for any other
284
+ * service variant. The variant's binding is supplied by the predefined variant entry (typically: discovery-driven channelSelector, service-specific URL,
285
+ * profile overrides) and the user's stored delta.
286
+ *
287
+ * Fields propagate through the delta-overlay kernel so null-means-clear and undefined-means-inherit semantics apply uniformly at every layer.
288
+ *
289
+ * Precondition: at least one of predefined or stored must be defined. A variant with no binding source has nothing to resolve; the result would have no URL
290
+ * and no selector, and downstream consumers would fail at the consumption site. classifyEntry only returns a "variant" classification when at least one
291
+ * source carries canonicalKey, so the precondition is satisfied by the only in-tree caller (getMergedChannelMap). Other callers must guarantee the same.
292
+ * @param canonical - The resolved canonical channel. Variant identity inherits from here; canonical binding is intentionally stripped via pickIdentity.
293
+ * @param predefined - The predefined variant entry, if the key has one. Contributes service-binding fields and any per-variant channelNumber override.
294
+ * @param stored - The user's stored delta for this variant, if any. Applied last so user edits win over predefined variant fields.
295
+ * @returns The fully resolved variant.
296
+ */
297
+ function resolveVariant(canonical, predefined, stored) {
298
+ // Identity-only base. The url field (required on ResolvedChannel) is supplied by the predefined variant or stored delta below; the cast acknowledges that
299
+ // intermediate state. After the chain completes, the result has url populated when at least one of the binding-bearing layers (predefined or stored) is
300
+ // present - which is the documented invariant for any well-formed variant entry.
301
+ let resolved = pickIdentity(canonical);
302
+ // Variant overlays apply binding fields only - identity stays from the canonical per the architectural principle that variants are pure tuning data. Both
303
+ // the predefined variant and the user's stored variant entry use the same binding-only overlay.
304
+ if (predefined) {
305
+ resolved = overlayVariantBinding(resolved, predefined);
306
+ }
307
+ if (stored) {
308
+ resolved = overlayVariantBinding(resolved, stored);
309
+ }
310
+ return resolved;
311
+ }
312
+ /* Dangling-canonical warning dedup. When a variant's canonicalKey points at a missing canonical, getMergedChannelMap preserves the variant as best it can but
313
+ * the user deserves a diagnostic - their data references something that is not there, and silently carrying on would hide a real data-integrity issue. We log
314
+ * once per (variant, canonical) pair to surface the problem without spamming the log on every mutation. When the canonical later reappears (user re-creates
315
+ * it, predefined gets restored), the pair is cleared from the set so a future regression re-warns.
316
+ */
317
+ const warnedDanglingVariants = new Set();
318
+ /**
319
+ * Composes a stable token identifying a dangling-variant relationship for the warning dedup set.
320
+ * @param variantKey - The variant channel key.
321
+ * @param canonicalKey - The canonical key the variant references.
322
+ * @returns The composite token.
323
+ */
324
+ function danglingToken(variantKey, canonicalKey) {
325
+ return variantKey + " -> " + canonicalKey;
326
+ }
327
+ /**
328
+ * Emits a dangling-canonical warning at most once per (variant, canonical) pair for the lifetime of this process. Called from getMergedChannelMap whenever a
329
+ * variant references a canonical that is not present in the resolved map.
330
+ * @param variantKey - The variant key whose canonicalKey points at a missing canonical.
331
+ * @param canonicalKey - The missing canonical key.
332
+ */
333
+ function warnDanglingCanonical(variantKey, canonicalKey) {
334
+ const token = danglingToken(variantKey, canonicalKey);
335
+ if (warnedDanglingVariants.has(token)) {
336
+ return;
337
+ }
338
+ warnedDanglingVariants.add(token);
339
+ LOG.warn("Channel variant '%s' references missing canonical '%s'. Preserving the variant as-is; restore the canonical or remove the variant to clear this.", variantKey, canonicalKey);
340
+ }
341
+ /**
342
+ * Builds the Pass 1 resolved-canonical map for a given stored map. Iterates every key present in either the predefined catalog or the stored map, resolves
343
+ * non-variant entries (canonicals and standalones), and returns the resulting ChannelMap. Variants are deliberately skipped - they depend on this map and are
344
+ * resolved in Pass 2 by the caller.
345
+ *
346
+ * This is shared by getMergedChannelMap (which uses the map directly as the starting point for its full resolution) and normalizeChannelDeltas (which uses the
347
+ * map as a source of bases for diffing variant deltas).
348
+ * @param stored - The stored channels map.
349
+ * @returns The resolved-canonical map, keyed by channel key.
350
+ */
351
+ function buildResolvedCanonicals(stored) {
352
+ const result = {};
353
+ const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(stored)]);
354
+ for (const key of allKeys) {
355
+ const classification = classifyEntry(key, stored[key]);
356
+ if (classification.kind === "variant") {
357
+ continue;
358
+ }
359
+ if (classification.kind === "canonical") {
360
+ result[key] = classification.stored ? overlayDelta(classification.predefined, classification.stored) : classification.predefined;
361
+ continue;
362
+ }
363
+ // Standalone: defensive copy so downstream mutation cannot leak back into loadedUserChannels. A standalone is structurally equivalent to a CanonicalChannel
364
+ // (identity + binding, no canonicalKey).
365
+ const copy = { ...classification.stored };
366
+ copy.tags &&= copy.tags.slice();
367
+ result[key] = copy;
368
+ }
369
+ return result;
370
+ }
371
+ /**
372
+ * Strips null fields from a stored entry. Null is a delta-only convention meaning "clear this field on the base"; without a base to clear against, nulls
373
+ * have no meaning and get dropped. Used by normalizeChannelDeltas for standalone user channels and for dangling-canonical variants that fall back to
374
+ * standalone-style normalization.
375
+ * @param stored - The raw stored entry.
376
+ * @returns The entry with null-valued fields removed.
377
+ */
378
+ function stripNulls(stored) {
379
+ return Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
380
+ }
381
+ /**
382
+ * Normalizes the stored channels map to its minimal delta form. Each entry is classified, diffed against its applicable base, and any no-op fields are
383
+ * stripped. Standalone user channels have no base, so nulls are stripped but everything else is preserved.
384
+ *
385
+ * The flow is uniform across kinds:
386
+ *
387
+ * - Canonical (predefined with user override): base is the predefined channel.
388
+ * - Variant: base is the resolved canonical layered with the predefined variant's service fields (the view the user sees in the form/table, so the delta
389
+ * records only what differs from that).
390
+ * - Standalone: no base, null values are stripped as a storage convention.
391
+ *
392
+ * Entries whose normalized delta is empty collapse to nothing and get dropped - a redundant override carries no information, and resolution falls through to
393
+ * the default.
394
+ * @param channels - The raw channel entries to normalize.
395
+ * @returns The normalized channel map.
396
+ */
397
+ function normalizeChannelDeltas(channels) {
398
+ const filtered = {};
399
+ const resolvedCanonicals = buildResolvedCanonicals(channels);
400
+ for (const [key, stored] of Object.entries(channels)) {
401
+ // Step 1: enforce the expected stored shape via filterToDeltaSurface. Strips orphan fields (non-delta-eligible identity, non-delta-eligible binding,
402
+ // identity-on-variants) before any diff computation. When fields are stripped, log a warning so operators see when their data is being cleaned. The shape
403
+ // rule is the same regardless of classification kind - canonicalKey-discriminated through filterToDeltaSurface internally.
404
+ const { filtered: shapeFiltered, stripped } = filterToDeltaSurface(key, stored);
405
+ if (stripped.length > 0) {
406
+ const isVariant = classifyEntry(key, stored).kind === "variant";
407
+ LOG.warn("Channel '%s' (%s-shaped) carried fields outside the delta surface: [%s]. Stripped during normalization.", key, isVariant ? "variant" : "canonical", stripped.join(", "));
408
+ }
409
+ // Step 2: classification-driven delta minimization against the appropriate base.
410
+ const classification = classifyEntry(key, shapeFiltered);
411
+ if (classification.kind === "canonical") {
412
+ const normalized = normalizeEntryAgainstBase(shapeFiltered, classification.predefined);
413
+ if (normalized) {
414
+ filtered[key] = normalized;
415
+ }
416
+ continue;
417
+ }
418
+ if (classification.kind === "standalone") {
419
+ filtered[key] = stripNulls(shapeFiltered);
420
+ continue;
421
+ }
422
+ // Variant. The normalization base is identity-from-canonical layered with the predefined variant's binding (if any) - the same shape the user sees in the
423
+ // form/table, so the delta records only what differs from that. Critically, this matches the inheritance shape used by resolveVariant: identity from
424
+ // canonical, binding from variant. Without the pickIdentity strip, the canonical service's binding would leak into the normalization base and stored
425
+ // overrides matching the canonical's binding would be incorrectly stripped as no-ops. A dangling canonical falls back to standalone-style normalization so
426
+ // the user does not silently lose data.
427
+ const canonical = resolvedCanonicals[classification.canonicalKey];
428
+ if (!canonical) {
429
+ filtered[key] = stripNulls(shapeFiltered);
430
+ continue;
431
+ }
432
+ const identityBase = pickIdentity(canonical);
433
+ // Mirror the resolveVariant chain: predefined-variant overlay applies binding fields only. The base computed here is what a stored variant entry should
434
+ // be diffed against - using a category-aware overlay keeps normalization symmetric with resolution.
435
+ const base = classification.predefined ? overlayVariantBinding(identityBase, classification.predefined) : identityBase;
436
+ const normalized = normalizeEntryAgainstBase(shapeFiltered, base);
437
+ if (normalized) {
438
+ filtered[key] = normalized;
150
439
  }
151
440
  }
152
441
  return filtered;
@@ -159,24 +448,133 @@ function normalizeChannelDeltas(channels) {
159
448
  * @returns The serializable output with channels and current metadata.
160
449
  */
161
450
  function prepareChannelsForWrite(data) {
162
- // Build the serializable output with metadata from module state.
163
- const output = { ...data.channels };
164
- const selections = getServiceSelections();
165
- if (Object.keys(selections).length > 0) {
166
- output.serviceSelections = selections;
451
+ // Pure function of data. The framework guarantees data is the source of truth at write time - serviceSelections and tagRegistry come from data, never from
452
+ // module state. Module-state caches in services.ts and userChannels.ts are downstream of this writer (hydrated post-mutate from the same data), so reading
453
+ // them here would be a side channel that breaks correctness when the boot persist runs before module state is populated.
454
+ const output = { schemaVersion: data.schemaVersion, ...data.channels };
455
+ if (data.migrationsApplied.length > 0) {
456
+ output["migrationsApplied"] = data.migrationsApplied;
167
457
  }
168
- if ((loadedTagRegistry.tags.length > 0) || (loadedTagRegistry.deletedTags.length > 0)) {
169
- output.tagRegistry = loadedTagRegistry;
458
+ if (Object.keys(data.serviceSelections).length > 0) {
459
+ output["serviceSelections"] = data.serviceSelections;
460
+ }
461
+ if ((data.tagRegistry.tags.length > 0) || (data.tagRegistry.deletedTags.length > 0)) {
462
+ output["tagRegistry"] = data.tagRegistry;
170
463
  }
171
464
  return output;
172
465
  }
466
+ /* Declarative schema migrations. The file store framework runs these in order from the file's stored schemaVersion up to CURRENT_CHANNELS_SCHEMA_VERSION,
467
+ * stamping the new version and recording the description in migrationsApplied after each application. Apply functions mutate the data in place. Any new
468
+ * migration is a single entry below; the framework wires the rest.
469
+ */
470
+ const channelsMigrations = {
471
+ 2: {
472
+ apply: (data) => {
473
+ // Stamp canonicalKey on hyphenated user channel entries whose identity matches a predefined canonical (shape-compatible variants). Conservative
474
+ // classifier - entries whose identity diverges are treated as user standalones and left alone.
475
+ const stampedKeys = collectLegacyVariantStamps(data.channels);
476
+ for (const key of stampedKeys) {
477
+ const channel = data.channels[key];
478
+ if (!channel) {
479
+ continue;
480
+ }
481
+ const hyphenIndex = key.indexOf("-");
482
+ channel.canonicalKey = key.substring(0, hyphenIndex);
483
+ }
484
+ },
485
+ description: "Stamp canonicalKey on legacy user channel variant entries"
486
+ },
487
+ 3: {
488
+ apply: (data) => {
489
+ // Rename foxcom -> foxone in service selections, the special-case fox-site -> fox-foxone, and corresponding channel keys. Bumping to v3 also flushes
490
+ // any parser-side legacy field cleanups (provider -> service field, providerSelections -> serviceSelections key) since the migration runner triggers a
491
+ // persist that writes the cleaned shape via prepareChannelsForWrite.
492
+ for (const [canonicalKey, selectedVariant] of Object.entries(data.serviceSelections)) {
493
+ if (selectedVariant.endsWith("-foxcom")) {
494
+ data.serviceSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
495
+ }
496
+ if ((canonicalKey === "fox") && (selectedVariant === "fox-site")) {
497
+ data.serviceSelections[canonicalKey] = "fox-foxone";
498
+ }
499
+ }
500
+ for (const [key, value] of Object.entries(data.channels)) {
501
+ if (key.endsWith("-foxcom")) {
502
+ data.channels[key.slice(0, -6) + "foxone"] = value;
503
+ Reflect.deleteProperty(data.channels, key);
504
+ }
505
+ }
506
+ },
507
+ description: "Rename foxcom service references to foxone and persist legacy field name cleanup"
508
+ }
509
+ };
510
+ /**
511
+ * Pre-write integrity validator for the channels store. Detects two classes of suspicious mutations:
512
+ *
513
+ * 1. Identity-field loss: a stored channel's identity field went from set to undefined without canonical fallback. Catches code that
514
+ * accidentally drops user-authored channel data while normalizing or transforming entries.
515
+ * 2. Metadata wholesale clear: a top-level metadata collection (serviceSelections, tagRegistry.tags, tagRegistry.deletedTags) went from non-empty to empty
516
+ * in a single mutation. Catches code that accidentally drops the entire collection (the original bug class - if a future writer or fn regression empties
517
+ * one of these, this guard logs loudly even though the change is structurally allowed).
518
+ *
519
+ * Both checks are log-only - issues surface as warnings without blocking the write. Genuine wholesale clears (operator clears all selections via UI) are the
520
+ * rare false-positive case here; in practice those happen via per-entry mutations rather than a single empty assignment.
521
+ * @param prev - Pre-mutation snapshot of the file data.
522
+ * @param next - Post-mutation, post-normalize file data.
523
+ * @returns Issues found, or empty array.
524
+ */
525
+ function validateChannelsIntegrity(prev, next) {
526
+ const issues = [];
527
+ for (const loss of detectIdentityFieldLoss(prev.channels, next.channels)) {
528
+ issues.push({
529
+ category: "identity-field-loss",
530
+ description: loss + " would be silently cleared without canonical fallback",
531
+ severity: "warning"
532
+ });
533
+ }
534
+ // Metadata wholesale-clear detection. Checks each top-level collection independently. Empty -> empty is a no-op (no issue); non-empty -> non-empty (any
535
+ // size) is a normal mutation; only non-empty -> empty is flagged.
536
+ if ((Object.keys(prev.serviceSelections).length > 0) && (Object.keys(next.serviceSelections).length === 0)) {
537
+ issues.push({
538
+ category: "metadata-wholesale-clear",
539
+ description: "serviceSelections went from " + String(Object.keys(prev.serviceSelections).length) + " entries to empty in a single mutation",
540
+ severity: "warning"
541
+ });
542
+ }
543
+ if ((prev.tagRegistry.tags.length > 0) && (next.tagRegistry.tags.length === 0)) {
544
+ issues.push({
545
+ category: "metadata-wholesale-clear",
546
+ description: "tagRegistry.tags went from " + String(prev.tagRegistry.tags.length) + " entries to empty in a single mutation",
547
+ severity: "warning"
548
+ });
549
+ }
550
+ if ((prev.tagRegistry.deletedTags.length > 0) && (next.tagRegistry.deletedTags.length === 0)) {
551
+ issues.push({
552
+ category: "metadata-wholesale-clear",
553
+ description: "tagRegistry.deletedTags went from " + String(prev.tagRegistry.deletedTags.length) + " entries to empty in a single mutation",
554
+ severity: "warning"
555
+ });
556
+ }
557
+ return issues;
558
+ }
173
559
  // Transactional store instance for channels.json.
174
560
  const channelsStore = createFileStore({
175
561
  beforeWrite: prepareChannelsForWrite,
176
- defaultValue: () => ({ channels: {}, serviceSelections: {}, tagRegistry: { deletedTags: [], tags: [] } }),
562
+ currentSchemaVersion: CURRENT_CHANNELS_SCHEMA_VERSION,
563
+ defaultValue: () => ({
564
+ channels: {},
565
+ migrationsApplied: [],
566
+ schemaVersion: CURRENT_CHANNELS_SCHEMA_VERSION,
567
+ serviceSelections: {},
568
+ tagRegistry: { deletedTags: [], tags: [] }
569
+ }),
570
+ getSchemaVersion: (data) => data.schemaVersion,
177
571
  label: "channels",
572
+ migrations: channelsMigrations,
178
573
  parse: parseChannelsFile,
179
- path: getChannelsFilePath
574
+ path: getChannelsFilePath,
575
+ recordMigration: (data, description) => { data.migrationsApplied.push(description); },
576
+ setSchemaVersion: (data, version) => { data.schemaVersion = version; },
577
+ validate: validateChannelsIntegrity
180
578
  });
181
579
  /**
182
580
  * Reads the current channels from disk without acquiring the serialization lock. Returns the parsed channels with metadata and parse status. Use this for
@@ -189,30 +587,98 @@ export async function readChannels() {
189
587
  channels: result.data.channels,
190
588
  parseError: result.parseError,
191
589
  parseErrorMessage: result.parseErrorMessage,
590
+ recoveredFromBackup: result.recoveredFromBackup,
192
591
  serviceSelections: result.data.serviceSelections,
193
592
  tagRegistry: result.data.tagRegistry
194
593
  };
195
594
  }
196
595
  /**
197
- * Serialized read-modify-write operation on channels.json. The mutation function receives the current StoredChannelMap and modifies it in place. The store
198
- * handles atomicity, serialization, corruption guard, backup, and metadata injection. Delta normalization is applied after the caller's mutation and before the
199
- * file write so that the normalized result is available for the post-write cache update.
200
- * @param fn - Mutation function. Receives current channels. Modify in place; return value is ignored.
201
- * @throws FileStoreParseError if channels.json contains invalid JSON.
596
+ * Pre-write integrity guard. Compares the channels map captured immediately after parse against the channels map about to be written, and reports identity
597
+ * fields that have been silently dropped without a canonical fallback.
598
+ *
599
+ * Allowed transitions for any identity field:
600
+ * - value -> same value (no change)
601
+ * - value -> different value (legitimate update)
602
+ * - value -> null (explicit clear; null is the codebase-wide "clear this field" signal)
603
+ * - field absent -> any value (new data)
604
+ *
605
+ * Forbidden transition (what this guard catches):
606
+ * - value -> undefined / missing WHERE the canonical (for variants and predefined-overrides) does not provide the same value
607
+ *
608
+ * Variant inheritance is honored: stripping a stored field that matches the canonical's value is delta minimization, not data loss, and is allowed. The check
609
+ * is non-blocking: it surfaces suspicious patterns for operator review without aborting legitimate code paths.
610
+ * @param before - The channels map captured immediately after parse, before the caller's mutation ran.
611
+ * @param after - The channels map after the caller's mutation and delta normalization.
612
+ * @returns A list of "key.field" strings naming each detected silent drop. Empty list means no losses.
613
+ */
614
+ function detectIdentityFieldLoss(before, after) {
615
+ const losses = [];
616
+ for (const [key, beforeEntry] of Object.entries(before)) {
617
+ const afterEntry = after[key];
618
+ // Whole entry removed - this is intentional deletion (deleteUserChannel etc.), not a silent drop.
619
+ if (!afterEntry) {
620
+ continue;
621
+ }
622
+ // Determine the canonical fallback. For variants the canonicalKey field points at it; for overrides of predefined channels the entry's key is the
623
+ // canonical. For standalones (no predefined match, no canonicalKey) there is no fallback - any drop is a loss.
624
+ const canonicalKey = afterEntry.canonicalKey ?? key;
625
+ const canonical = PREDEFINED_CHANNELS[canonicalKey];
626
+ for (const field of CHANNEL_IDENTITY_KEYS) {
627
+ const beforeValue = beforeEntry[field];
628
+ const afterValue = afterEntry[field];
629
+ // Field was not set before - any post state is fine.
630
+ if ((beforeValue === undefined) || (beforeValue === null)) {
631
+ continue;
632
+ }
633
+ // Field is still present after (with any value, including null which means explicit clear) - allowed.
634
+ if (afterValue !== undefined) {
635
+ continue;
636
+ }
637
+ // Field went value -> undefined. Check whether the canonical provides the same value, in which case this is variant inheritance / delta minimization
638
+ // and not data loss. Array-valued fields (notably tags) are compared via JSON.stringify since reference equality would always fail.
639
+ const canonicalValue = canonical ? canonical[field] : undefined;
640
+ if (canonicalValue !== undefined) {
641
+ const sameValue = (Array.isArray(beforeValue) && Array.isArray(canonicalValue)) ?
642
+ isDeepStrictEqual(beforeValue, canonicalValue) :
643
+ (beforeValue === canonicalValue);
644
+ if (sameValue) {
645
+ continue;
646
+ }
647
+ }
648
+ losses.push(key + "." + field);
649
+ }
650
+ }
651
+ return losses;
652
+ }
653
+ /**
654
+ * Serialized read-modify-write operation on channels.json. The mutation function receives the full file data (channels + serviceSelections + tagRegistry +
655
+ * framework metadata) and modifies it in place. The store handles atomicity, serialization, corruption guard, backup, schema migrations, and pre-write
656
+ * integrity validation. Delta normalization is applied to data.channels after the caller's mutation and before the file write so that the normalized result is
657
+ * available for the post-write cache hydration.
658
+ *
659
+ * After a successful write, the in-memory caches that mirror persisted state (loadedUserChannels, loadedTagRegistry, services.ts serviceSelections Map,
660
+ * runtime service groups) are hydrated from the just-written normalized data. This is the single direction caches flow: data is the source of truth, caches
661
+ * are derived. Callers must never poke caches directly and rely on a later save to pick up the change - all mutations go through this function.
662
+ * @param fn - Mutation function. Receives the full file data. Modify in place; return value is ignored.
663
+ * @throws FileStoreParseError if channels.json contains invalid JSON and the .bak rotation is also unparseable.
202
664
  */
203
665
  export async function mutateChannels(fn) {
204
- // Normalized channels captured from inside the mutation callback for post-write cache update. Normalization runs inside the callback (under the
205
- // serialization lock) so the same normalized data is written to disk and assigned to the cache.
206
- let normalizedChannels = {};
666
+ // Captured from inside the callback so the post-write cache hydration sees the same normalized data the framework wrote to disk. Captured before the await
667
+ // returns, but the assignments below only run if the store mutation (including atomic file write) completed without error.
668
+ let writtenData;
207
669
  await channelsStore.mutate((data) => {
208
- fn(data.channels);
209
- // Normalize deltas before the beforeWrite hook serializes the data. This ensures the in-memory cache and the on-disk representation are identical.
210
- // The beforeWrite hook (prepareChannelsForWrite) handles metadata injection only.
670
+ fn(data);
671
+ // Normalize channel deltas before the beforeWrite hook serializes the data. This ensures the in-memory cache and the on-disk representation are
672
+ // identical. Other top-level fields (serviceSelections, tagRegistry, framework metadata) are not delta-shaped and pass through unchanged.
211
673
  data.channels = normalizeChannelDeltas(data.channels);
212
- normalizedChannels = data.channels;
674
+ writtenData = data;
213
675
  });
214
- // Side effects after successful write. These only run if the store mutation (including atomic file write) completed without error.
215
- loadedUserChannels = { ...normalizedChannels };
676
+ // Side effects after successful write. The store framework guarantees writtenData reflects exactly what was persisted.
677
+ if (writtenData) {
678
+ loadedUserChannels = { ...writtenData.channels };
679
+ loadedTagRegistry = writtenData.tagRegistry;
680
+ setServiceSelections(writtenData.serviceSelections);
681
+ }
216
682
  buildServiceGroups(getMergedChannelMap());
217
683
  userChannelsParseError = false;
218
684
  userChannelsParseErrorMessage = undefined;
@@ -224,11 +690,48 @@ export async function mutateChannels(fn) {
224
690
  * @throws If the file cannot be written.
225
691
  */
226
692
  export async function deleteUserChannel(key) {
227
- await mutateChannels((channels) => {
228
- Reflect.deleteProperty(channels, key);
693
+ await mutateChannels((data) => {
694
+ Reflect.deleteProperty(data.channels, key);
229
695
  });
230
696
  LOG.info("User channel '%s' deleted.", key);
231
697
  }
698
+ /**
699
+ * Removes all user override entries for a canonical channel - both the canonical-stored entry and the active service variant's stored entry (when one resolves
700
+ * via explicit selection or service-filter fallback). Returns the stored entry that was deleted (canonical takes precedence; falls back to the variant entry
701
+ * when canonical is absent) so callers can compute downstream effects like the playlist reload hint without duplicating the lookup.
702
+ *
703
+ * Callers wrap the call in mutateChannels() and pass the resulting StoredChannelMap. The helper operates on the map directly; it does not own the mutation
704
+ * lifecycle, allowing callers to combine the dual-delete with other operations (e.g., setting serviceSelections) in the same atomic write.
705
+ * @param channels - The stored channels map to mutate in place.
706
+ * @param canonicalKey - The canonical channel key. The active variant is resolved internally via resolveServiceKey.
707
+ * @returns The stored entry that existed pre-deletion (canonical first, variant fallback). Undefined when neither entry existed.
708
+ */
709
+ export function clearChannelOverrides(channels, canonicalKey) {
710
+ const resolvedKey = resolveServiceKey(canonicalKey);
711
+ const activeVariantKey = (resolvedKey === canonicalKey) ? undefined : resolvedKey;
712
+ // Canonical takes precedence for the returned entry because the canonical entry is the editing target; the variant entry is secondary cleanup. Falls back
713
+ // to the variant entry only when canonical is absent so callers still see a non-undefined result for variant-only revert cases.
714
+ const stored = channels[canonicalKey] ?? (activeVariantKey ? channels[activeVariantKey] : undefined);
715
+ Reflect.deleteProperty(channels, canonicalKey);
716
+ if (activeVariantKey) {
717
+ Reflect.deleteProperty(channels, activeVariantKey);
718
+ }
719
+ return stored;
720
+ }
721
+ /**
722
+ * Merges a partial ChannelDelta into a stored entry. Returns a new entry with the existing fields preserved and the delta's fields layered on top. Used by
723
+ * bulk operations that update one or more delta fields across many channels - the helper handles both the "no entry exists yet" case (uses an empty record)
724
+ * and the "entry exists with prior overrides" case (preserves them) without forcing each call site to write the spread + assign manually.
725
+ *
726
+ * The merged entry is returned in StoredChannel shape; callers assign it back to data.channels[key]. The post-mutation normalizer (filterToDeltaSurface +
727
+ * normalizeEntryAgainstBase) handles delta minimization and shape enforcement, so callers can pass arbitrary ChannelDelta keys without pre-validating them.
728
+ * @param stored - The existing stored entry, or undefined when the channel has no prior overrides.
729
+ * @param delta - Fields to assign onto the entry. Null values flow through unchanged for the delta-clear semantic.
730
+ * @returns The merged stored entry.
731
+ */
732
+ export function applyChannelDelta(stored, delta) {
733
+ return { ...stored, ...delta };
734
+ }
232
735
  /**
233
736
  * Resets all user channels by deleting the channels file.
234
737
  * @throws If the file exists but cannot be deleted.
@@ -249,64 +752,81 @@ export async function resetUserChannels() {
249
752
  }
250
753
  /* User channels are loaded at server startup and stored in module-level state. This avoids repeated file reads during request handling.
251
754
  */
755
+ /**
756
+ * Classifies which stored entries are legitimate legacy variants eligible for canonicalKey stamping. A legitimate legacy variant has a hyphenated key whose
757
+ * prefix matches a predefined canonical AND every stored identity field either is absent or matches the canonical's value. Entries with diverging identity
758
+ * are user-created standalones that happen to share the hyphenated key shape (e.g., "abc-kabc" as a local affiliate with its own channel number and station
759
+ * ID) and must be left alone - stamping them would mark them for delta normalization and silently destroy the user's custom identity.
760
+ *
761
+ * Array-valued identity fields (tags) compare via JSON.stringify with case-insensitive canonical sorting so historical write order does not defeat the match.
762
+ * @param channels - The raw stored channel entries.
763
+ * @returns Keys that should receive a canonicalKey stamp. Empty when nothing needs migrating.
764
+ */
765
+ function collectLegacyVariantStamps(channels) {
766
+ const stamps = [];
767
+ for (const [key, stored] of Object.entries(channels)) {
768
+ // Entries that already declare canonicalKey are already on the new model - skip.
769
+ if (stored.canonicalKey) {
770
+ continue;
771
+ }
772
+ const hyphenIndex = key.indexOf("-");
773
+ if (hyphenIndex === -1) {
774
+ continue;
775
+ }
776
+ const prefix = key.substring(0, hyphenIndex);
777
+ const canonical = PREDEFINED_CHANNELS[prefix];
778
+ if (!canonical) {
779
+ continue;
780
+ }
781
+ // Safe classifier: stamp only when every stored identity field is either absent or matches the canonical exactly. Any divergence signals a user-created
782
+ // standalone that happens to share the variant-shaped key; stamping would trigger delta normalization and destroy the user's custom identity.
783
+ let shapeCompatible = true;
784
+ for (const field of CHANNEL_IDENTITY_KEYS) {
785
+ const storedValue = stored[field];
786
+ if (storedValue === undefined) {
787
+ continue;
788
+ }
789
+ const canonicalValue = canonical[field];
790
+ if (Array.isArray(storedValue) && Array.isArray(canonicalValue)) {
791
+ if (!isDeepStrictEqual(sortTags(storedValue), sortTags(canonicalValue))) {
792
+ shapeCompatible = false;
793
+ break;
794
+ }
795
+ continue;
796
+ }
797
+ if (storedValue !== canonicalValue) {
798
+ shapeCompatible = false;
799
+ break;
800
+ }
801
+ }
802
+ if (shapeCompatible) {
803
+ stamps.push(key);
804
+ }
805
+ }
806
+ return stamps;
807
+ }
252
808
  /**
253
809
  * Initializes user channels by loading them from the file. This should be called once at server startup. Also builds service groups and loads service selections.
254
810
  */
255
811
  export async function initializeUserChannels() {
812
+ // Read the file. Schema migrations (canonicalKey stamping, foxcom -> foxone rename, etc.) run automatically inside the file store framework via the
813
+ // declarative channelsMigrations registry; ensureMigrated (called by the release boot coordinator at startup) persists any upgrades to disk before this
814
+ // function runs. The data returned here is always at CURRENT_CHANNELS_SCHEMA_VERSION.
256
815
  const result = await readChannels();
257
- // Populate module-level state from the loaded file. Migrations below may call mutateChannels(), which updates loadedUserChannels via its side effects
258
- // with normalized data. Setting the initial state here ensures the cache is populated even when no migrations run.
816
+ // Populate module-level state from the loaded (and already-migrated) data.
259
817
  loadedUserChannels = result.channels;
260
818
  loadedTagRegistry = result.tagRegistry;
261
819
  userChannelsParseError = result.parseError;
262
820
  userChannelsParseErrorMessage = result.parseErrorMessage;
263
- // Silent migrations: rename stale service keys to their current equivalents. Migrates service selections (channels.json) and user channel variant keys.
264
- // The service filter (config.json) is handled separately below since it's already loaded into CONFIG at this point.
265
- let channelsMigrated = false;
266
- for (const [canonicalKey, selectedVariant] of Object.entries(result.serviceSelections)) {
267
- // foxcom → foxone: original Fox service slug renamed.
268
- if (selectedVariant.endsWith("-foxcom")) {
269
- result.serviceSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
270
- channelsMigrated = true;
271
- }
272
- // fox-site → fox-foxone: the "fox" channel's FoxOne variant was briefly keyed as "site" in v1.8.0 instead of "foxone" like every other Fox channel.
273
- if ((canonicalKey === "fox") && (selectedVariant === "fox-site")) {
274
- result.serviceSelections[canonicalKey] = "fox-foxone";
275
- channelsMigrated = true;
276
- }
277
- }
278
- // Load service selections before saving so that mutateChannels (which persists both channels and selections via the beforeWrite hook) captures the
279
- // migrated values from module state.
280
- setServiceSelections(result.serviceSelections);
281
- if (channelsMigrated) {
282
- await mutateChannels((channels) => {
283
- // Apply the foxcom → foxone channel key migration. The mutation reads fresh from disk, so we replay the transform rather than passing the in-memory
284
- // result. The fox-site selection migration is selection-only (no channel keys to rename).
285
- for (const key of Object.keys(channels)) {
286
- if (key.endsWith("-foxcom")) {
287
- channels[key.slice(0, -6) + "foxone"] = channels[key];
288
- Reflect.deleteProperty(channels, key);
289
- }
290
- }
291
- });
292
- LOG.info("Migrated stale Fox service references.");
293
- }
294
- // Load enabled services from the configuration, validating that each tag is recognized. Invalid tags (e.g., from hand-edited config.json typos) are stripped
295
- // silently after logging a warning. Validation must happen after buildServiceGroups() because getAllServiceTags() depends on the groups being built.
296
- let configuredServices = CONFIG.channels.enabledServices;
297
- // Silent migration: rename "foxcom" to "foxone" in the service filter if present. Persisted to config.json immediately so the stale value doesn't remain.
298
- if (configuredServices.includes("foxcom")) {
299
- configuredServices = configuredServices.map((tag) => (tag === "foxcom") ? "foxone" : tag);
300
- CONFIG.channels.enabledServices = configuredServices;
301
- await mutateConfig((config) => {
302
- if (config.channels?.enabledServices) {
303
- config.channels.enabledServices = configuredServices;
304
- }
305
- });
306
- LOG.info("Migrated service filter from foxcom to foxone.");
821
+ if (result.recoveredFromBackup) {
822
+ LOG.info("Channels were recovered from backup after a corrupt main file.");
307
823
  }
308
- // Upgrade inference for setupCompleted: existing users who already have services or channels configured should not see the first-run setup wizard. If the
309
- // flag is not set in the config file and evidence of prior configuration exists, infer true and persist.
824
+ // Load service selections so prepareChannelsForWrite captures them on subsequent writes.
825
+ setServiceSelections(result.serviceSelections);
826
+ // Inference for setupCompleted: existing users who already have services or channels configured should not see the first-run setup wizard. This is runtime
827
+ // inference (not a schema migration) because it depends on observed state from two stores (channels and config) and never changes the file shape - only
828
+ // sets a flag based on what exists. Lives here at the cross-store boundary where both stores are loaded.
829
+ const configuredServices = CONFIG.channels.enabledServices;
310
830
  if (!CONFIG.channels.setupCompleted) {
311
831
  const hasServices = configuredServices.length > 0;
312
832
  const hasUserChannels = Object.keys(loadedUserChannels).length > 0;
@@ -318,97 +838,35 @@ export async function initializeUserChannels() {
318
838
  });
319
839
  }
320
840
  }
321
- // One-time migration: stamp canonicalKey on existing user channel variant entries that were created before explicit variant relationships were introduced.
322
- // These entries have hyphenated keys where the prefix is a known predefined canonical (e.g., "espn-hulu" where "espn" exists). The heuristic runs once —
323
- // new entries get canonicalKey written at creation time by the browse modal.
324
- let canonicalKeyMigrated = false;
325
- for (const [key, channel] of Object.entries(result.channels)) {
326
- // Skip entries that already have canonicalKey.
327
- if (channel.canonicalKey) {
328
- continue;
329
- }
330
- const hyphenIndex = key.indexOf("-");
331
- if (hyphenIndex === -1) {
332
- continue;
333
- }
334
- const prefix = key.substring(0, hyphenIndex);
335
- // Only stamp canonicalKey if the prefix exists as a predefined channel. This matches the old buildServiceGroups heuristic exactly.
336
- if (prefix in PREDEFINED_CHANNELS) {
337
- channel.canonicalKey = prefix;
338
- canonicalKeyMigrated = true;
339
- }
340
- }
341
- if (canonicalKeyMigrated) {
342
- await mutateChannels((channels) => {
343
- for (const [key, channel] of Object.entries(channels)) {
344
- if (channel.canonicalKey) {
345
- continue;
346
- }
347
- const hyphenIndex = key.indexOf("-");
348
- if (hyphenIndex === -1) {
349
- continue;
350
- }
351
- const prefix = key.substring(0, hyphenIndex);
352
- if (prefix in PREDEFINED_CHANNELS) {
353
- channel.canonicalKey = prefix;
354
- }
355
- }
356
- });
357
- LOG.info("Migrated user channel variant entries with explicit canonical key declarations.");
358
- }
359
- // One-time migration: strip identity fields from user channel variant entries. Identity fields (name, stationId, tags, etc.) are resolved from the canonical
360
- // at runtime via applyVariantInheritance, so storing them on variants is redundant. Older versions wrote these fields on variant creation. This migration
361
- // cleans them up so channels.json only contains service-specific fields on variant entries.
362
- let variantFieldsMigrated = false;
363
- for (const channel of Object.values(result.channels)) {
364
- if (!channel.canonicalKey) {
365
- continue;
366
- }
367
- for (const field of CHANNEL_IDENTITY_FIELDS) {
368
- if (field in channel) {
369
- Reflect.deleteProperty(channel, field);
370
- variantFieldsMigrated = true;
371
- }
372
- }
373
- }
374
- if (variantFieldsMigrated) {
375
- await mutateChannels((channels) => {
376
- for (const channel of Object.values(channels)) {
377
- if (!channel.canonicalKey) {
378
- continue;
379
- }
380
- for (const field of CHANNEL_IDENTITY_FIELDS) {
381
- if (field in channel) {
382
- Reflect.deleteProperty(channel, field);
383
- }
384
- }
385
- }
386
- });
387
- LOG.info("Stripped redundant identity fields from user channel variant entries.");
388
- }
389
841
  // Build the merged channels map and then build service groups.
390
842
  const mergedChannels = getMergedChannelMap();
391
- // buildServiceGroups validates stored service selections against the rebuilt variant structure and reverts any that are stale. If any were cleaned, persist
392
- // once so the cleanup survives restarts. At runtime (via mutateChannels), the in-memory cleanup is sufficient and persists naturally on the next write.
843
+ // buildServiceGroups validates stored service selections against the rebuilt variant structure and reverts any that are stale (cleared from module state).
844
+ // If any were cleaned, persist the same cleanup to disk so the file matches the runtime state and the cleanup survives restarts. mutateChannels operates on
845
+ // the freshly-read on-disk data; explicitly deleting the stale keys from data.serviceSelections is the data-side equivalent of the module-side cleanup
846
+ // buildServiceGroups already performed.
393
847
  const staleSelections = buildServiceGroups(mergedChannels);
394
848
  if (staleSelections.length > 0) {
395
- await saveServiceSelections();
849
+ await mutateChannels((data) => {
850
+ for (const key of staleSelections) {
851
+ Reflect.deleteProperty(data.serviceSelections, key);
852
+ }
853
+ });
396
854
  }
397
855
  // Now that service groups are built, validate the configured service tags. Strip any unrecognized tags and warn.
398
856
  if (configuredServices.length > 0) {
399
857
  const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
400
- const validTags = configuredServices.filter((tag) => knownTags.has(tag));
401
- const invalidTags = configuredServices.filter((tag) => !knownTags.has(tag));
858
+ const configuredSet = new Set(configuredServices);
859
+ const validTags = [...configuredSet.intersection(knownTags)];
860
+ const invalidTags = [...configuredSet.difference(knownTags)];
402
861
  if (invalidTags.length > 0) {
403
862
  LOG.warn("Ignoring unrecognized service tags in configuration: %s.", invalidTags.join(", "));
404
863
  }
405
864
  setEnabledServices(validTags);
406
- CONFIG.channels.enabledServices = validTags;
407
865
  }
408
866
  else {
409
867
  setEnabledServices(configuredServices);
410
868
  }
411
- // Check for non-printable characters in loaded channel string values. These warnings are informational — loaded data is not modified.
869
+ // Check for non-printable characters in loaded channel string values. These warnings are informational - loaded data is not modified.
412
870
  for (const [channelKey, stored] of Object.entries(loadedUserChannels)) {
413
871
  for (const [field, value] of Object.entries(stored)) {
414
872
  if ((typeof value === "string") && containsNonPrintable(value)) {
@@ -426,58 +884,173 @@ export async function initializeUserChannels() {
426
884
  LOG.info("Loaded %d channels.", totalCount);
427
885
  }
428
886
  }
429
- // User-editable fields for predefined channel delta overrides. Derived from CHANNEL_IDENTITY_FIELDS (identity fields like name, stationId, tags) plus the
430
- // service-specific fields exposed in the edit form (channelSelector, profile, url). This derivation ensures that adding a new identity field to
431
- // CHANNEL_IDENTITY_FIELDS automatically includes it in the delta allowlist.
432
- const SERVICE_SPECIFIC_EDITABLE_FIELDS = ["channelSelector", "profile", "url"];
433
- const DELTA_ALLOWED_FIELDS = new Set([...CHANNEL_IDENTITY_FIELDS, ...SERVICE_SPECIFIC_EDITABLE_FIELDS]);
887
+ // Variant overlay allowlist used by the resolver (overlayVariantBinding). The resolver runs against catalog data merged with user storage, and predefined
888
+ // variants legitimately carry DOM-hook binding fields (dismissSelector, scrollSelector, etc.) set by the catalog flattener. So the resolver's allowlist is the
889
+ // FULL binding partition, NOT the delta-eligible subset - this is correct domain asymmetry: the resolver accepts catalog-domain data, while storage filtering
890
+ // (filterToDeltaSurface) restricts to user-domain data. Identity fields encountered in a variant entry are silently dropped here - canonical's identity always
891
+ // wins by construction.
892
+ const VARIANT_OVERLAY_ALLOWED_FIELDS = new Set(CHANNEL_BINDING_KEYS);
893
+ // The runtime delta surface: every field that may legitimately appear in a stored ChannelDelta override. Derived from the partition arrays in types/channels.ts
894
+ // so adding or removing a delta-eligible field in one place propagates everywhere. Typed as Set<string> for ergonomics at .has() call sites that take string-keyed
895
+ // values from Object.entries iteration; the contents are statically-known CustomizableField values via the spread above.
896
+ const DELTA_ALLOWED_FIELDS = new Set([...DELTA_ELIGIBLE_IDENTITY_KEYS, ...DELTA_ELIGIBLE_BINDING_KEYS]);
434
897
  /**
435
- * Resolves a stored channel entry (full definition or delta) into a fully resolved Channel. For user-defined channels with no predefined equivalent, the stored
436
- * entry is returned as-is (it must be a full Channel). For overrides of predefined channels, the predefined definition is used as a base and only allowlisted
437
- * delta fields are overlaid. Fields set to null in the delta are removed from the result. Fields not in the allowlist are silently ignored.
438
- * @param key - The channel key.
439
- * @param stored - The stored channel data (full definition or delta).
440
- * @returns A fully resolved Channel with all fields populated.
898
+ * Extracts the identity-only subset of a channel. Used by resolveVariant and normalizeChannelDeltas to compute the inheritance base for variants: a variant
899
+ * inherits identity from its canonical but must contribute its own service binding (URL, channelSelector, profile, etc.) - the canonical's binding is for the
900
+ * canonical service and is structurally wrong for any other service.
901
+ *
902
+ * The fields copied are exactly those listed in CHANNEL_IDENTITY_KEYS, the single source of truth for the identity partition. Array-valued fields are shallow-
903
+ * copied so downstream mutation cannot leak back into the source.
904
+ * @param channel - The channel to extract identity from. Typed as ResolvedChannel because that is the shape after canonical resolution; CanonicalChannel is
905
+ * structurally compatible.
906
+ * @returns A new ChannelIdentity object with just the identity fields populated.
441
907
  */
442
- export function resolveStoredChannel(key, stored) {
443
- const predefined = PREDEFINED_CHANNELS[key];
444
- // No predefined equivalent — this is a user-defined channel, return as-is. The caller is responsible for ensuring it has a url field.
445
- // Runtime check needed — TypeScript thinks Record indexing always returns a value, but the key may not exist.
446
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
447
- if (!predefined) {
448
- return stored;
449
- }
450
- // Start with a copy of the predefined definition, then overlay allowlisted non-null delta fields. The spread creates a shallow copy — reference-type fields
451
- // (like tags) share the same array instance as PREDEFINED_CHANNELS. The defensive copy below ensures the returned Channel is fully independent so callers can
452
- // safely modify it without corrupting the predefined source of truth.
453
- const resolved = { ...predefined };
454
- for (const [field, value] of Object.entries(stored)) {
455
- if (!DELTA_ALLOWED_FIELDS.has(field)) {
908
+ export function pickIdentity(channel) {
909
+ const identity = {};
910
+ for (const field of CHANNEL_IDENTITY_KEYS) {
911
+ const value = channel[field];
912
+ if (value === undefined) {
456
913
  continue;
457
914
  }
458
- if (value === null) {
459
- // Explicit null means "clear this field" — delete it from the resolved object.
460
- Reflect.deleteProperty(resolved, field);
915
+ identity[field] = value;
916
+ }
917
+ identity.tags &&= identity.tags.slice();
918
+ return identity;
919
+ }
920
+ /**
921
+ * Overlays a stored channel (full definition or delta) onto a base. Allowlisted delta fields in the stored entry override the base: a null value clears the
922
+ * field from the resolved object, a defined value replaces it, and missing fields inherit from the base. Non-delta fields on the stored entry (e.g.,
923
+ * canonicalKey) pass through so the resolved channel retains its relationship metadata. The returned object is a fresh reference with a defensive copy of any
924
+ * array-valued fields, so callers can mutate it without corrupting the base.
925
+ *
926
+ * This is the single delta-overlay kernel used by both predefined overrides and user variants. The base may be:
927
+ *
928
+ * - A predefined CanonicalChannel for canonical-override resolution.
929
+ * - A ChannelIdentity-only intermediate during variant resolution (URL is supplied by the variant binding overlay).
930
+ * - A fully resolved canonical for variant base computation.
931
+ *
932
+ * Internally typed as ResolvedChannel for the input shape; intermediate variant-resolution states pass through a cast because url is required on
933
+ * ResolvedChannel but is supplied by the next overlay. The contract is: after the full resolveVariant chain, the result has every required field populated.
934
+ * @param base - The base channel to inherit from.
935
+ * @param stored - The stored entry (delta) to overlay.
936
+ * @returns A new ResolvedChannel with the base's fields and the stored entry's overrides applied.
937
+ */
938
+ function overlayDelta(base, stored) {
939
+ return applyOverlayKernel(base, stored, { allowedFields: DELTA_ALLOWED_FIELDS, passThroughOthers: true });
940
+ }
941
+ /**
942
+ * Shared overlay kernel used by overlayDelta and overlayVariantBinding. Walks the stored entry's fields and applies them to a clone of base under three rules:
943
+ * fields in allowedFields apply with delta semantics (null clears, undefined skips, value overrides); non-allowed fields either pass through (delta) or are
944
+ * silently dropped (variant overlay) per passThroughOthers; canonicalKey is always allowed regardless of the gate so resolved variants retain their relationship
945
+ * metadata. The kernel exists to keep the two overlays from drifting - identical control flow lived in both call sites before this consolidation.
946
+ * @param base - The base channel to clone and overlay onto.
947
+ * @param stored - The stored entry whose fields override the base.
948
+ * @param options - allowedFields selects which fields apply with delta semantics; passThroughOthers controls whether non-allowed fields flow through or are
949
+ * dropped. canonicalKey is always passed through.
950
+ * @returns A new ResolvedChannel with overlay applied.
951
+ */
952
+ function applyOverlayKernel(base, stored, options) {
953
+ const resolved = { ...base };
954
+ for (const [field, value] of Object.entries(stored)) {
955
+ if (options.allowedFields.has(field)) {
956
+ if (value === null) {
957
+ // Explicit null means "clear this field" - delete it from the resolved object.
958
+ Reflect.deleteProperty(resolved, field);
959
+ }
960
+ else if (value !== undefined) {
961
+ // Non-null, non-undefined - override the base value.
962
+ resolved[field] = value;
963
+ }
964
+ continue;
461
965
  }
462
- else if (value !== undefined) {
463
- // Non-null, non-undefined — override the predefined value.
966
+ // Non-allowed fields: pass through under delta semantics, or always pass canonicalKey as relationship metadata. Undefined values skipped per the
967
+ // standard "undefined inherits" rule.
968
+ if ((options.passThroughOthers || (field === "canonicalKey")) && (value !== undefined)) {
464
969
  resolved[field] = value;
465
970
  }
466
971
  }
467
- // Defensive copy of reference-type fields to break shared references with PREDEFINED_CHANNELS. The delta overlay above may have replaced tags entirely (if
468
- // the delta included a tags array), but when no delta is present for tags, the spread leaves the predefined's array reference on the resolved object.
972
+ // Defensive copy of reference-type fields to break shared references with the base. The overlay above may have replaced tags entirely (if the stored
973
+ // entry included a tags array), but when no delta is present for tags, the spread leaves the base's array reference on the resolved object.
469
974
  resolved.tags &&= resolved.tags.slice();
470
975
  return resolved;
471
976
  }
977
+ /**
978
+ * Variant-binding-only overlay. Like overlayDelta but applies only fields in CHANNEL_BINDING_KEYS - identity fields on the variant entry are silently dropped
979
+ * during resolution. This enforces the architectural principle that variants are pure tuning data: how to reach the channel via this service. Identity
980
+ * (channelNumber, hdhrEnabled, tags, name, etc.) is a property of the channel as a whole and lives only on the canonical entry.
981
+ *
982
+ * Used by resolveVariant for both predefined-variant and stored-variant overlays. Legacy data, hand-edited files, or future migrations that put identity
983
+ * fields on variant entries are inert at resolution time - canonical's identity always wins. canonicalKey passes through as relationship metadata so the
984
+ * resolved variant can still be identified as a variant.
985
+ *
986
+ * Null/undefined semantics match overlayDelta: null clears the field; undefined inherits.
987
+ * @param base - The base channel (typically canonical identity from pickIdentity).
988
+ * @param stored - The variant entry (predefined or user-stored).
989
+ * @returns A new ResolvedChannel with binding fields applied from the variant.
990
+ */
991
+ function overlayVariantBinding(base, stored) {
992
+ return applyOverlayKernel(base, stored, { allowedFields: VARIANT_OVERLAY_ALLOWED_FIELDS, passThroughOthers: false });
993
+ }
994
+ /**
995
+ * Resolves a stored channel entry into a fully populated ResolvedChannel. Handles the two non-variant cases:
996
+ *
997
+ * 1. Predefined override (key matches a predefined entry): the stored entry is a delta over the predefined canonical definition.
998
+ * 2. Standalone user channel (no predefined equivalent): the stored entry is already a full channel; a defensive copy is returned so downstream mutations
999
+ * cannot leak into loadedUserChannels.
1000
+ *
1001
+ * Variants are resolved by getMergedChannelMap via the layered overlay (canonical identity -> predefined variant binding -> user delta). Callers that need a
1002
+ * resolved variant should read from channelsRef (via getResolvedChannel) rather than calling this function with a variant key.
1003
+ * @param key - The channel key (must not be a variant key).
1004
+ * @param stored - The stored channel data.
1005
+ * @returns A fully resolved channel with identity and binding both populated.
1006
+ */
1007
+ export function resolveStoredChannel(key, stored) {
1008
+ const predefined = PREDEFINED_CHANNELS[key];
1009
+ if (predefined) {
1010
+ return overlayDelta(predefined, stored);
1011
+ }
1012
+ // Standalone: defensive copy so callers can mutate the result without corrupting loadedUserChannels. A standalone is structurally a CanonicalChannel
1013
+ // (identity + binding, no canonicalKey) and therefore satisfies ResolvedChannel.
1014
+ const standalone = { ...stored };
1015
+ standalone.tags &&= standalone.tags.slice();
1016
+ return standalone;
1017
+ }
472
1018
  /**
473
1019
  * Returns the merged channel map (predefined + user) without filtering by enabled status or service variants. Used internally for building service groups.
474
- * Resolves any delta overrides into full Channel objects so the result contains only complete definitions.
1020
+ * Resolves deltas into fully populated channels.
1021
+ *
1022
+ * Built from buildResolvedCanonicals (Pass 1: canonicals and standalones) plus a variant pass (Pass 2: layered canonical identity -> predefined variant
1023
+ * binding -> user delta). The dangling-canonical path preserves whatever data exists so the user does not silently lose anything when a canonicalKey points
1024
+ * at a missing entry.
475
1025
  * @returns The complete merged channel map.
476
1026
  */
477
1027
  function getMergedChannelMap() {
478
- const result = { ...PREDEFINED_CHANNELS };
479
- for (const [key, stored] of Object.entries(loadedUserChannels)) {
480
- result[key] = resolveStoredChannel(key, stored);
1028
+ const result = buildResolvedCanonicals(loadedUserChannels);
1029
+ const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(loadedUserChannels)]);
1030
+ for (const key of allKeys) {
1031
+ const stored = loadedUserChannels[key];
1032
+ const classification = classifyEntry(key, stored);
1033
+ if (classification.kind !== "variant") {
1034
+ continue;
1035
+ }
1036
+ const canonical = result[classification.canonicalKey];
1037
+ if (canonical) {
1038
+ result[key] = resolveVariant(canonical, classification.predefined, classification.stored);
1039
+ // Clear the dangling-warning dedup entry if one was recorded earlier: a previously-missing canonical is now present, so a future regression should
1040
+ // re-warn rather than staying silent because we already warned once.
1041
+ warnedDanglingVariants.delete(danglingToken(key, classification.canonicalKey));
1042
+ continue;
1043
+ }
1044
+ // Dangling canonical: the variant references a canonical that is not present. Warn once per (variant, canonical) pair, then preserve the variant as best
1045
+ // we can so the user does not silently lose data. When a stored entry exists, funnel through resolveStoredChannel - it already overlays onto the
1046
+ // predefined definition (if any) or returns a standalone copy. When only the predefined entry exists, use it directly.
1047
+ warnDanglingCanonical(key, classification.canonicalKey);
1048
+ if (classification.stored) {
1049
+ result[key] = resolveStoredChannel(key, classification.stored);
1050
+ }
1051
+ else if (classification.predefined) {
1052
+ result[key] = classification.predefined;
1053
+ }
481
1054
  }
482
1055
  return result;
483
1056
  }
@@ -485,7 +1058,7 @@ function getMergedChannelMap() {
485
1058
  * classification and enabled status. All other channel retrieval functions that need merged data build on top of it.
486
1059
  */
487
1060
  /**
488
- * Returns the full channel listing with source classification and enabled status. This is the authoritative merge point for predefined and user channels — all
1061
+ * Returns the full channel listing with source classification and enabled status. This is the authoritative merge point for predefined and user channels - all
489
1062
  * code that needs a merged view of channels should use this function (or getAllChannels() which delegates to it).
490
1063
  *
491
1064
  * For each channel key, the source is classified as:
@@ -496,7 +1069,7 @@ function getMergedChannelMap() {
496
1069
  * The enabled field reflects whether the channel is available for streaming. Predefined-only channels can be disabled via configuration; user and override
497
1070
  * channels are always enabled.
498
1071
  *
499
- * Service variants (non-canonical keys in service groups) are filtered out from this listing — they are accessed via the service selection mechanism instead.
1072
+ * Service variants (non-canonical keys in service groups) are filtered out from this listing - they are accessed via the service selection mechanism instead.
500
1073
  *
501
1074
  * Override entries produce a new resolved Channel object (via resolveStoredChannel()), which is a different reference from PREDEFINED_CHANNELS[key]. The service
502
1075
  * system (services.ts) relies on this reference difference to detect user overrides via isUserOverride(). Predefined-only entries preserve the original reference.
@@ -509,7 +1082,7 @@ export function getChannelListing() {
509
1082
  const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(loadedUserChannels)]);
510
1083
  const listing = [];
511
1084
  for (const key of allKeys) {
512
- // Skip service variants — they're accessed via service selection, not as separate channels.
1085
+ // Skip service variants - they're accessed via service selection, not as separate channels.
513
1086
  if (isServiceVariant(key)) {
514
1087
  continue;
515
1088
  }
@@ -528,9 +1101,19 @@ export function getChannelListing() {
528
1101
  }
529
1102
  // For user entries (including overrides), resolve the stored delta/definition into a full Channel. The resolved object is a new reference, which preserves
530
1103
  // the isUserOverride() contract in services.ts (reference comparison against PREDEFINED_CHANNELS[key]). Predefined-only entries keep the original reference.
531
- const channel = isUser ? resolveStoredChannel(key, loadedUserChannels[key]) : PREDEFINED_CHANNELS[key];
1104
+ const userEntry = loadedUserChannels[key];
1105
+ const predefinedEntry = PREDEFINED_CHANNELS[key];
1106
+ const resolvedBase = isUser && userEntry ? resolveStoredChannel(key, userEntry) : predefinedEntry;
1107
+ if (!resolvedBase) {
1108
+ // Defensive: the entry exists in at least one source per the allKeys construction, so this branch is unreachable.
1109
+ continue;
1110
+ }
1111
+ // Variants are filtered out at the top of this loop via isServiceVariant, so resolvedBase reaching here is structurally canonical-shaped (a CanonicalChannel
1112
+ // from the catalog or a fully-resolved stored entry). Type as ResolvedChannel so the boundary rule in types/channels.ts holds: post-resolution values are
1113
+ // typed ResolvedChannel.
1114
+ const channel = resolvedBase;
532
1115
  // When a non-default service is selected, resolve the variant so consumers see the correct URL, channelSelector, stationId, and channelNumber. We skip
533
- // resolution when the resolved key matches the canonical key — the channel object is already correct and preserving its reference avoids a redundant lookup.
1116
+ // resolution when the resolved key matches the canonical key - the channel object is already correct and preserving its reference avoids a redundant lookup.
534
1117
  const resolvedKey = resolveServiceKey(key);
535
1118
  const resolvedChannel = (resolvedKey !== key) ? getResolvedChannel(resolvedKey) : undefined;
536
1119
  listing.push({
@@ -545,6 +1128,24 @@ export function getChannelListing() {
545
1128
  listing.sort((a, b) => a.key.localeCompare(b.key));
546
1129
  return listing;
547
1130
  }
1131
+ /**
1132
+ * Predicate for "visible" channels - entries that are both enabled and available under the current service filter. This is the single source of truth for what
1133
+ * "visible" means across bulk operations, the playlist, and the merged channel map. Every site that filters the listing by visibility routes through this
1134
+ * predicate so the definition lives in exactly one place.
1135
+ * @param entry - The listing entry to test.
1136
+ * @returns True when the channel is enabled and passes the service filter.
1137
+ */
1138
+ export function isVisibleChannel(entry) {
1139
+ return entry.enabled && entry.availableByService;
1140
+ }
1141
+ /**
1142
+ * Returns the subset of getChannelListing() that is enabled and available under the current service filter. Built on top of isVisibleChannel so the visibility
1143
+ * predicate lives in one place.
1144
+ * @returns Listing entries that pass the visibility predicate.
1145
+ */
1146
+ export function getVisibleChannels() {
1147
+ return getChannelListing().filter(isVisibleChannel);
1148
+ }
548
1149
  /**
549
1150
  * Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded. Built on
550
1151
  * top of getChannelListing() to ensure a single merging code path.
@@ -552,13 +1153,122 @@ export function getChannelListing() {
552
1153
  */
553
1154
  export function getAllChannels() {
554
1155
  const result = {};
555
- for (const entry of getChannelListing()) {
556
- if (entry.enabled && entry.availableByService) {
557
- result[entry.key] = entry.channel;
558
- }
1156
+ for (const entry of getVisibleChannels()) {
1157
+ result[entry.key] = entry.channel;
559
1158
  }
560
1159
  return result;
561
1160
  }
1161
+ /**
1162
+ * Returns a copy of the raw stored user channels map - the unresolved entries as they exist in channels.json. Used by the consistency probe to check
1163
+ * cross-store invariants without applying delta resolution. Callers should not rely on the shape for anything beyond key/canonicalKey inspection.
1164
+ * @returns Shallow copy of the loaded user channels.
1165
+ */
1166
+ export function getStoredUserChannels() {
1167
+ return { ...loadedUserChannels };
1168
+ }
1169
+ /**
1170
+ * Computes the per-field reset value for a customized field. The reset value is what the field would resolve to in the user's effective view if the override
1171
+ * were removed - so it must apply the same effective-view rules consumers see (vocabulary-filtered tags, implicit-true hdhrEnabled). Field-specific dispatch
1172
+ * routes through the existing single-source helpers (getChannelEffectiveTags, getEffectiveHdhrEnabled) so the rules live in exactly one place.
1173
+ *
1174
+ * For canonical-stored fields, the reset value reads from the predefined canonical. For variant-stored fields, it reads from the predefined variant first,
1175
+ * falling back to the predefined canonical when the variant doesn't carry the field (variant-inheritance fallback). Returns undefined when no predefined
1176
+ * source has the field - a reset would clear the input.
1177
+ * @param field - The customized field name.
1178
+ * @param storedIn - Where the override is stored (canonical or variant).
1179
+ * @param predefinedCanonical - The predefined canonical entry (raw catalog read).
1180
+ * @param predefinedVariant - The predefined variant entry, if applicable (raw catalog read).
1181
+ * @returns The effective reset value, or undefined.
1182
+ */
1183
+ function computeResetValue(field, storedIn, predefinedCanonical, predefinedVariant) {
1184
+ // Pick the right predefined source for this storage location. Variant-stored fields prefer the predefined variant; fall back to canonical when the variant
1185
+ // doesn't carry the field (e.g., a variant that inherits profile from canonical's URL-based detection).
1186
+ let source;
1187
+ if (storedIn === "canonical") {
1188
+ source = predefinedCanonical;
1189
+ }
1190
+ else if (predefinedVariant && (predefinedVariant[field] !== undefined)) {
1191
+ source = predefinedVariant;
1192
+ }
1193
+ else {
1194
+ source = predefinedCanonical;
1195
+ }
1196
+ if (!source) {
1197
+ return undefined;
1198
+ }
1199
+ // Delegate to the effective-view helpers for fields with view semantics. Channel structurally satisfies ResolvedChannel here (CanonicalChannel and
1200
+ // VariantChannel both assign), so no cast is needed at the boundary.
1201
+ switch (field) {
1202
+ case "tags": {
1203
+ return getChannelEffectiveTags(source);
1204
+ }
1205
+ case "hdhrEnabled": {
1206
+ return getEffectiveHdhrEnabled(source);
1207
+ }
1208
+ default: {
1209
+ return source[field];
1210
+ }
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Returns the customization provenance for the given canonical channel. The form layer uses this as the single source of truth for which fields are
1215
+ * customized (instead of comparing resolved values to predefined values, which produces false positives when a non-canonical service is active and the
1216
+ * resolved value legitimately differs from the canonical's predefined value).
1217
+ *
1218
+ * Resolution: for each field present in the user's canonical-stored entry, mark customized with storedIn="canonical" and resetValue from computeResetValue.
1219
+ * For each field present in the active variant's stored entry, mark customized with storedIn="variant" and resetValue from computeResetValue (which handles
1220
+ * variant-inheritance fallback). Variant entries override canonical entries on the same field name because variant resolution overlays the variant's value
1221
+ * last.
1222
+ *
1223
+ * Pass 2 (variant-stored fields) restricts the walked field set via getAllowedFieldsForShape so identity fields encountered on a variant entry are silently
1224
+ * dropped from the customization map, matching the resolver's behavior. The "canonicalKey" field is similarly excluded from both passes - structural metadata,
1225
+ * not user-customizable.
1226
+ * @param canonicalKey - The canonical channel key.
1227
+ * @returns The customization provenance map and the active variant key (when applicable).
1228
+ */
1229
+ export function getChannelCustomizations(canonicalKey) {
1230
+ const customizations = new Map();
1231
+ const predefinedCanonical = PREDEFINED_CHANNELS[canonicalKey];
1232
+ // Determine the active variant via the service resolver. resolveServiceKey returns the canonical key itself when the canonical service is selected (no
1233
+ // override is needed) and the variant key otherwise (whether via stored selection or filter fallback).
1234
+ const resolvedKey = resolveServiceKey(canonicalKey);
1235
+ const activeVariantKey = (resolvedKey === canonicalKey) ? undefined : resolvedKey;
1236
+ // Walk a stored entry against its shape's allowed-field set, recording each customized field. The shape rule is computed via getAllowedFieldsForShape so
1237
+ // canonical-stored entries see the full delta surface and variant-stored entries see only the variant binding-only subset (identity-on-variants is dropped).
1238
+ const recordCustomizations = (storedKey, stored, storedIn, predefinedVariant) => {
1239
+ const allowed = getAllowedFieldsForShape(storedKey, stored);
1240
+ for (const [field, value] of Object.entries(stored)) {
1241
+ // canonicalKey is structural metadata, not a customization. Other non-allowed fields (e.g., orphan identity on a variant) are silently ignored here -
1242
+ // the normalizer's filterToDeltaSurface will strip them on next save with a warning.
1243
+ if (!allowed.has(field) || (field === "canonicalKey")) {
1244
+ continue;
1245
+ }
1246
+ if (value === undefined) {
1247
+ continue;
1248
+ }
1249
+ // Cast acknowledges that field is one of the allowed customizable names per the allowlist check above.
1250
+ customizations.set(field, {
1251
+ resetValue: computeResetValue(field, storedIn, predefinedCanonical, predefinedVariant),
1252
+ storedIn
1253
+ });
1254
+ }
1255
+ };
1256
+ // Pass 1: canonical-stored customizations. The canonical entry's allowed shape is the full delta surface (identity + binding delta-eligible).
1257
+ const canonicalStored = loadedUserChannels[canonicalKey];
1258
+ if (canonicalStored) {
1259
+ recordCustomizations(canonicalKey, canonicalStored, "canonical", undefined);
1260
+ }
1261
+ // Pass 2: variant-stored customizations. The variant entry's allowed shape is binding-only. Variant entries override canonical entries on the same field
1262
+ // name (last-overlay-wins matches resolution semantics).
1263
+ if (activeVariantKey) {
1264
+ const variantStored = loadedUserChannels[activeVariantKey];
1265
+ if (variantStored) {
1266
+ const predefinedVariant = PREDEFINED_CHANNELS[activeVariantKey];
1267
+ recordCustomizations(activeVariantKey, variantStored, "variant", predefinedVariant);
1268
+ }
1269
+ }
1270
+ return { activeVariantKey, customizations };
1271
+ }
562
1272
  /**
563
1273
  * Returns the raw stored channel data (without predefined channels). Entries may be full Channel definitions or ChannelDelta overrides.
564
1274
  * @returns The stored channel map.
@@ -575,11 +1285,17 @@ export function getTagRegistry() {
575
1285
  return { deletedTags: [...loadedTagRegistry.deletedTags], tags: [...loadedTagRegistry.tags] };
576
1286
  }
577
1287
  /**
578
- * Updates the tag registry state in memory. Call saveTagRegistry() after to persist the change.
1288
+ * Persists a new tag registry state. Goes through mutateChannels so the file write, integrity validation, and post-mutate cache hydration all run uniformly -
1289
+ * after the call returns, both disk and module-state cache reflect the new value. The caller's input arrays are not mutated; both are sorted non-destructively
1290
+ * via sortTags so storage shares one canonical ordering.
579
1291
  * @param registry - The new tag registry state.
1292
+ * @throws FileStoreParseError if channels.json contains invalid JSON and the .bak rotation is also unparseable.
580
1293
  */
581
- export function setTagRegistry(registry) {
582
- loadedTagRegistry = { deletedTags: registry.deletedTags.sort(), tags: registry.tags.sort() };
1294
+ export async function setTagRegistry(registry) {
1295
+ const sorted = { deletedTags: sortTags(registry.deletedTags), tags: sortTags(registry.tags) };
1296
+ await mutateChannels((data) => {
1297
+ data.tagRegistry = sorted;
1298
+ });
583
1299
  }
584
1300
  /**
585
1301
  * Returns the active tag vocabulary: predefined tags minus user-deleted tags, plus user-created tags, sorted alphabetically. This is the single source of truth
@@ -601,10 +1317,11 @@ export function getActiveTagVocabulary() {
601
1317
  return [...seen.values()].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
602
1318
  }
603
1319
  /**
604
- * Returns a channel's effective tags — the intersection of the channel's assigned tags with the active vocabulary. Tags that exist on the channel but are not in
605
- * the active vocabulary are filtered out, ensuring only assignable and queryable tags are visible in the UI and playlist responses.
1320
+ * Returns a channel's effective tags - the intersection of the channel's assigned tags with the active vocabulary. Tags that exist on the channel but are not in
1321
+ * the active vocabulary are filtered out, ensuring only assignable and queryable tags are visible in the UI and playlist responses. Output preserves the source
1322
+ * order of channel.tags; every write path (sortTags) keeps that source order canonical, so in practice the returned array is sorted.
606
1323
  * @param channel - The channel to get effective tags for.
607
- * @returns Sorted array of effective tag strings, or empty array if the channel has no tags or none are in the active vocabulary.
1324
+ * @returns Effective tag strings in source order, or empty array if the channel has no tags or none are in the active vocabulary.
608
1325
  */
609
1326
  export function getChannelEffectiveTags(channel) {
610
1327
  if (!channel.tags || (channel.tags.length === 0)) {
@@ -613,9 +1330,20 @@ export function getChannelEffectiveTags(channel) {
613
1330
  const vocabulary = getActiveTagVocabulary();
614
1331
  return channel.tags.filter((tag) => vocabulary.some((v) => tagsMatch(v, tag)));
615
1332
  }
1333
+ /**
1334
+ * Returns whether a channel is effectively included in the HDHomeRun lineup. Codifies the sparse-storage convention that absent or true means included, and only
1335
+ * an explicit `false` means excluded. This is the single source of truth for the convention - every site that needs to ask "is HDHR enabled for this channel?"
1336
+ * routes through this helper rather than reaching for the inline `channel.hdhrEnabled !== false` pattern. Storage-shape inspections that ask a different
1337
+ * question ("did the client send literal false?", "did the user override?") stay inline because they are not asking about effective state.
1338
+ * @param channel - The channel to evaluate.
1339
+ * @returns True when the channel is effectively included in the HDHR lineup.
1340
+ */
1341
+ export function getEffectiveHdhrEnabled(channel) {
1342
+ return channel.hdhrEnabled !== false;
1343
+ }
616
1344
  /**
617
1345
  * Case-insensitive tag comparison. Tags are freeform strings with preserved casing, but all matching throughout the system is case-insensitive. This function
618
- * is the single source of truth for that policy — all tag identity checks should use it rather than inline toLowerCase() calls.
1346
+ * is the single source of truth for that policy - all tag identity checks should use it rather than inline toLowerCase() calls.
619
1347
  * @param a - The first tag.
620
1348
  * @param b - The second tag.
621
1349
  * @returns True if the tags match case-insensitively.
@@ -624,10 +1352,20 @@ export function tagsMatch(a, b) {
624
1352
  return a.toLowerCase() === b.toLowerCase();
625
1353
  }
626
1354
  /**
627
- * Applies a tag transformation across channels and persists the result. This is the single source of truth for batch tag mutations — delete, rename, and bulk
1355
+ * Checks whether a tag is present in the active vocabulary under case-insensitive identity. This is the single source of truth for "does this tag exist?"
1356
+ * questions - tag endpoints (create/rename collision, bulk-tags vocabulary check) route through this instead of ad-hoc Set+toLowerCase comparisons so the
1357
+ * case-insensitive policy lives in exactly one place (tagsMatch), not in parallel implementations.
1358
+ * @param tag - The tag to check.
1359
+ * @returns True if the active vocabulary contains a tag matching case-insensitively.
1360
+ */
1361
+ export function isInVocabulary(tag) {
1362
+ return getActiveTagVocabulary().some((v) => tagsMatch(v, tag));
1363
+ }
1364
+ /**
1365
+ * Applies a tag transformation across channels and persists the result. This is the single source of truth for batch tag mutations - delete, rename, and bulk
628
1366
  * toggle all route through this function. The caller provides a filter (which channels to transform) and a transform (how to modify each channel's tags). This
629
1367
  * function handles loading stored channel data, applying the transform, and saving. Delta normalization in mutateChannels() handles predefined channel
630
- * delta computation automatically — callers do not need to reason about deltas vs. full definitions.
1368
+ * delta computation automatically - callers do not need to reason about deltas vs. full definitions.
631
1369
  * @param filter - Predicate selecting which listing entries to transform. Receives each ChannelListingEntry from getChannelListing().
632
1370
  * @param transform - Pure function mapping a channel's current resolved tags to its new tags. Receives the channel's current tags array (may be empty, ordering
633
1371
  * not guaranteed). Must return the desired tags array (may be empty to clear all tags). The returned array is sorted before storage.
@@ -636,22 +1374,24 @@ export function tagsMatch(a, b) {
636
1374
  export async function transformChannelTags(filter, transform) {
637
1375
  const affectedKeys = [];
638
1376
  try {
639
- await mutateChannels((channels) => {
1377
+ await mutateChannels((data) => {
640
1378
  for (const entry of getChannelListing()) {
641
1379
  if (!filter(entry)) {
642
1380
  continue;
643
1381
  }
644
1382
  const currentTags = entry.channel.tags ?? [];
645
- const newTags = transform(currentTags).sort();
646
- // Skip channels where the transform produced no change.
647
- if (JSON.stringify(newTags) === JSON.stringify(currentTags.toSorted())) {
1383
+ // Route both the transform result and the "no change" comparison through sortTags so tag storage shares one canonical ordering with the rest of the
1384
+ // system. sortTags is non-mutating, which matters here because for predefined-only listing entries, entry.channel is the PREDEFINED_CHANNELS reference
1385
+ // directly - a raw .sort() on entry.channel.tags would rearrange the predefined array in process memory.
1386
+ const newTags = sortTags(transform(currentTags));
1387
+ if (isDeepStrictEqual(newTags, sortTags(currentTags))) {
648
1388
  continue;
649
1389
  }
650
1390
  // Set the new tags on the stored entry. Callers use null uniformly for "clear/empty" - the normalizer in mutateChannels() handles the storage
651
1391
  // conventions: delta normalization for predefined channels (comparing against raw definitions), null-stripping for user channels.
652
- const existing = channels[entry.key] ?? {};
653
- existing.tags = (newTags.length > 0) ? newTags : null;
654
- channels[entry.key] = existing;
1392
+ const existing = data.channels[entry.key] ?? {};
1393
+ existing["tags"] = (newTags.length > 0) ? newTags : null;
1394
+ data.channels[entry.key] = existing;
655
1395
  affectedKeys.push(entry.key);
656
1396
  }
657
1397
  });
@@ -664,13 +1404,48 @@ export async function transformChannelTags(filter, transform) {
664
1404
  }
665
1405
  return { affectedKeys };
666
1406
  }
1407
+ /* Dangling-canonical warning dedup for getPredefinedChannel. The catalog flattener should make a missing canonical structurally impossible, so observing one
1408
+ * indicates real data corruption that an operator needs to know about. We log once per variant key for the lifetime of this process - a tight loop calling
1409
+ * getPredefinedChannel on the corrupt entry would otherwise spam the log on every form render.
1410
+ */
1411
+ const warnedMissingCanonicalForPredefined = new Set();
667
1412
  /**
668
- * Returns the predefined channel definition for a key.
1413
+ * Returns the predefined channel definition for a key as a fully-resolved view. For predefined variant entries (which carry only service-specific fields plus
1414
+ * canonicalKey), overlays the variant's binding onto its predefined canonical so callers receive the resolved view - canonical identity inherited onto the
1415
+ * variant. This matches what the user sees in the form/table and is the correct base for delta-from-predefined computations in crud.ts and channelForm.ts.
1416
+ *
1417
+ * Always returns ResolvedChannel-shaped data: identity fields (name, tags, hdhrEnabled, etc.) are always available, sourced from the canonical when this key is
1418
+ * a variant. CanonicalChannel and VariantChannel both assign structurally to ResolvedChannel since the identity fields are optional in both types.
1419
+ *
1420
+ * Returns undefined when the key is not predefined OR when the catalog is internally inconsistent (variant references a missing canonical). The latter case is
1421
+ * structurally impossible by construction of the flattener, so observing it is a real corruption indicator: the function logs once per offending key and
1422
+ * returns undefined rather than fabricating a half-resolved view that would lie about its identity fields.
669
1423
  * @param key - The channel key to look up.
670
- * @returns The predefined channel, or undefined if the key is not predefined.
1424
+ * @returns The resolved predefined channel, or undefined if the key is not predefined or the catalog is inconsistent.
671
1425
  */
672
1426
  export function getPredefinedChannel(key) {
673
- return PREDEFINED_CHANNELS[key];
1427
+ const entry = PREDEFINED_CHANNELS[key];
1428
+ if (!entry) {
1429
+ return undefined;
1430
+ }
1431
+ // Canonical entries have full identity already; return as-is. CanonicalChannel assigns to ResolvedChannel structurally (canonicalKey: never is assignable to
1432
+ // canonicalKey?: string).
1433
+ if (!entry.canonicalKey || (entry.canonicalKey === key)) {
1434
+ return entry;
1435
+ }
1436
+ // Predefined variant: overlay its binding fields onto the canonical so callers see the resolved view with canonical identity. Variant overlays apply
1437
+ // binding fields only per the architectural principle - matches resolveVariant's behavior so callers get the same view regardless of which path produced it.
1438
+ const canonical = PREDEFINED_CHANNELS[entry.canonicalKey];
1439
+ if (!canonical) {
1440
+ // Catalog inconsistency. Log once per offending key, then return undefined. Returning a half-resolved view (the variant typed as ResolvedChannel) would
1441
+ // lie about which identity fields are reachable and silently hide the corruption from consumers.
1442
+ if (!warnedMissingCanonicalForPredefined.has(key)) {
1443
+ warnedMissingCanonicalForPredefined.add(key);
1444
+ LOG.warn("Catalog inconsistency: predefined variant '%s' references missing canonical '%s'. Returning undefined; the flattener should make this impossible.", key, entry.canonicalKey);
1445
+ }
1446
+ return undefined;
1447
+ }
1448
+ return overlayVariantBinding(canonical, entry);
674
1449
  }
675
1450
  /**
676
1451
  * Checks if a channel key exists in the predefined channels.
@@ -733,11 +1508,17 @@ export function getChannelStationId(channelKey) {
733
1508
  // Pacific channels are regional feeds of the same brand as their east counterparts, so we resolve to the east variant's station ID for logo purposes.
734
1509
  const effectiveKey = getEastCanonicalKey(channelKey) ?? channelKey;
735
1510
  // User channels take precedence - resolveStoredChannel merges deltas with the predefined base, so overrides that change the stationId are reflected here.
736
- return (effectiveKey in loadedUserChannels) ?
737
- resolveStoredChannel(effectiveKey, loadedUserChannels[effectiveKey]).stationId :
738
- // Runtime check needed - TypeScript thinks Record indexing always returns a value, but the key may not exist.
739
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
740
- PREDEFINED_CHANNELS[effectiveKey]?.stationId;
1511
+ const userEntry = loadedUserChannels[effectiveKey];
1512
+ if (userEntry) {
1513
+ return resolveStoredChannel(effectiveKey, userEntry).stationId;
1514
+ }
1515
+ // PREDEFINED_CHANNELS is a discriminated union; only canonicals carry stationId. The lookup here is by canonical-equivalent key (Pacific resolved to East),
1516
+ // so a hit is structurally a canonical. Narrow via the canonicalKey discriminator: a defined canonicalKey marks a VariantChannel, which has no stationId.
1517
+ const predefined = PREDEFINED_CHANNELS[effectiveKey];
1518
+ if (!predefined || (predefined.canonicalKey !== undefined)) {
1519
+ return undefined;
1520
+ }
1521
+ return predefined.stationId;
741
1522
  }
742
1523
  /**
743
1524
  * Returns the logo URL for a channel from the cache. Pacific channels (e.g., "bravop") are resolved to their east counterpart automatically. The cache is
@@ -764,7 +1545,7 @@ export function isUserChannel(key) {
764
1545
  * re-enable. This is useful for users who don't want certain predefined channels cluttering their channel list.
765
1546
  */
766
1547
  /**
767
- * Checks if a predefined channel is disabled. The disabled state is determined solely by the disabledPredefined list in config — the user's explicit visibility
1548
+ * Checks if a predefined channel is disabled. The disabled state is determined solely by the disabledPredefined list in config - the user's explicit visibility
768
1549
  * intent. Property overrides (HDHR toggle, name change, tag edits) stored as deltas in channels.json are orthogonal and do not affect the enabled/disabled state.
769
1550
  * @param key - The channel key to check.
770
1551
  * @returns True if the channel is predefined and disabled.
@@ -782,6 +1563,84 @@ export function isPredefinedChannelDisabled(key) {
782
1563
  export function getDisabledPredefinedChannels() {
783
1564
  return [...CONFIG.channels.disabledPredefined];
784
1565
  }
1566
+ /**
1567
+ * Applies a set operation (add or delete) to the disabledPredefined list in user config. Persists the change to config.json and syncs the runtime CONFIG object
1568
+ * so subsequent reads see the updated state immediately. This is the internal implementation shared by disablePredefinedChannels and enablePredefinedChannels so
1569
+ * the mutateConfig scaffolding, sort, and CONFIG sync live in exactly one place.
1570
+ * @param op - "add" to insert keys into the disabled list, "delete" to remove them.
1571
+ * @param keys - The predefined channel keys to apply the operation to.
1572
+ */
1573
+ async function mutateDisabledPredefined(op, keys) {
1574
+ if (keys.length === 0) {
1575
+ return;
1576
+ }
1577
+ let updatedList = [];
1578
+ await mutateConfig((config) => {
1579
+ config.channels ??= {};
1580
+ config.channels.disabledPredefined ??= [];
1581
+ const disabledSet = new Set(config.channels.disabledPredefined);
1582
+ for (const key of keys) {
1583
+ disabledSet[op](key);
1584
+ }
1585
+ config.channels.disabledPredefined = [...disabledSet].toSorted();
1586
+ updatedList = config.channels.disabledPredefined;
1587
+ });
1588
+ CONFIG.channels.disabledPredefined = updatedList;
1589
+ }
1590
+ /**
1591
+ * Disables one or more predefined channels by adding their keys to the disabledPredefined list in user config. Idempotent.
1592
+ * @param keys - The predefined channel keys to disable. Duplicates within the input array and pre-existing disabled entries are handled idempotently.
1593
+ */
1594
+ export async function disablePredefinedChannels(keys) {
1595
+ await mutateDisabledPredefined("add", keys);
1596
+ }
1597
+ /**
1598
+ * Enables one or more predefined channels by removing their keys from the disabledPredefined list in user config. Idempotent.
1599
+ * @param keys - The predefined channel keys to enable. Entries that aren't in the disabled list are ignored.
1600
+ */
1601
+ export async function enablePredefinedChannels(keys) {
1602
+ await mutateDisabledPredefined("delete", keys);
1603
+ }
1604
+ /**
1605
+ * Persists a partial update of channel-table display preferences (sort field, sort direction, visible columns) to config.json. Only fields present in `prefs`
1606
+ * are written; absent fields are left untouched. The mutate fn copies the new values into data and the runtime CONFIG cache is updated post-write to match.
1607
+ * Single-call replacement for the prior set-then-save pattern, consistent with mutateServiceSelections, mutateEnabledServices, and setTagRegistry.
1608
+ * @param prefs - Subset of display preferences to update.
1609
+ * @throws FileStoreParseError if config.json contains invalid JSON and the .bak rotation is also unparseable.
1610
+ */
1611
+ export async function mutateChannelDisplayPrefs(prefs) {
1612
+ const next = {
1613
+ channelSortDirection: prefs.channelSortDirection ?? CONFIG.channels.channelSortDirection,
1614
+ channelSortField: prefs.channelSortField ?? CONFIG.channels.channelSortField,
1615
+ visibleColumns: prefs.visibleColumns ? [...prefs.visibleColumns] : [...CONFIG.channels.visibleColumns]
1616
+ };
1617
+ await mutateConfig((config) => {
1618
+ config.channels ??= {};
1619
+ config.channels.channelSortDirection = next.channelSortDirection;
1620
+ config.channels.channelSortField = next.channelSortField;
1621
+ config.channels.visibleColumns = next.visibleColumns;
1622
+ });
1623
+ // Hydrate the runtime CONFIG cache from the values just written.
1624
+ CONFIG.channels.channelSortDirection = next.channelSortDirection;
1625
+ CONFIG.channels.channelSortField = next.channelSortField;
1626
+ CONFIG.channels.visibleColumns = next.visibleColumns;
1627
+ }
1628
+ /**
1629
+ * Marks the first-run Service Setup wizard as completed. Writes the flag to runtime CONFIG and persists to config.json in one call - the operation is a single
1630
+ * one-way transition (setupCompleted goes from false/absent to true once, never back), so splitting into set+save would be ceremony without benefit.
1631
+ *
1632
+ * Persistence note: setupCompleted is intentionally absent from CONFIG_METADATA and PRESERVED_FIELDS in userConfig.ts, so filterDefaults strips it from the
1633
+ * on-disk shape during the write below. This is by design - the flag is observably derived from "has the user configured any services or channels" and
1634
+ * initializeUserChannels() re-infers it at startup from that observable state. The mutateConfig call here is a no-op on the file but keeps the runtime
1635
+ * mutation pathway uniform with every other CONFIG.channels writer; the flag survives across restarts via inference, not persistence.
1636
+ */
1637
+ export async function markSetupCompleted() {
1638
+ CONFIG.channels.setupCompleted = true;
1639
+ await mutateConfig((config) => {
1640
+ config.channels ??= {};
1641
+ config.channels.setupCompleted = true;
1642
+ });
1643
+ }
785
1644
  /**
786
1645
  * Returns all predefined channels regardless of disabled state, excluding service variants. Used by the UI to show all predefined channels including disabled ones.
787
1646
  * Service variants are internal implementation details of channel delivery and are not channels themselves.
@@ -803,14 +1662,14 @@ export function getPredefinedChannels() {
803
1662
  /**
804
1663
  * Filters predefined channel keys by their relationship to the Pacific timezone naming convention. For "pacific" mode, returns keys that end in "p" and
805
1664
  * whose East counterpart (key minus trailing "p") exists. For "east" mode, returns keys that do NOT end in "p" and whose Pacific counterpart (key plus "p")
806
- * exists. Service variants are excluded — only canonical keys are returned.
1665
+ * exists. Service variants are excluded - only canonical keys are returned.
807
1666
  * @param side - Which side of the East/Pacific pair to select.
808
1667
  * @returns Sorted array of matching canonical predefined channel keys.
809
1668
  */
810
1669
  function filterPredefinedKeysByTimezone(side) {
811
1670
  const keys = [];
812
1671
  for (const key of Object.keys(PREDEFINED_CHANNELS)) {
813
- // Skip service variants — they are internal implementation details, not channels.
1672
+ // Skip service variants - they are internal implementation details, not channels.
814
1673
  if (isServiceVariant(key)) {
815
1674
  continue;
816
1675
  }
@@ -1004,29 +1863,29 @@ export function validateImportedChannels(data, validProfiles) {
1004
1863
  }
1005
1864
  const channelData = value;
1006
1865
  // Validate required name field. Sanitize after type check to strip non-printable characters from imported data.
1007
- if ((typeof channelData.name !== "string") || (channelData.name.trim() === "")) {
1866
+ if ((typeof channelData["name"] !== "string") || (channelData["name"].trim() === "")) {
1008
1867
  errors.push("Channel '" + key + "': name is required.");
1009
1868
  continue;
1010
1869
  }
1011
- const cleanName = sanitizeString(channelData.name);
1870
+ const cleanName = sanitizeString(channelData["name"]);
1012
1871
  const nameError = validateChannelName(cleanName);
1013
1872
  if (nameError) {
1014
1873
  errors.push("Channel '" + key + "': " + nameError);
1015
1874
  continue;
1016
1875
  }
1017
1876
  // Validate required url field. Sanitize after type check.
1018
- if ((typeof channelData.url !== "string") || (channelData.url.trim() === "")) {
1877
+ if ((typeof channelData["url"] !== "string") || (channelData["url"].trim() === "")) {
1019
1878
  errors.push("Channel '" + key + "': url is required.");
1020
1879
  continue;
1021
1880
  }
1022
- const cleanUrl = sanitizeString(channelData.url);
1881
+ const cleanUrl = sanitizeString(channelData["url"]);
1023
1882
  const urlError = validateChannelUrl(cleanUrl);
1024
1883
  if (urlError) {
1025
1884
  errors.push("Channel '" + key + "': " + urlError);
1026
1885
  continue;
1027
1886
  }
1028
1887
  // Validate optional profile field. Sanitize after type check.
1029
- const profile = (typeof channelData.profile === "string") ? sanitizeString(channelData.profile) : undefined;
1888
+ const profile = (typeof channelData["profile"] === "string") ? sanitizeString(channelData["profile"]) : undefined;
1030
1889
  const profileError = validateChannelProfile(profile, validProfiles);
1031
1890
  if (profileError) {
1032
1891
  errors.push("Channel '" + key + "': " + profileError);
@@ -1041,15 +1900,15 @@ export function validateImportedChannels(data, validProfiles) {
1041
1900
  channel.profile = profile;
1042
1901
  }
1043
1902
  // Include optional fields if present. Sanitize string values to strip non-printable characters.
1044
- if (typeof channelData.stationId === "string") {
1045
- channel.stationId = sanitizeString(channelData.stationId);
1903
+ if (typeof channelData["stationId"] === "string") {
1904
+ channel.stationId = sanitizeString(channelData["stationId"]);
1046
1905
  }
1047
- if (typeof channelData.channelSelector === "string") {
1048
- channel.channelSelector = sanitizeString(channelData.channelSelector);
1906
+ if (typeof channelData["channelSelector"] === "string") {
1907
+ channel.channelSelector = sanitizeString(channelData["channelSelector"]);
1049
1908
  }
1050
1909
  // Validate optional channelNumber field (range and type).
1051
- if (channelData.channelNumber !== undefined) {
1052
- const num = Number(channelData.channelNumber);
1910
+ if (channelData["channelNumber"] !== undefined) {
1911
+ const num = Number(channelData["channelNumber"]);
1053
1912
  if (!Number.isInteger(num) || (num < 1) || (num > 99999)) {
1054
1913
  errors.push("Channel '" + key + "': channelNumber must be an integer between 1 and 99999.");
1055
1914
  continue;
@@ -1057,8 +1916,8 @@ export function validateImportedChannels(data, validProfiles) {
1057
1916
  channel.channelNumber = num;
1058
1917
  }
1059
1918
  // Validate optional tvgShift field (must be a finite number). Negative values are valid (e.g., a West Coast feed viewed from the East).
1060
- if (channelData.tvgShift !== undefined) {
1061
- const shift = Number(channelData.tvgShift);
1919
+ if (channelData["tvgShift"] !== undefined) {
1920
+ const shift = Number(channelData["tvgShift"]);
1062
1921
  if (!Number.isFinite(shift)) {
1063
1922
  errors.push("Channel '" + key + "': tvgShift must be a finite number.");
1064
1923
  continue;
@@ -1067,10 +1926,12 @@ export function validateImportedChannels(data, validProfiles) {
1067
1926
  }
1068
1927
  channels[key] = channel;
1069
1928
  }
1070
- // Validate channelNumber uniqueness across all imported channels. We check after building the full map so that all duplicates are reported.
1929
+ // Validate channelNumber uniqueness across all imported channels. We check after building the full map so that all duplicates are reported. channelNumber
1930
+ // is identity (canonical-only by architectural principle); the validator above only constructs canonical-shaped entries, but the StoredChannelMap is typed
1931
+ // as the union, so we narrow with an in-check before reading.
1071
1932
  const numberToKey = new Map();
1072
1933
  for (const [key, channel] of Object.entries(channels)) {
1073
- if (channel.channelNumber === undefined) {
1934
+ if (!("channelNumber" in channel) || (channel.channelNumber === undefined)) {
1074
1935
  continue;
1075
1936
  }
1076
1937
  const existing = numberToKey.get(channel.channelNumber);
@@ -1083,25 +1944,53 @@ export function validateImportedChannels(data, validProfiles) {
1083
1944
  }
1084
1945
  return { channels, errors, valid: errors.length === 0 };
1085
1946
  }
1086
- /* Service selections are stored in the channels.json file alongside user channels. When a selection changes, we save the entire file (channels + selections)
1087
- * to persist the change.
1088
- */
1089
1947
  /**
1090
- * Saves the current service selections to the channels file. The no-op mutation triggers a write that picks up current serviceSelections from module state via the
1091
- * beforeWrite hook.
1092
- * @throws If the file cannot be written.
1948
+ * Sorts tags case-insensitively using locale-aware comparison. This is the single source of truth for tag ordering. Every write path (parseTagInput, PATCH
1949
+ * handlers, computePredefinedDelta, transformChannelTags, setTagRegistry) routes through this so stored tag arrays share one canonical ordering. The channels
1950
+ * normalizer is a READER of that invariant - it uses sortTags on both sides when comparing a delta's tags against a predefined's tags, making the JSON.stringify
1951
+ * equality check canonical regardless of how either side was originally populated.
1952
+ * @param tags - Any iterable of tag strings (array, Set, generator). Not mutated.
1953
+ * @returns A new array with the same elements in canonical case-insensitive order.
1093
1954
  */
1094
- export async function saveServiceSelections() {
1095
- // No-op mutation: the beforeWrite hook injects current serviceSelections from module state.
1096
- await mutateChannels(() => { });
1955
+ export function sortTags(tags) {
1956
+ return Array.from(tags).toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
1097
1957
  }
1098
1958
  /**
1099
- * Saves the current tag registry to the channels file. The no-op mutation triggers a write that picks up the current tag registry from module state via the
1100
- * beforeWrite hook.
1101
- * @throws If the file cannot be written.
1959
+ * Parses a comma-separated tag input string into a normalized tag array. Trims whitespace, drops empty entries, deduplicates case-sensitively via Set (so
1960
+ * distinct casings survive as distinct tags), then sorts via sortTags so every caller produces identical normalized arrays.
1961
+ * @param raw - The comma-separated tag input from a form field or inline editor. An empty or whitespace-only string returns an empty array.
1962
+ * @returns Sorted, case-sensitively-deduplicated array of trimmed tag strings.
1102
1963
  */
1103
- export async function saveTagRegistry() {
1104
- // No-op mutation: the beforeWrite hook injects the current tag registry from module state.
1105
- await mutateChannels(() => { });
1964
+ export function parseTagInput(raw) {
1965
+ if (!raw) {
1966
+ return [];
1967
+ }
1968
+ return sortTags(new Set(raw.split(",").map((t) => t.trim()).filter((t) => t.length > 0)));
1106
1969
  }
1970
+ /* Test-only export barrel. The helpers below are intentionally file-private for production code - the resolver, normalizer, and migration classifier are
1971
+ * internal building blocks, and exposing them as public API would invite misuse from routes and streaming code that should be going through the higher-level
1972
+ * functions above. Unit tests need direct access to exercise each building block in isolation, so they reach in through this named export. Production code
1973
+ * must not import from this barrel; its shape is not stable and its presence in the module carries no API guarantee.
1974
+ */
1975
+ export const __internalForTests = {
1976
+ applyOverlayKernel,
1977
+ buildResolvedCanonicals,
1978
+ channelsMigrations,
1979
+ classifyEntry,
1980
+ collectLegacyVariantStamps,
1981
+ detectIdentityFieldLoss,
1982
+ filterToDeltaSurface,
1983
+ getAllowedFieldsForShape,
1984
+ getMergedChannelMap,
1985
+ normalizeChannelDeltas,
1986
+ normalizeEntryAgainstBase,
1987
+ overlayDelta,
1988
+ overlayVariantBinding,
1989
+ parseChannelsFile,
1990
+ prepareChannelsForWrite,
1991
+ resolveVariant,
1992
+ stripNulls,
1993
+ validateChannelsIntegrity,
1994
+ warnDanglingCanonical
1995
+ };
1107
1996
  //# sourceMappingURL=userChannels.js.map