prismcast 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (392) hide show
  1. package/README.md +6 -6
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +41 -13
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.d.ts +4 -6
  6. package/dist/browser/cdp.js +18 -22
  7. package/dist/browser/cdp.js.map +1 -1
  8. package/dist/browser/channelSelection.d.ts +41 -11
  9. package/dist/browser/channelSelection.js +75 -24
  10. package/dist/browser/channelSelection.js.map +1 -1
  11. package/dist/browser/display.d.ts +1 -1
  12. package/dist/browser/index.d.ts +8 -8
  13. package/dist/browser/index.js +44 -45
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/login.d.ts +2 -2
  16. package/dist/browser/login.js +1 -1
  17. package/dist/browser/manifestInterceptor.d.ts +77 -0
  18. package/dist/browser/manifestInterceptor.js +323 -0
  19. package/dist/browser/manifestInterceptor.js.map +1 -0
  20. package/dist/browser/precaching.d.ts +1 -1
  21. package/dist/browser/precaching.js +23 -23
  22. package/dist/browser/precaching.js.map +1 -1
  23. package/dist/browser/tuning/comcastPolymer.d.ts +1 -1
  24. package/dist/browser/tuning/cox.d.ts +1 -1
  25. package/dist/browser/tuning/directv.d.ts +1 -1
  26. package/dist/browser/tuning/directv.js +65 -69
  27. package/dist/browser/tuning/directv.js.map +1 -1
  28. package/dist/browser/tuning/fox.d.ts +1 -1
  29. package/dist/browser/tuning/fox.js +172 -15
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.d.ts +1 -1
  32. package/dist/browser/tuning/hbo.js +7 -7
  33. package/dist/browser/tuning/hbo.js.map +1 -1
  34. package/dist/browser/tuning/hulu.d.ts +1 -1
  35. package/dist/browser/tuning/hulu.js +120 -113
  36. package/dist/browser/tuning/hulu.js.map +1 -1
  37. package/dist/browser/tuning/shared.d.ts +3 -3
  38. package/dist/browser/tuning/shared.js +5 -5
  39. package/dist/browser/tuning/sling.d.ts +1 -1
  40. package/dist/browser/tuning/sling.js +30 -30
  41. package/dist/browser/tuning/sling.js.map +1 -1
  42. package/dist/browser/tuning/spectrum.d.ts +1 -1
  43. package/dist/browser/tuning/spectrum.js +21 -22
  44. package/dist/browser/tuning/spectrum.js.map +1 -1
  45. package/dist/browser/tuning/thumbnailRow.d.ts +1 -1
  46. package/dist/browser/tuning/thumbnailRow.js +1 -1
  47. package/dist/browser/tuning/tileClick.d.ts +1 -1
  48. package/dist/browser/tuning/tileClick.js +5 -5
  49. package/dist/browser/tuning/xfinity.d.ts +1 -1
  50. package/dist/browser/tuning/youtubeTv.d.ts +1 -1
  51. package/dist/browser/tuning/youtubeTv.js +17 -18
  52. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  53. package/dist/browser/video.d.ts +20 -6
  54. package/dist/browser/video.js +30 -38
  55. package/dist/browser/video.js.map +1 -1
  56. package/dist/channels/index.d.ts +1 -2
  57. package/dist/channels/index.js +475 -442
  58. package/dist/channels/index.js.map +1 -1
  59. package/dist/config/channelForm.d.ts +58 -0
  60. package/dist/config/channelForm.js +129 -0
  61. package/dist/config/channelForm.js.map +1 -0
  62. package/dist/config/consistencyProbe.d.ts +7 -0
  63. package/dist/config/consistencyProbe.js +143 -0
  64. package/dist/config/consistencyProbe.js.map +1 -0
  65. package/dist/config/health.d.ts +7 -6
  66. package/dist/config/health.js +78 -65
  67. package/dist/config/health.js.map +1 -1
  68. package/dist/config/index.d.ts +1 -1
  69. package/dist/config/index.js +21 -14
  70. package/dist/config/index.js.map +1 -1
  71. package/dist/config/paths.d.ts +9 -3
  72. package/dist/config/paths.js +12 -4
  73. package/dist/config/paths.js.map +1 -1
  74. package/dist/config/persistence.context.d.ts +8 -0
  75. package/dist/config/persistence.context.js +30 -0
  76. package/dist/config/persistence.context.js.map +1 -0
  77. package/dist/config/persistence.d.ts +157 -0
  78. package/dist/config/persistence.js +446 -0
  79. package/dist/config/persistence.js.map +1 -0
  80. package/dist/config/presets.d.ts +14 -4
  81. package/dist/config/presets.js +84 -75
  82. package/dist/config/presets.js.map +1 -1
  83. package/dist/config/profiles.d.ts +2 -2
  84. package/dist/config/profiles.js +7 -9
  85. package/dist/config/profiles.js.map +1 -1
  86. package/dist/config/servicePacks.d.ts +46 -0
  87. package/dist/config/{providerPacks.js → servicePacks.js} +50 -53
  88. package/dist/config/servicePacks.js.map +1 -0
  89. package/dist/config/services.d.ts +228 -0
  90. package/dist/config/services.js +772 -0
  91. package/dist/config/services.js.map +1 -0
  92. package/dist/config/sites.d.ts +3 -3
  93. package/dist/config/sites.js +75 -75
  94. package/dist/config/sites.js.map +1 -1
  95. package/dist/config/userChannels.d.ts +522 -54
  96. package/dist/config/userChannels.js +1343 -366
  97. package/dist/config/userChannels.js.map +1 -1
  98. package/dist/config/userConfig.d.ts +108 -13
  99. package/dist/config/userConfig.js +296 -143
  100. package/dist/config/userConfig.js.map +1 -1
  101. package/dist/config/userProfiles.d.ts +34 -16
  102. package/dist/config/userProfiles.js +189 -100
  103. package/dist/config/userProfiles.js.map +1 -1
  104. package/dist/hdhr/channelMap.d.ts +1 -1
  105. package/dist/hdhr/channelMap.js +10 -16
  106. package/dist/hdhr/channelMap.js.map +1 -1
  107. package/dist/hdhr/deviceId.js +10 -9
  108. package/dist/hdhr/deviceId.js.map +1 -1
  109. package/dist/hdhr/discover.js +4 -5
  110. package/dist/hdhr/discover.js.map +1 -1
  111. package/dist/hdhr/index.js +12 -19
  112. package/dist/hdhr/index.js.map +1 -1
  113. package/dist/index.js +12 -12
  114. package/dist/index.js.map +1 -1
  115. package/dist/native/codecInference.d.ts +44 -0
  116. package/dist/native/codecInference.js +327 -0
  117. package/dist/native/codecInference.js.map +1 -0
  118. package/dist/native/decrypt.d.ts +1 -1
  119. package/dist/native/index.d.ts +5 -5
  120. package/dist/native/index.js +45 -67
  121. package/dist/native/index.js.map +1 -1
  122. package/dist/native/probe.d.ts +27 -9
  123. package/dist/native/probe.js +159 -68
  124. package/dist/native/probe.js.map +1 -1
  125. package/dist/native/proxy.d.ts +4 -2
  126. package/dist/native/proxy.js +56 -53
  127. package/dist/native/proxy.js.map +1 -1
  128. package/dist/native/tokenExpiry.d.ts +22 -0
  129. package/dist/native/tokenExpiry.js +153 -0
  130. package/dist/native/tokenExpiry.js.map +1 -0
  131. package/dist/routes/assets.js +3 -3
  132. package/dist/routes/assets.js.map +1 -1
  133. package/dist/routes/auth.js +9 -13
  134. package/dist/routes/auth.js.map +1 -1
  135. package/dist/routes/components.js +2 -2
  136. package/dist/routes/components.js.map +1 -1
  137. package/dist/routes/config/channels/endpoints/browse.d.ts +6 -0
  138. package/dist/routes/config/channels/endpoints/browse.js +195 -0
  139. package/dist/routes/config/channels/endpoints/browse.js.map +1 -0
  140. package/dist/routes/config/channels/endpoints/bulk.d.ts +6 -0
  141. package/dist/routes/config/channels/endpoints/bulk.js +128 -0
  142. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -0
  143. package/dist/routes/config/channels/endpoints/crud.d.ts +6 -0
  144. package/dist/routes/config/channels/endpoints/crud.js +504 -0
  145. package/dist/routes/config/channels/endpoints/crud.js.map +1 -0
  146. package/dist/routes/config/channels/endpoints/importExport.d.ts +6 -0
  147. package/dist/routes/config/channels/endpoints/importExport.js +131 -0
  148. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -0
  149. package/dist/routes/config/channels/endpoints/predefined.d.ts +6 -0
  150. package/dist/routes/config/channels/endpoints/predefined.js +78 -0
  151. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -0
  152. package/dist/routes/config/channels/endpoints/prefs.d.ts +6 -0
  153. package/dist/routes/config/channels/endpoints/prefs.js +58 -0
  154. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -0
  155. package/dist/routes/config/channels/endpoints/service.d.ts +6 -0
  156. package/dist/routes/config/channels/endpoints/service.js +149 -0
  157. package/dist/routes/config/channels/endpoints/service.js.map +1 -0
  158. package/dist/routes/config/channels/endpoints/tags.d.ts +6 -0
  159. package/dist/routes/config/channels/endpoints/tags.js +185 -0
  160. package/dist/routes/config/channels/endpoints/tags.js.map +1 -0
  161. package/dist/routes/config/channels/http/handler.d.ts +10 -0
  162. package/dist/routes/config/channels/http/handler.js +20 -0
  163. package/dist/routes/config/channels/http/handler.js.map +1 -0
  164. package/dist/routes/config/channels/http/playlistHint.d.ts +25 -0
  165. package/dist/routes/config/channels/http/playlistHint.js +40 -0
  166. package/dist/routes/config/channels/http/playlistHint.js.map +1 -0
  167. package/dist/routes/config/channels/http/serviceWarning.d.ts +10 -0
  168. package/dist/routes/config/channels/http/serviceWarning.js +28 -0
  169. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -0
  170. package/dist/routes/config/channels/index.d.ts +3 -3
  171. package/dist/routes/config/channels/index.js +2 -2
  172. package/dist/routes/config/channels/index.js.map +1 -1
  173. package/dist/routes/config/channels/{routes.d.ts → setup.d.ts} +1 -1
  174. package/dist/routes/config/channels/setup.js +23 -0
  175. package/dist/routes/config/channels/setup.js.map +1 -0
  176. package/dist/routes/config/channels/table.d.ts +13 -13
  177. package/dist/routes/config/channels/table.js +291 -184
  178. package/dist/routes/config/channels/table.js.map +1 -1
  179. package/dist/routes/config/http/envelope.d.ts +111 -0
  180. package/dist/routes/config/http/envelope.js +103 -0
  181. package/dist/routes/config/http/envelope.js.map +1 -0
  182. package/dist/routes/config/index.d.ts +9 -9
  183. package/dist/routes/config/index.js +5 -5
  184. package/dist/routes/config/index.js.map +1 -1
  185. package/dist/routes/config/{providers.js → services.js} +105 -99
  186. package/dist/routes/config/services.js.map +1 -0
  187. package/dist/routes/config/settings.d.ts +1 -1
  188. package/dist/routes/config/settings.js +85 -60
  189. package/dist/routes/config/settings.js.map +1 -1
  190. package/dist/routes/debug.js +17 -18
  191. package/dist/routes/debug.js.map +1 -1
  192. package/dist/routes/health.js +1 -1
  193. package/dist/routes/health.js.map +1 -1
  194. package/dist/routes/icons.js +2 -2
  195. package/dist/routes/icons.js.map +1 -1
  196. package/dist/routes/index.js +2 -2
  197. package/dist/routes/index.js.map +1 -1
  198. package/dist/routes/logs.js +23 -34
  199. package/dist/routes/logs.js.map +1 -1
  200. package/dist/routes/mpegts.js +1 -1
  201. package/dist/routes/playlist.d.ts +5 -5
  202. package/dist/routes/playlist.js +84 -61
  203. package/dist/routes/playlist.js.map +1 -1
  204. package/dist/routes/root/content.js +82 -61
  205. package/dist/routes/root/content.js.map +1 -1
  206. package/dist/routes/root/index.js +2 -5
  207. package/dist/routes/root/index.js.map +1 -1
  208. package/dist/routes/root/scripts/channels.js +538 -581
  209. package/dist/routes/root/scripts/channels.js.map +1 -1
  210. package/dist/routes/root/scripts/config.js +868 -1022
  211. package/dist/routes/root/scripts/config.js.map +1 -1
  212. package/dist/routes/root/scripts/index.d.ts +4 -4
  213. package/dist/routes/root/scripts/shared.js +445 -197
  214. package/dist/routes/root/scripts/shared.js.map +1 -1
  215. package/dist/routes/root/scripts/status.d.ts +4 -0
  216. package/dist/routes/root/scripts/status.handlers.d.ts +182 -0
  217. package/dist/routes/root/scripts/status.handlers.js +713 -0
  218. package/dist/routes/root/scripts/status.handlers.js.map +1 -0
  219. package/dist/routes/root/scripts/status.js +85 -436
  220. package/dist/routes/root/scripts/status.js.map +1 -1
  221. package/dist/routes/root/styles.js +16 -8
  222. package/dist/routes/root/styles.js.map +1 -1
  223. package/dist/routes/services.d.ts +6 -0
  224. package/dist/routes/{providers.js → services.js} +58 -55
  225. package/dist/routes/services.js.map +1 -0
  226. package/dist/routes/sse.d.ts +16 -0
  227. package/dist/routes/sse.js +29 -0
  228. package/dist/routes/sse.js.map +1 -0
  229. package/dist/routes/streams.js +10 -23
  230. package/dist/routes/streams.js.map +1 -1
  231. package/dist/routes/ui.d.ts +2 -2
  232. package/dist/routes/ui.js +2 -0
  233. package/dist/routes/ui.js.map +1 -1
  234. package/dist/routes/upgrade.js +6 -7
  235. package/dist/routes/upgrade.js.map +1 -1
  236. package/dist/service/commands.context.d.ts +6 -0
  237. package/dist/service/commands.context.js +47 -0
  238. package/dist/service/commands.context.js.map +1 -0
  239. package/dist/service/commands.d.ts +53 -8
  240. package/dist/service/commands.js +172 -192
  241. package/dist/service/commands.js.map +1 -1
  242. package/dist/service/generators.context.d.ts +6 -0
  243. package/dist/service/generators.context.js +45 -0
  244. package/dist/service/generators.context.js.map +1 -0
  245. package/dist/service/generators.d.ts +56 -16
  246. package/dist/service/generators.js +509 -291
  247. package/dist/service/generators.js.map +1 -1
  248. package/dist/service/index.d.ts +2 -2
  249. package/dist/streaming/clients.d.ts +2 -2
  250. package/dist/streaming/clients.js +2 -2
  251. package/dist/streaming/codec.d.ts +2 -2
  252. package/dist/streaming/codec.js +0 -1
  253. package/dist/streaming/codec.js.map +1 -1
  254. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  255. package/dist/streaming/fmp4Segmenter.js +12 -12
  256. package/dist/streaming/hls.d.ts +7 -7
  257. package/dist/streaming/hls.js +104 -92
  258. package/dist/streaming/hls.js.map +1 -1
  259. package/dist/streaming/hlsResume.d.ts +3 -3
  260. package/dist/streaming/hlsResume.js +6 -6
  261. package/dist/streaming/hlsResume.js.map +1 -1
  262. package/dist/streaming/hlsSegments.d.ts +5 -2
  263. package/dist/streaming/hlsSegments.js +14 -9
  264. package/dist/streaming/hlsSegments.js.map +1 -1
  265. package/dist/streaming/monitor.d.ts +12 -12
  266. package/dist/streaming/monitor.js +98 -66
  267. package/dist/streaming/monitor.js.map +1 -1
  268. package/dist/streaming/mp4Parser.d.ts +34 -4
  269. package/dist/streaming/mp4Parser.js +111 -29
  270. package/dist/streaming/mp4Parser.js.map +1 -1
  271. package/dist/streaming/mpegts.d.ts +1 -1
  272. package/dist/streaming/mpegts.js +13 -10
  273. package/dist/streaming/mpegts.js.map +1 -1
  274. package/dist/streaming/playlistBuilder.d.ts +1 -1
  275. package/dist/streaming/playlistBuilder.js +2 -2
  276. package/dist/streaming/preroll.d.ts +9 -9
  277. package/dist/streaming/preroll.js +40 -42
  278. package/dist/streaming/preroll.js.map +1 -1
  279. package/dist/streaming/pretune.js +4 -4
  280. package/dist/streaming/pretune.js.map +1 -1
  281. package/dist/streaming/recovery.d.ts +3 -3
  282. package/dist/streaming/recovery.js +4 -4
  283. package/dist/streaming/registry.d.ts +6 -6
  284. package/dist/streaming/registry.js +4 -8
  285. package/dist/streaming/registry.js.map +1 -1
  286. package/dist/streaming/setup.d.ts +11 -10
  287. package/dist/streaming/setup.js +120 -71
  288. package/dist/streaming/setup.js.map +1 -1
  289. package/dist/streaming/showInfo.d.ts +9 -3
  290. package/dist/streaming/showInfo.js +33 -27
  291. package/dist/streaming/showInfo.js.map +1 -1
  292. package/dist/streaming/statusEmitter.d.ts +8 -7
  293. package/dist/streaming/statusEmitter.js +5 -5
  294. package/dist/streaming/statusEmitter.js.map +1 -1
  295. package/dist/types/channels.d.ts +139 -36
  296. package/dist/types/channels.js +39 -1
  297. package/dist/types/channels.js.map +1 -1
  298. package/dist/types/config.d.ts +22 -3
  299. package/dist/types/index.d.ts +9 -8
  300. package/dist/types/index.js +1 -0
  301. package/dist/types/index.js.map +1 -1
  302. package/dist/types/profiles.d.ts +14 -13
  303. package/dist/types/selection.d.ts +49 -5
  304. package/dist/types/shared.d.ts +1 -1
  305. package/dist/types/streaming.d.ts +2 -2
  306. package/dist/upgrade/commands.context.d.ts +6 -0
  307. package/dist/upgrade/commands.context.js +29 -0
  308. package/dist/upgrade/commands.context.js.map +1 -0
  309. package/dist/upgrade/commands.d.ts +29 -2
  310. package/dist/upgrade/commands.js +87 -134
  311. package/dist/upgrade/commands.js.map +1 -1
  312. package/dist/upgrade/detection.context.d.ts +6 -0
  313. package/dist/upgrade/detection.context.js +24 -0
  314. package/dist/upgrade/detection.context.js.map +1 -0
  315. package/dist/upgrade/detection.d.ts +140 -10
  316. package/dist/upgrade/detection.js +153 -66
  317. package/dist/upgrade/detection.js.map +1 -1
  318. package/dist/upgrade/index.d.ts +2 -2
  319. package/dist/utils/chromeFetch.d.ts +1 -1
  320. package/dist/utils/chromeFetch.js +2 -2
  321. package/dist/utils/clock.d.ts +14 -0
  322. package/dist/utils/clock.js +28 -0
  323. package/dist/utils/clock.js.map +1 -0
  324. package/dist/utils/debugFilter.d.ts +1 -1
  325. package/dist/utils/debugFilter.js +8 -6
  326. package/dist/utils/debugFilter.js.map +1 -1
  327. package/dist/utils/delay.d.ts +2 -3
  328. package/dist/utils/delay.js +10 -16
  329. package/dist/utils/delay.js.map +1 -1
  330. package/dist/utils/evaluate.js +12 -14
  331. package/dist/utils/evaluate.js.map +1 -1
  332. package/dist/utils/ffmpeg.context.d.ts +6 -0
  333. package/dist/utils/ffmpeg.context.js +31 -0
  334. package/dist/utils/ffmpeg.context.js.map +1 -0
  335. package/dist/utils/ffmpeg.d.ts +85 -29
  336. package/dist/utils/ffmpeg.js +126 -129
  337. package/dist/utils/ffmpeg.js.map +1 -1
  338. package/dist/utils/fileLogger.d.ts +18 -2
  339. package/dist/utils/fileLogger.js +60 -31
  340. package/dist/utils/fileLogger.js.map +1 -1
  341. package/dist/utils/format.d.ts +7 -1
  342. package/dist/utils/format.js +22 -2
  343. package/dist/utils/format.js.map +1 -1
  344. package/dist/utils/html.js +1 -3
  345. package/dist/utils/html.js.map +1 -1
  346. package/dist/utils/index.d.ts +21 -20
  347. package/dist/utils/index.js +1 -0
  348. package/dist/utils/index.js.map +1 -1
  349. package/dist/utils/logger.js +31 -45
  350. package/dist/utils/logger.js.map +1 -1
  351. package/dist/utils/m3u.d.ts +18 -3
  352. package/dist/utils/m3u.js +28 -14
  353. package/dist/utils/m3u.js.map +1 -1
  354. package/dist/utils/memo.d.ts +19 -0
  355. package/dist/utils/memo.js +53 -0
  356. package/dist/utils/memo.js.map +1 -0
  357. package/dist/utils/morganStream.js +4 -4
  358. package/dist/utils/morganStream.js.map +1 -1
  359. package/dist/utils/pid.d.ts +2 -2
  360. package/dist/utils/pid.js +2 -2
  361. package/dist/utils/platform.d.ts +1 -1
  362. package/dist/utils/platform.js +3 -2
  363. package/dist/utils/platform.js.map +1 -1
  364. package/dist/utils/retry.d.ts +2 -0
  365. package/dist/utils/retry.js +9 -7
  366. package/dist/utils/retry.js.map +1 -1
  367. package/dist/utils/sanitize.d.ts +2 -2
  368. package/dist/utils/sanitize.js +4 -4
  369. package/dist/utils/streamContext.d.ts +7 -0
  370. package/dist/utils/streamContext.js +10 -1
  371. package/dist/utils/streamContext.js.map +1 -1
  372. package/dist/utils/timing.d.ts +6 -3
  373. package/dist/utils/timing.js +10 -6
  374. package/dist/utils/timing.js.map +1 -1
  375. package/dist/utils/version.d.ts +3 -2
  376. package/dist/utils/version.js +15 -28
  377. package/dist/utils/version.js.map +1 -1
  378. package/package.json +14 -10
  379. package/dist/config/providerPacks.d.ts +0 -46
  380. package/dist/config/providerPacks.js.map +0 -1
  381. package/dist/config/providers.d.ts +0 -175
  382. package/dist/config/providers.js +0 -691
  383. package/dist/config/providers.js.map +0 -1
  384. package/dist/native/intercept.d.ts +0 -35
  385. package/dist/native/intercept.js +0 -184
  386. package/dist/native/intercept.js.map +0 -1
  387. package/dist/routes/config/channels/routes.js +0 -1477
  388. package/dist/routes/config/channels/routes.js.map +0 -1
  389. package/dist/routes/config/providers.js.map +0 -1
  390. package/dist/routes/providers.d.ts +0 -6
  391. package/dist/routes/providers.js.map +0 -1
  392. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -1,8 +1,8 @@
1
- import { LOG, stringifySorted } from "../utils/index.js";
2
- import { getConfigFilePath, getDataDir } from "./paths.js";
3
- import fs from "node:fs";
1
+ import { createFileStore } from "./persistence.js";
2
+ import { LOG } from "../utils/index.js";
3
+ import { getConfigFilePath } from "./paths.js";
4
4
  import { getValidPresetIds } from "./presets.js";
5
- const { promises: fsPromises } = fs;
5
+ import { isDeepStrictEqual } from "node:util";
6
6
  /**
7
7
  * Metadata for all configurable settings, organized by category.
8
8
  */
@@ -31,16 +31,28 @@ export const CONFIG_METADATA = {
31
31
  ],
32
32
  channels: [
33
33
  {
34
- description: "Speed up your first channel tune by loading provider lineups when PrismCast starts. Normally, each provider's lineup is loaded on your " +
35
- "first tune, which may add a few extra seconds. Enabling precaching fetches lineups at startup so even your very first tune is fast. Providers not " +
36
- "enabled in the Channels tab provider filter are skipped at startup. Ensure you've logged into each provider before enabling.",
34
+ description: "Speed up your first channel tune by loading service lineups when PrismCast starts. Normally, each service's lineup is loaded on your " +
35
+ "first tune, which may add a few extra seconds. Enabling precaching fetches lineups at startup so even your very first tune is fast. Services not " +
36
+ "enabled in the Channels tab service filter are skipped at startup. Ensure you've logged into each service before enabling.",
37
37
  envVar: null,
38
38
  label: "Channel Lineup Precaching",
39
39
  listItemsKey: "providerModules",
40
- path: "channels.precacheProviders",
40
+ path: "channels.precacheServices",
41
41
  type: "checkboxList"
42
42
  }
43
43
  ],
44
+ channelsDvr: [
45
+ {
46
+ description: "TCP port for the user's Channels DVR API. PrismCast polls Channels DVR over HTTP for show-info, device-mapping discovery, and pretune " +
47
+ "scheduling. Defaults to 8089, which is the canonical Channels DVR port. Override only when the user has changed the DVR's listen port from its default.",
48
+ envVar: "CHANNELS_DVR_PORT",
49
+ label: "Channels DVR Port",
50
+ max: 65535,
51
+ min: 1,
52
+ path: "channelsDvr.port",
53
+ type: "port"
54
+ }
55
+ ],
44
56
  hdhr: [
45
57
  {
46
58
  description: "Enable HDHomeRun emulation for Plex integration. When enabled, PrismCast runs a second HTTP server that emulates an HDHomeRun tuner, " +
@@ -467,49 +479,164 @@ export const CONFIG_METADATA = {
467
479
  ]
468
480
  };
469
481
  /* The config file path is resolved via the centralized paths module (config/paths.ts). The data directory is initialized at startup before config loading.
482
+ * Configuration persistence uses a transactional file store that provides atomic writes, serialized mutations, corruption protection, and backup rotation.
483
+ * All config modifications go through mutateConfig(), which prevents the class of bugs where a corrupt file gets silently overwritten with nearly-empty data.
484
+ */
485
+ /* Current schema version for config.json. Migrations are declared in configMigrations below; the framework runs them in order from the file's stored version
486
+ * up to this constant, stamps the new version after each, and records the audit trail in migrationsApplied.
487
+ *
488
+ * Version history:
489
+ * 1 - Original. Provider-themed channel field names ("enabledProviders", "precacheProviders") and "foxcom" service tag still present.
490
+ * 2 - Service-themed naming. Renames "enabledProviders" -> "enabledServices", "precacheProviders" -> "precacheServices", and "foxcom" -> "foxone" inside
491
+ * the channels.enabledServices array. Companion to channels.json v3 which renames foxcom in channel keys and selections.
492
+ * 3 - DVR connection namespace. Moves the top-level `dvrHost` field into `channelsDvr.host`, splitting any legacy `host:port` value so the host portion
493
+ * lands at `channelsDvr.host` (host-only) and the port portion lands at `channelsDvr.port` only when the user has not already customized the port.
470
494
  */
471
- /* These functions handle reading and writing the config file. All operations are async and handle errors gracefully.
495
+ const CURRENT_CONFIG_SCHEMA_VERSION = 3;
496
+ /* Declarative schema migrations. The file store framework runs these in order from the file's stored schemaVersion up to CURRENT_CONFIG_SCHEMA_VERSION,
497
+ * stamping the new version and recording the description in migrationsApplied after each application. Apply functions mutate the data in place.
472
498
  */
499
+ const configMigrations = {
500
+ 2: {
501
+ apply: applyChannelsProviderRenameMigration,
502
+ description: "Rename legacy provider-themed channel field names and foxcom service tag to foxone"
503
+ },
504
+ 3: {
505
+ apply: applyDvrHostNamespaceMigration,
506
+ description: "Move dvrHost into channelsDvr.host (split legacy host:port format)"
507
+ }
508
+ };
473
509
  /**
474
- * Loads user configuration from the config file. Returns an empty config if the file doesn't exist, and sets parseError if the file exists but contains invalid
475
- * JSON.
476
- * @returns The loaded configuration with parse status.
510
+ * Renames the legacy provider-themed channel fields to their current service-themed names: `enabledProviders` -> `enabledServices`,
511
+ * `precacheProviders` -> `precacheServices`, and the `"foxcom"` tag -> `"foxone"` inside `enabledServices`. When both legacy and current names coexist on disk
512
+ * (a rare hand-edited collision), the current name wins and the legacy key is deleted - operator intent on the new name takes precedence over the auto-rename.
513
+ *
514
+ * Behavioral contract: this is a pure transformation on the on-disk shape. Configs without a `channels` block are left untouched (early return); configs that
515
+ * already have the v2 shape (no legacy keys present) are unchanged at the field level (idempotent), though the foxcom-to-foxone map runs unconditionally over
516
+ * the present `enabledServices` array because that operation is itself a no-op for already-migrated tags.
517
+ *
518
+ * Exported for unit-test coverage of the rename cases (each rename, collision wins, foxcom remap, no-channels early return). Production callers reach this
519
+ * only through the schema migration runner, never directly.
520
+ *
521
+ * @param data - The pre-migration UserConfig data, mutated in place.
477
522
  */
478
- export async function loadUserConfig() {
479
- try {
480
- const content = await fsPromises.readFile(getConfigFilePath(), "utf-8");
481
- try {
482
- const config = JSON.parse(content);
483
- return { config, parseError: false };
484
- }
485
- catch (parseError) {
486
- const message = (parseError instanceof Error) ? parseError.message : String(parseError);
487
- LOG.warn("Invalid JSON in configuration file %s: %s. Using defaults.", getConfigFilePath(), message);
488
- return { config: {}, parseError: true, parseErrorMessage: message };
523
+ export function applyChannelsProviderRenameMigration(data) {
524
+ // Cast to a permissive shape because the legacy provider-themed keys are not declared on UserChannelsConfig - they only exist on older on-disk files.
525
+ const channels = data.channels;
526
+ if (!channels) {
527
+ return;
528
+ }
529
+ // Rename enabledProviders -> enabledServices. If both are present (rare hand-edited case) the current name wins.
530
+ if (Array.isArray(channels["enabledProviders"])) {
531
+ if (!Array.isArray(channels["enabledServices"])) {
532
+ channels["enabledServices"] = channels["enabledProviders"];
533
+ }
534
+ delete channels["enabledProviders"];
535
+ }
536
+ // Rename precacheProviders -> precacheServices.
537
+ if (Array.isArray(channels["precacheProviders"])) {
538
+ if (!Array.isArray(channels["precacheServices"])) {
539
+ channels["precacheServices"] = channels["precacheProviders"];
489
540
  }
541
+ delete channels["precacheProviders"];
490
542
  }
491
- catch (error) {
492
- // File doesn't exist - this is normal, use defaults.
493
- if (error.code === "ENOENT") {
494
- return { config: {}, parseError: false };
495
- }
496
- // Other read errors - log and use defaults.
497
- LOG.warn("Failed to read configuration file %s: %s. Using defaults.", getConfigFilePath(), (error instanceof Error) ? error.message : String(error));
498
- return { config: {}, parseError: false };
543
+ // Rename "foxcom" -> "foxone" inside the enabledServices service-tag filter.
544
+ if (Array.isArray(channels["enabledServices"])) {
545
+ channels["enabledServices"] = channels["enabledServices"].map((tag) => (tag === "foxcom") ? "foxone" : tag);
499
546
  }
500
547
  }
501
548
  /**
502
- * Saves user configuration to the config file. Creates the data directory if it doesn't exist.
503
- * @param config - The configuration to save.
504
- * @throws If the file cannot be written.
549
+ * Splits a legacy `dvrHost` value into the new `channelsDvr.host` / `channelsDvr.port` namespace. The host portion is host-only by invariant; legacy values
550
+ * carrying an embedded port (e.g., `192.168.1.5:8089`) are split so the host lands at `channelsDvr.host` and the port lands at `channelsDvr.port` IFF the
551
+ * user has not already customized the port. When both an embedded port and an explicit user-set port exist, the explicit port wins (a warning is logged) and
552
+ * the embedded port is discarded - the user's deliberate choice takes precedence over implicit legacy data.
553
+ *
554
+ * Splits at the LAST colon so bracket-wrapped IPv6 forms like `[::1]:8089` survive (the production setDvrHost invariant rejects bare-colon values, but disk
555
+ * files may carry hand-edited content the framework cannot vet). When the trailing portion does not parse as a valid port number (1..65535), the entire input
556
+ * is treated as a host-only value rather than fabricating a bogus port.
557
+ *
558
+ * Exported for unit-test coverage of the splitting cases (host-only, host+default-port, host+non-default-port collision). Production callers reach this only
559
+ * through the schema migration runner, never directly.
560
+ *
561
+ * @param data - The pre-migration UserConfig data, mutated in place.
505
562
  */
506
- export async function saveUserConfig(config) {
507
- // Ensure data directory exists.
508
- await fsPromises.mkdir(getDataDir(), { recursive: true });
509
- // Write config with pretty formatting and sorted keys for consistent, diff-friendly output.
510
- const content = stringifySorted(config);
511
- await fsPromises.writeFile(getConfigFilePath(), content + "\n", "utf-8");
512
- LOG.info("Configuration saved to %s.", getConfigFilePath());
563
+ export function applyDvrHostNamespaceMigration(data) {
564
+ // The legacy field is not declared on the current UserConfig; we read it through a permissive cast to access the on-disk pre-migration shape. After migration
565
+ // the field is deleted so it cannot leak into the post-migration write path.
566
+ const legacy = data;
567
+ const dvrHost = legacy.dvrHost;
568
+ if (typeof dvrHost !== "string") {
569
+ return;
570
+ }
571
+ let host = dvrHost;
572
+ let embeddedPort;
573
+ // Split at the last colon so bracket-wrapped IPv6 (e.g., [::1]:8089) survives. If the trailing portion does not parse as a valid TCP port, treat the whole
574
+ // input as host-only - avoid fabricating a port from a malformed string.
575
+ const colonIdx = dvrHost.lastIndexOf(":");
576
+ if (colonIdx >= 0) {
577
+ const tail = dvrHost.slice(colonIdx + 1);
578
+ const parsed = Number(tail);
579
+ if (Number.isInteger(parsed) && (parsed >= 1) && (parsed <= 65535)) {
580
+ host = dvrHost.slice(0, colonIdx);
581
+ embeddedPort = parsed;
582
+ }
583
+ }
584
+ data.channelsDvr ??= {};
585
+ data.channelsDvr.host = host;
586
+ // Migrate the embedded port only when the user has not customized the port already. A user-set port reflects an explicit choice that overrides whatever the
587
+ // legacy host:port string encoded - prefer the explicit value and log the conflict so it is auditable.
588
+ if (embeddedPort !== undefined) {
589
+ const userPort = data.channelsDvr.port;
590
+ if (userPort === undefined) {
591
+ data.channelsDvr.port = embeddedPort;
592
+ }
593
+ else if (userPort !== embeddedPort) {
594
+ LOG.warn("Schema migration v3: legacy dvrHost \"%s\" carried embedded port %d but channelsDvr.port is already set to %d. " +
595
+ "Keeping the explicit port; embedded port discarded.", dvrHost, embeddedPort, userPort);
596
+ }
597
+ }
598
+ delete legacy.dvrHost;
599
+ }
600
+ // Transactional store instance for config.json. The beforeWrite hook is the single chokepoint where the persisted shape is normalized: filterDefaults() runs on
601
+ // every save so the file on disk contains only non-default values, regardless of which call site initiated the write. Schema migrations run automatically via
602
+ // the file store framework's migration runner before the data reaches mergeConfiguration.
603
+ const configStore = createFileStore({
604
+ beforeWrite: (data) => filterDefaults(data),
605
+ currentSchemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
606
+ defaultValue: () => ({ schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION }),
607
+ getSchemaVersion: (data) => data.schemaVersion ?? 1,
608
+ label: "configuration",
609
+ migrations: configMigrations,
610
+ parse: (raw) => JSON.parse(raw),
611
+ path: getConfigFilePath,
612
+ recordMigration: (data, description) => {
613
+ data.migrationsApplied ??= [];
614
+ data.migrationsApplied.push(description);
615
+ },
616
+ setSchemaVersion: (data, version) => { data.schemaVersion = version; }
617
+ });
618
+ /**
619
+ * Reads the current configuration from disk without acquiring the serialization lock. Returns the parsed (and migrated) config with parse status. Use this for
620
+ * read-only access (export endpoints, startup initialization). For modifications, use mutateConfig() instead.
621
+ * @returns The loaded configuration with parse status.
622
+ */
623
+ export async function readConfig() {
624
+ const result = await configStore.read();
625
+ return {
626
+ config: result.data,
627
+ parseError: result.parseError,
628
+ parseErrorMessage: result.parseErrorMessage,
629
+ recoveredFromBackup: result.recoveredFromBackup
630
+ };
631
+ }
632
+ /**
633
+ * Serialized read-modify-write operation on config.json. The mutation function receives the current config (already migrated to the latest schema version) and
634
+ * modifies it in place. The store handles atomicity, serialization, corruption guard, backup, schema migration, and filterDefaults via the framework.
635
+ * @param fn - Mutation function. Receives current config. Modify in place; return value is ignored.
636
+ * @throws FileStoreParseError if config.json contains invalid JSON and no usable backup exists.
637
+ */
638
+ export async function mutateConfig(fn) {
639
+ await configStore.mutate(fn);
513
640
  }
514
641
  /* These functions detect which settings are overridden by environment variables, so the UI can disable those fields and show appropriate warnings.
515
642
  */
@@ -543,11 +670,15 @@ export const DEFAULTS = {
543
670
  channelSortDirection: "asc",
544
671
  channelSortField: "name",
545
672
  disabledPredefined: [],
546
- enabledProviders: [],
547
- precacheProviders: [],
673
+ enabledServices: [],
674
+ precacheServices: [],
548
675
  setupCompleted: false,
549
676
  visibleColumns: []
550
677
  },
678
+ channelsDvr: {
679
+ host: "",
680
+ port: 8089
681
+ },
551
682
  hdhr: {
552
683
  deviceId: "",
553
684
  enabled: true,
@@ -638,7 +769,7 @@ function parseEnvValue(value, type) {
638
769
  return value.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
639
770
  }
640
771
  case "path": {
641
- // An empty path env var means "use default" — return null so the downstream code sees the same sentinel as an unset config field.
772
+ // An empty path env var means "use default" - return null so the downstream code sees the same sentinel as an unset config field.
642
773
  return (value.trim() === "") ? null : value;
643
774
  }
644
775
  default: {
@@ -672,14 +803,11 @@ export function getNestedValue(obj, settingPath) {
672
803
  export function setNestedValue(obj, settingPath, value) {
673
804
  const parts = settingPath.split(".");
674
805
  let current = obj;
675
- for (let i = 0; i < (parts.length - 1); i++) {
676
- const part = parts[i];
677
- if (current[part] === undefined) {
678
- current[part] = {};
679
- }
806
+ for (const part of parts.slice(0, -1)) {
807
+ current[part] ??= {};
680
808
  current = current[part];
681
809
  }
682
- current[parts[parts.length - 1]] = value;
810
+ current[parts.at(-1) ?? ""] = value;
683
811
  }
684
812
  /**
685
813
  * Merges user configuration with defaults, environment overrides, and CLI overrides to produce the final configuration.
@@ -700,39 +828,18 @@ export function mergeConfiguration(userConfig, cliOverrides) {
700
828
  }
701
829
  }
702
830
  }
703
- /* Array and auto-generated string fields require explicit handling here because the standard CONFIG_METADATA loop above assigns by reference. These blocks
704
- * apply defensive spread copies to prevent shared references between the user config and runtime config. Fields not in CONFIG_METADATA (disabledPredefined,
705
- * enabledProviders) also need their own POST /config carry-forward logic since they are managed by separate endpoints. The precacheProviders field is in
706
- * CONFIG_METADATA (as a checkboxList) so the standard loop handles it, but the spread copy here still provides the defensive guarantee.
707
- *
708
- * When adding a new field here, also add corresponding preservation logic in filterDefaults() below.
831
+ /* Hydrate fields that live outside CONFIG_METADATA (auto-discovery results like channelsDvr.host, separately-managed lists like channels.disabledPredefined,
832
+ * the persisted debug filter pattern) and array fields whose CONFIG_METADATA hydration would alias the parsed UserConfig blob into runtime CONFIG. The
833
+ * registry pairs each path with a predicate (ignored when undefined) and an optional defensive-copy hook. Adding a new field here is one HYDRATED_FIELDS
834
+ * entry; the drift-check test in userConfig.merge.test.ts asserts that PRESERVED_FIELDS partitions exactly into HYDRATED_FIELDS plus PERSISTENCE_ONLY_FIELDS,
835
+ * so a future preservation entry cannot be added without an explicit hydration classification. See the section comment above HYDRATED_FIELDS for the full
836
+ * design intent.
709
837
  */
710
- if (Array.isArray(userConfig.channels?.disabledPredefined)) {
711
- config.channels.disabledPredefined = [...userConfig.channels.disabledPredefined];
712
- }
713
- if (Array.isArray(userConfig.channels?.enabledProviders)) {
714
- config.channels.enabledProviders = [...userConfig.channels.enabledProviders];
715
- }
716
- if (Array.isArray(userConfig.channels?.precacheProviders)) {
717
- config.channels.precacheProviders = [...userConfig.channels.precacheProviders];
718
- }
719
- if (Array.isArray(userConfig.channels?.visibleColumns)) {
720
- config.channels.visibleColumns = [...userConfig.channels.visibleColumns];
721
- }
722
- if (Array.isArray(userConfig.streaming?.captureCodecs)) {
723
- config.streaming.captureCodecs = [...userConfig.streaming.captureCodecs];
724
- }
725
- if ((typeof userConfig.channels?.channelSortField === "string") && (userConfig.channels.channelSortField.length > 0)) {
726
- config.channels.channelSortField = userConfig.channels.channelSortField;
727
- }
728
- if ((typeof userConfig.channels?.channelSortDirection === "string") && (userConfig.channels.channelSortDirection.length > 0)) {
729
- config.channels.channelSortDirection = userConfig.channels.channelSortDirection;
730
- }
731
- if ((typeof userConfig.hdhr?.deviceId === "string") && (userConfig.hdhr.deviceId.length > 0)) {
732
- config.hdhr.deviceId = userConfig.hdhr.deviceId;
733
- }
734
- if ((typeof userConfig.logging?.debugFilter === "string") && (userConfig.logging.debugFilter.length > 0)) {
735
- config.logging.debugFilter = userConfig.logging.debugFilter;
838
+ for (const field of HYDRATED_FIELDS) {
839
+ const userValue = getNestedValue(userConfig, field.path);
840
+ if (field.shouldHydrate(userValue, undefined)) {
841
+ setNestedValue(config, field.path, field.copy ? field.copy(userValue) : userValue);
842
+ }
736
843
  }
737
844
  // Apply environment variable overrides.
738
845
  for (const settings of Object.values(CONFIG_METADATA)) {
@@ -773,7 +880,7 @@ const SETTINGS_TAB_SECTIONS = [
773
880
  {
774
881
  displayName: "Startup",
775
882
  id: "startup",
776
- paths: ["channels.precacheProviders"]
883
+ paths: ["channels.precacheServices"]
777
884
  },
778
885
  {
779
886
  displayName: "Capture",
@@ -790,6 +897,7 @@ const SETTINGS_TAB_SECTIONS = [
790
897
  /* Display metadata for Advanced tab sections. The category field must match a key in CONFIG_METADATA. Entries are sorted alphabetically by category.
791
898
  */
792
899
  const ADVANCED_SECTION_META = [
900
+ { category: "channelsDvr", displayName: "Channels DVR" },
793
901
  { category: "hls", displayName: "HLS" },
794
902
  { category: "logging", displayName: "Logging" },
795
903
  { category: "paths", displayName: "Paths" },
@@ -865,25 +973,20 @@ export function getAdvancedSections() {
865
973
  const settingsTabPaths = SETTINGS_TAB_SECTIONS.flatMap((s) => s.paths);
866
974
  // Get all paths that belong in Advanced (not in Settings).
867
975
  const advancedPaths = getAllSettingPaths().filter((p) => !settingsTabPaths.includes(p));
868
- // Group by category (first path segment).
869
- const byCategory = new Map();
870
- for (const path of advancedPaths) {
871
- const category = path.split(".")[0];
872
- const setting = getSettingByPath(path);
873
- if (setting) {
874
- if (!byCategory.has(category)) {
875
- byCategory.set(category, []);
876
- }
877
- byCategory.get(category)?.push(setting);
878
- }
879
- }
976
+ // Pair every advanced path with its resolved setting and category prefix, then drop entries whose category is empty or whose setting lookup failed. The
977
+ // surviving pairs feed Map.groupBy below for the actual grouping.
978
+ const settingsByPath = advancedPaths
979
+ .map((path) => ({ category: path.split(".")[0] ?? "", setting: getSettingByPath(path) }))
980
+ .filter((entry) => Boolean(entry.category && entry.setting));
981
+ // Group by category (first path segment) using the standard ESNext.Collection grouper.
982
+ const byCategory = Map.groupBy(settingsByPath, (entry) => entry.category);
880
983
  // Return sections in the defined order with display names.
881
984
  return ADVANCED_SECTION_META
882
985
  .filter((meta) => byCategory.has(meta.category))
883
986
  .map((meta) => ({
884
987
  displayName: meta.displayName,
885
988
  id: meta.category,
886
- settings: byCategory.get(meta.category) ?? []
989
+ settings: byCategory.get(meta.category)?.map((entry) => entry.setting) ?? []
887
990
  }));
888
991
  }
889
992
  /* When saving user configuration, we only want to persist values that differ from defaults. This keeps the config file clean and makes it easy to see what the user has
@@ -929,8 +1032,84 @@ export function isEqualToDefault(value, defaultValue) {
929
1032
  return false;
930
1033
  }
931
1034
  // Compare as strings for consistent comparison across types (handles number/string coercion). Config values are always primitives.
932
- return String(value) === String(defaultValue);
1035
+ const primitive = value;
1036
+ const defaultPrimitive = defaultValue;
1037
+ return String(primitive) === String(defaultPrimitive);
933
1038
  }
1039
+ // Predicate: any array, including empty. Used by HYDRATED_FIELDS to mirror the historical mergeConfiguration() inline-block behavior of unconditionally
1040
+ // hydrating any array-typed value present on disk; downstream validateConfiguration() normalizes empty-array edge cases (e.g., captureCodecs forces h264).
1041
+ const isArrayValue = (value) => Array.isArray(value);
1042
+ // Predicate: any non-empty array. Used for fields where the user's empty-array state coincides with the default-empty-array state (disabledPredefined,
1043
+ // enabledServices, precacheServices, visibleColumns, migrationsApplied). The defaultValue argument is intentionally unused.
1044
+ const isNonEmptyArray = (value) => Array.isArray(value) && (value.length > 0);
1045
+ // Predicate: any non-empty string. Used for fields whose default is the empty string and whose presence-on-disk should follow the same rule (hdhr.deviceId,
1046
+ // logging.debugFilter, channelsDvr.host).
1047
+ const isNonEmptyString = (value) => (typeof value === "string") && (value.length > 0);
1048
+ // Predicate: any number. Used for schemaVersion - the framework-managed integer that any non-undefined value must round-trip through filterDefaults.
1049
+ // Predicate: any number including NaN. Number.isFinite() would tighten the contract if NaN must be rejected.
1050
+ const isNumber = (value) => typeof value === "number";
1051
+ // Predicate: a string that differs from the default by simple equality. Used for channelSortField / channelSortDirection - both have meaningful default
1052
+ // values ("name", "asc") that should be stripped on save while any other valid string is preserved.
1053
+ const differsFromStringDefault = (value, defaultValue) => (typeof value === "string") && (value !== defaultValue);
1054
+ // Predicate: an array whose contents differ from the default by SORTED-element comparison. Used for streaming.captureCodecs - the default ["h264", "hevc"]
1055
+ // reordered by the user (["hevc", "h264"]) is semantically the same configuration and should not write to disk; only a different SET of codecs counts as a
1056
+ // customization worth preserving.
1057
+ const differsFromSortedArrayDefault = (value, defaultValue) => {
1058
+ return Array.isArray(value) && Array.isArray(defaultValue) && !isDeepStrictEqual(value.toSorted(), defaultValue.toSorted());
1059
+ };
1060
+ /**
1061
+ * Allowlist of config paths that filterDefaults() preserves outside the CONFIG_METADATA-driven loop. See the section comment above for the two distinct
1062
+ * reasons a path lives here rather than in CONFIG_METADATA. The list is alphabetized by path so a future maintainer can locate any entry by paging through
1063
+ * the registry; ordering does not affect runtime semantics because each entry's preserve check is independent.
1064
+ */
1065
+ export const PRESERVED_FIELDS = [
1066
+ { path: "channels.channelSortDirection", shouldPreserve: differsFromStringDefault },
1067
+ { path: "channels.channelSortField", shouldPreserve: differsFromStringDefault },
1068
+ { path: "channels.disabledPredefined", shouldPreserve: isNonEmptyArray },
1069
+ { path: "channels.enabledServices", shouldPreserve: isNonEmptyArray },
1070
+ { path: "channels.precacheServices", shouldPreserve: isNonEmptyArray },
1071
+ { path: "channels.visibleColumns", shouldPreserve: isNonEmptyArray },
1072
+ { path: "channelsDvr.host", shouldPreserve: isNonEmptyString },
1073
+ { path: "hdhr.deviceId", shouldPreserve: isNonEmptyString },
1074
+ { path: "logging.debugFilter", shouldPreserve: isNonEmptyString },
1075
+ { path: "migrationsApplied", shouldPreserve: isNonEmptyArray },
1076
+ { path: "schemaVersion", shouldPreserve: isNumber },
1077
+ { path: "streaming.captureCodecs", shouldPreserve: differsFromSortedArrayDefault }
1078
+ ];
1079
+ // Defensive spread copy for array fields. Pulled out as a const so the registry below reads as a declarative table.
1080
+ const spreadArray = (value) => [...value];
1081
+ /**
1082
+ * Allowlist of config paths that mergeConfiguration() hydrates from the persisted UserConfig into the runtime CONFIG outside the CONFIG_METADATA-driven loop.
1083
+ * Each entry corresponds to a PRESERVED_FIELDS entry that lives on the runtime Config type (the persistence-only entries schemaVersion / migrationsApplied are
1084
+ * declared in PERSISTENCE_ONLY_FIELDS instead). The list is alphabetized by path; ordering does not affect runtime semantics. A drift-check test in
1085
+ * userConfig.merge.test.ts asserts that PRESERVED_FIELDS partitions exactly into HYDRATED_FIELDS plus PERSISTENCE_ONLY_FIELDS, so a future preservation entry
1086
+ * cannot be added without explicitly classifying it as runtime-hydrated or persistence-only.
1087
+ */
1088
+ export const HYDRATED_FIELDS = [
1089
+ { path: "channels.channelSortDirection", shouldHydrate: isNonEmptyString },
1090
+ { path: "channels.channelSortField", shouldHydrate: isNonEmptyString },
1091
+ { copy: spreadArray, path: "channels.disabledPredefined", shouldHydrate: isArrayValue },
1092
+ { copy: spreadArray, path: "channels.enabledServices", shouldHydrate: isArrayValue },
1093
+ { copy: spreadArray, path: "channels.precacheServices", shouldHydrate: isArrayValue },
1094
+ { copy: spreadArray, path: "channels.visibleColumns", shouldHydrate: isArrayValue },
1095
+ { path: "channelsDvr.host", shouldHydrate: isNonEmptyString },
1096
+ { path: "hdhr.deviceId", shouldHydrate: isNonEmptyString },
1097
+ { path: "logging.debugFilter", shouldHydrate: isNonEmptyString },
1098
+ { copy: spreadArray, path: "streaming.captureCodecs", shouldHydrate: isArrayValue }
1099
+ ];
1100
+ /**
1101
+ * Allowlist of PRESERVED_FIELDS entries that exist only on the persisted UserConfig shape and have NO runtime CONFIG counterpart. These are framework-managed
1102
+ * file-store metadata - the schema-migration runner owns schemaVersion (so the file-store framework can decide whether migrations have run) and migrationsApplied
1103
+ * (the audit trail of which migrations have applied). Neither belongs in runtime CONFIG; both must round-trip through filterDefaults so saves do not strip them.
1104
+ *
1105
+ * The drift-check test consumes this constant to assert that every PRESERVED_FIELDS entry is classified as either runtime-hydrated (HYDRATED_FIELDS) or
1106
+ * persistence-only (here), and that the two sets are disjoint. Adding a new PRESERVED_FIELDS entry without classifying it here or in HYDRATED_FIELDS fails the
1107
+ * drift check at test time, surfacing the architectural intent that every preserved field has a declared destination.
1108
+ */
1109
+ export const PERSISTENCE_ONLY_FIELDS = [
1110
+ "migrationsApplied",
1111
+ "schemaVersion"
1112
+ ];
934
1113
  /**
935
1114
  * Filters a user configuration object to remove values that match the defaults. This produces a minimal config file containing only the settings the user has actually
936
1115
  * customized. Empty nested objects are also removed.
@@ -939,9 +1118,20 @@ export function isEqualToDefault(value, defaultValue) {
939
1118
  */
940
1119
  export function filterDefaults(config) {
941
1120
  const filtered = {};
942
- // Iterate over all known settings and check if the value differs from the default.
1121
+ // Build the set of paths owned by PRESERVED_FIELDS so the metadata-driven loop can skip them. The PRESERVED_FIELDS predicate is the sole arbiter for those
1122
+ // paths because String() coercion in isEqualToDefault produces false positives for array-shaped values (e.g., a reordered captureCodecs list coerces to a
1123
+ // different comma-joined string than the default and would survive the loop, even when the predicate would correctly classify it as default-equal).
1124
+ // Skipping the loop for those paths and letting the predicate decide is the only way to make the predicate authoritative without restructuring either
1125
+ // registry. Paths in PRESERVED_FIELDS that are NOT in CONFIG_METADATA (channelsDvr.host, hdhr.deviceId, etc.) are unaffected - the loop never visited them
1126
+ // before either.
1127
+ const preservedPaths = new Set(PRESERVED_FIELDS.map((field) => field.path));
1128
+ // Iterate over all known settings and check if the value differs from the default. Paths managed by PRESERVED_FIELDS are skipped here so the predicate below
1129
+ // has unilateral control over their inclusion.
943
1130
  for (const settings of Object.values(CONFIG_METADATA)) {
944
1131
  for (const setting of settings) {
1132
+ if (preservedPaths.has(setting.path)) {
1133
+ continue;
1134
+ }
945
1135
  const value = getNestedValue(config, setting.path);
946
1136
  // Skip undefined values (setting not present in config).
947
1137
  if (value === undefined) {
@@ -954,51 +1144,14 @@ export function filterDefaults(config) {
954
1144
  }
955
1145
  }
956
1146
  }
957
- /* Counterpart to the array handling in mergeConfiguration() above. Array fields need explicit preservation here because the standard loop's isEqualToDefault()
958
- * uses String() comparison, which works for inequality detection but can produce false positives for arrays with comma-containing strings. The explicit blocks
959
- * also ensure non-empty arrays survive the diff even when the standard loop would skip them.
960
- */
961
- const configChannelsDisabled = getNestedValue(config, "channels.disabledPredefined");
962
- if (Array.isArray(configChannelsDisabled) && (configChannelsDisabled.length > 0)) {
963
- setNestedValue(filtered, "channels.disabledPredefined", configChannelsDisabled);
964
- }
965
- const configEnabledProviders = getNestedValue(config, "channels.enabledProviders");
966
- if (Array.isArray(configEnabledProviders) && (configEnabledProviders.length > 0)) {
967
- setNestedValue(filtered, "channels.enabledProviders", configEnabledProviders);
968
- }
969
- const configPrecacheProviders = getNestedValue(config, "channels.precacheProviders");
970
- if (Array.isArray(configPrecacheProviders) && (configPrecacheProviders.length > 0)) {
971
- setNestedValue(filtered, "channels.precacheProviders", configPrecacheProviders);
972
- }
973
- const configVisibleColumns = getNestedValue(config, "channels.visibleColumns");
974
- if (Array.isArray(configVisibleColumns) && (configVisibleColumns.length > 0)) {
975
- setNestedValue(filtered, "channels.visibleColumns", configVisibleColumns);
976
- }
977
- // Preserve captureCodecs when it differs from the default. The default includes all recognized codecs, so a shorter list means the user has disabled one.
978
- const configCaptureCodecs = getNestedValue(config, "streaming.captureCodecs");
979
- const defaultCaptureCodecs = getNestedValue(DEFAULTS, "streaming.captureCodecs");
980
- if (Array.isArray(configCaptureCodecs) && (JSON.stringify(configCaptureCodecs.slice().sort()) !== JSON.stringify(defaultCaptureCodecs.slice().sort()))) {
981
- setNestedValue(filtered, "streaming.captureCodecs", configCaptureCodecs);
982
- }
983
- const configSortField = getNestedValue(config, "channels.channelSortField");
984
- if ((typeof configSortField === "string") && (configSortField !== "name")) {
985
- setNestedValue(filtered, "channels.channelSortField", configSortField);
986
- }
987
- const configSortDirection = getNestedValue(config, "channels.channelSortDirection");
988
- if ((typeof configSortDirection === "string") && (configSortDirection !== "asc")) {
989
- setNestedValue(filtered, "channels.channelSortDirection", configSortDirection);
990
- }
991
- const configDeviceId = getNestedValue(config, "hdhr.deviceId");
992
- if ((typeof configDeviceId === "string") && (configDeviceId.length > 0)) {
993
- setNestedValue(filtered, "hdhr.deviceId", configDeviceId);
994
- }
995
- const configDebugFilter = getNestedValue(config, "logging.debugFilter");
996
- if ((typeof configDebugFilter === "string") && (configDebugFilter.length > 0)) {
997
- setNestedValue(filtered, "logging.debugFilter", configDebugFilter);
998
- }
999
- // Preserve the auto-discovered DVR host across settings saves.
1000
- if ((typeof config.dvrHost === "string") && (config.dvrHost.length > 0)) {
1001
- filtered.dvrHost = config.dvrHost;
1147
+ // Apply the explicit preservation registry. Each entry is consulted with its current value plus the default-at-path; the predicate decides whether to
1148
+ // write through. See PRESERVED_FIELDS above for the entries and their rationale.
1149
+ for (const field of PRESERVED_FIELDS) {
1150
+ const value = getNestedValue(config, field.path);
1151
+ const defaultValue = getNestedValue(DEFAULTS, field.path);
1152
+ if (field.shouldPreserve(value, defaultValue)) {
1153
+ setNestedValue(filtered, field.path, value);
1154
+ }
1002
1155
  }
1003
1156
  // Remove any empty nested objects that resulted from filtering.
1004
1157
  return removeEmptyObjects(filtered);