@bnhf/prismcast 1.3.4-2026.2.19

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 (234) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +347 -0
  3. package/bin/prismcast +6 -0
  4. package/dist/app.d.ts +6 -0
  5. package/dist/app.js +315 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/browser/cdp.d.ts +38 -0
  8. package/dist/browser/cdp.js +155 -0
  9. package/dist/browser/cdp.js.map +1 -0
  10. package/dist/browser/channelSelection.d.ts +65 -0
  11. package/dist/browser/channelSelection.js +202 -0
  12. package/dist/browser/channelSelection.js.map +1 -0
  13. package/dist/browser/display.d.ts +34 -0
  14. package/dist/browser/display.js +54 -0
  15. package/dist/browser/display.js.map +1 -0
  16. package/dist/browser/index.d.ts +205 -0
  17. package/dist/browser/index.js +1205 -0
  18. package/dist/browser/index.js.map +1 -0
  19. package/dist/browser/tuning/fox.d.ts +2 -0
  20. package/dist/browser/tuning/fox.js +83 -0
  21. package/dist/browser/tuning/fox.js.map +1 -0
  22. package/dist/browser/tuning/hbo.d.ts +2 -0
  23. package/dist/browser/tuning/hbo.js +237 -0
  24. package/dist/browser/tuning/hbo.js.map +1 -0
  25. package/dist/browser/tuning/hulu.d.ts +2 -0
  26. package/dist/browser/tuning/hulu.js +550 -0
  27. package/dist/browser/tuning/hulu.js.map +1 -0
  28. package/dist/browser/tuning/sling.d.ts +2 -0
  29. package/dist/browser/tuning/sling.js +518 -0
  30. package/dist/browser/tuning/sling.js.map +1 -0
  31. package/dist/browser/tuning/thumbnailRow.d.ts +2 -0
  32. package/dist/browser/tuning/thumbnailRow.js +108 -0
  33. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  34. package/dist/browser/tuning/tileClick.d.ts +2 -0
  35. package/dist/browser/tuning/tileClick.js +103 -0
  36. package/dist/browser/tuning/tileClick.js.map +1 -0
  37. package/dist/browser/tuning/youtubeTv.d.ts +2 -0
  38. package/dist/browser/tuning/youtubeTv.js +182 -0
  39. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  40. package/dist/browser/video.d.ts +289 -0
  41. package/dist/browser/video.js +996 -0
  42. package/dist/browser/video.js.map +1 -0
  43. package/dist/channels/index.d.ts +3 -0
  44. package/dist/channels/index.js +392 -0
  45. package/dist/channels/index.js.map +1 -0
  46. package/dist/config/index.d.ts +53 -0
  47. package/dist/config/index.js +233 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/config/presets.d.ts +98 -0
  50. package/dist/config/presets.js +241 -0
  51. package/dist/config/presets.js.map +1 -0
  52. package/dist/config/profiles.d.ts +79 -0
  53. package/dist/config/profiles.js +245 -0
  54. package/dist/config/profiles.js.map +1 -0
  55. package/dist/config/providers.d.ts +120 -0
  56. package/dist/config/providers.js +450 -0
  57. package/dist/config/providers.js.map +1 -0
  58. package/dist/config/sites.d.ts +22 -0
  59. package/dist/config/sites.js +377 -0
  60. package/dist/config/sites.js.map +1 -0
  61. package/dist/config/userChannels.d.ts +178 -0
  62. package/dist/config/userChannels.js +543 -0
  63. package/dist/config/userChannels.js.map +1 -0
  64. package/dist/config/userConfig.d.ts +235 -0
  65. package/dist/config/userConfig.js +913 -0
  66. package/dist/config/userConfig.js.map +1 -0
  67. package/dist/hdhr/channelMap.d.ts +21 -0
  68. package/dist/hdhr/channelMap.js +82 -0
  69. package/dist/hdhr/channelMap.js.map +1 -0
  70. package/dist/hdhr/deviceId.d.ts +11 -0
  71. package/dist/hdhr/deviceId.js +84 -0
  72. package/dist/hdhr/deviceId.js.map +1 -0
  73. package/dist/hdhr/discover.d.ts +6 -0
  74. package/dist/hdhr/discover.js +155 -0
  75. package/dist/hdhr/discover.js.map +1 -0
  76. package/dist/hdhr/index.d.ts +9 -0
  77. package/dist/hdhr/index.js +87 -0
  78. package/dist/hdhr/index.js.map +1 -0
  79. package/dist/index.d.ts +1 -0
  80. package/dist/index.js +144 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/routes/assets.d.ts +6 -0
  83. package/dist/routes/assets.js +79 -0
  84. package/dist/routes/assets.js.map +1 -0
  85. package/dist/routes/auth.d.ts +6 -0
  86. package/dist/routes/auth.js +77 -0
  87. package/dist/routes/auth.js.map +1 -0
  88. package/dist/routes/channels.d.ts +6 -0
  89. package/dist/routes/channels.js +40 -0
  90. package/dist/routes/channels.js.map +1 -0
  91. package/dist/routes/components.d.ts +138 -0
  92. package/dist/routes/components.js +210 -0
  93. package/dist/routes/components.js.map +1 -0
  94. package/dist/routes/config.d.ts +72 -0
  95. package/dist/routes/config.js +1977 -0
  96. package/dist/routes/config.js.map +1 -0
  97. package/dist/routes/debug.d.ts +6 -0
  98. package/dist/routes/debug.js +274 -0
  99. package/dist/routes/debug.js.map +1 -0
  100. package/dist/routes/health.d.ts +6 -0
  101. package/dist/routes/health.js +85 -0
  102. package/dist/routes/health.js.map +1 -0
  103. package/dist/routes/hls.d.ts +6 -0
  104. package/dist/routes/hls.js +25 -0
  105. package/dist/routes/hls.js.map +1 -0
  106. package/dist/routes/index.d.ts +19 -0
  107. package/dist/routes/index.js +49 -0
  108. package/dist/routes/index.js.map +1 -0
  109. package/dist/routes/logs.d.ts +6 -0
  110. package/dist/routes/logs.js +164 -0
  111. package/dist/routes/logs.js.map +1 -0
  112. package/dist/routes/mpegts.d.ts +6 -0
  113. package/dist/routes/mpegts.js +19 -0
  114. package/dist/routes/mpegts.js.map +1 -0
  115. package/dist/routes/play.d.ts +6 -0
  116. package/dist/routes/play.js +18 -0
  117. package/dist/routes/play.js.map +1 -0
  118. package/dist/routes/playlist.d.ts +36 -0
  119. package/dist/routes/playlist.js +134 -0
  120. package/dist/routes/playlist.js.map +1 -0
  121. package/dist/routes/root.d.ts +6 -0
  122. package/dist/routes/root.js +2920 -0
  123. package/dist/routes/root.js.map +1 -0
  124. package/dist/routes/streams.d.ts +6 -0
  125. package/dist/routes/streams.js +88 -0
  126. package/dist/routes/streams.js.map +1 -0
  127. package/dist/routes/theme.d.ts +15 -0
  128. package/dist/routes/theme.js +275 -0
  129. package/dist/routes/theme.js.map +1 -0
  130. package/dist/routes/ui.d.ts +56 -0
  131. package/dist/routes/ui.js +354 -0
  132. package/dist/routes/ui.js.map +1 -0
  133. package/dist/service/commands.d.ts +41 -0
  134. package/dist/service/commands.js +391 -0
  135. package/dist/service/commands.js.map +1 -0
  136. package/dist/service/generators.d.ts +33 -0
  137. package/dist/service/generators.js +432 -0
  138. package/dist/service/generators.js.map +1 -0
  139. package/dist/service/index.d.ts +2 -0
  140. package/dist/service/index.js +7 -0
  141. package/dist/service/index.js.map +1 -0
  142. package/dist/streaming/clients.d.ts +48 -0
  143. package/dist/streaming/clients.js +114 -0
  144. package/dist/streaming/clients.js.map +1 -0
  145. package/dist/streaming/fmp4Segmenter.d.ts +61 -0
  146. package/dist/streaming/fmp4Segmenter.js +461 -0
  147. package/dist/streaming/fmp4Segmenter.js.map +1 -0
  148. package/dist/streaming/hls.d.ts +120 -0
  149. package/dist/streaming/hls.js +722 -0
  150. package/dist/streaming/hls.js.map +1 -0
  151. package/dist/streaming/hlsSegments.d.ts +54 -0
  152. package/dist/streaming/hlsSegments.js +162 -0
  153. package/dist/streaming/hlsSegments.js.map +1 -0
  154. package/dist/streaming/lifecycle.d.ts +33 -0
  155. package/dist/streaming/lifecycle.js +185 -0
  156. package/dist/streaming/lifecycle.js.map +1 -0
  157. package/dist/streaming/monitor.d.ts +74 -0
  158. package/dist/streaming/monitor.js +1310 -0
  159. package/dist/streaming/monitor.js.map +1 -0
  160. package/dist/streaming/mp4Parser.d.ts +74 -0
  161. package/dist/streaming/mp4Parser.js +566 -0
  162. package/dist/streaming/mp4Parser.js.map +1 -0
  163. package/dist/streaming/mpegts.d.ts +14 -0
  164. package/dist/streaming/mpegts.js +248 -0
  165. package/dist/streaming/mpegts.js.map +1 -0
  166. package/dist/streaming/registry.d.ts +119 -0
  167. package/dist/streaming/registry.js +127 -0
  168. package/dist/streaming/registry.js.map +1 -0
  169. package/dist/streaming/setup.d.ts +135 -0
  170. package/dist/streaming/setup.js +670 -0
  171. package/dist/streaming/setup.js.map +1 -0
  172. package/dist/streaming/showInfo.d.ts +30 -0
  173. package/dist/streaming/showInfo.js +362 -0
  174. package/dist/streaming/showInfo.js.map +1 -0
  175. package/dist/streaming/statusEmitter.d.ts +125 -0
  176. package/dist/streaming/statusEmitter.js +139 -0
  177. package/dist/streaming/statusEmitter.js.map +1 -0
  178. package/dist/types/index.d.ts +403 -0
  179. package/dist/types/index.js +6 -0
  180. package/dist/types/index.js.map +1 -0
  181. package/dist/utils/debugFilter.d.ts +38 -0
  182. package/dist/utils/debugFilter.js +157 -0
  183. package/dist/utils/debugFilter.js.map +1 -0
  184. package/dist/utils/delay.d.ts +6 -0
  185. package/dist/utils/delay.js +15 -0
  186. package/dist/utils/delay.js.map +1 -0
  187. package/dist/utils/errors.d.ts +15 -0
  188. package/dist/utils/errors.js +40 -0
  189. package/dist/utils/errors.js.map +1 -0
  190. package/dist/utils/evaluate.d.ts +51 -0
  191. package/dist/utils/evaluate.js +124 -0
  192. package/dist/utils/evaluate.js.map +1 -0
  193. package/dist/utils/ffmpeg.d.ts +65 -0
  194. package/dist/utils/ffmpeg.js +317 -0
  195. package/dist/utils/ffmpeg.js.map +1 -0
  196. package/dist/utils/fileLogger.d.ts +25 -0
  197. package/dist/utils/fileLogger.js +248 -0
  198. package/dist/utils/fileLogger.js.map +1 -0
  199. package/dist/utils/format.d.ts +16 -0
  200. package/dist/utils/format.js +46 -0
  201. package/dist/utils/format.js.map +1 -0
  202. package/dist/utils/html.d.ts +6 -0
  203. package/dist/utils/html.js +24 -0
  204. package/dist/utils/html.js.map +1 -0
  205. package/dist/utils/index.d.ts +15 -0
  206. package/dist/utils/index.js +20 -0
  207. package/dist/utils/index.js.map +1 -0
  208. package/dist/utils/logEmitter.d.ts +17 -0
  209. package/dist/utils/logEmitter.js +30 -0
  210. package/dist/utils/logEmitter.js.map +1 -0
  211. package/dist/utils/logger.d.ts +82 -0
  212. package/dist/utils/logger.js +219 -0
  213. package/dist/utils/logger.js.map +1 -0
  214. package/dist/utils/m3u.d.ts +32 -0
  215. package/dist/utils/m3u.js +148 -0
  216. package/dist/utils/m3u.js.map +1 -0
  217. package/dist/utils/morganStream.d.ts +7 -0
  218. package/dist/utils/morganStream.js +33 -0
  219. package/dist/utils/morganStream.js.map +1 -0
  220. package/dist/utils/platform.d.ts +64 -0
  221. package/dist/utils/platform.js +157 -0
  222. package/dist/utils/platform.js.map +1 -0
  223. package/dist/utils/retry.d.ts +15 -0
  224. package/dist/utils/retry.js +82 -0
  225. package/dist/utils/retry.js.map +1 -0
  226. package/dist/utils/streamContext.d.ts +28 -0
  227. package/dist/utils/streamContext.js +33 -0
  228. package/dist/utils/streamContext.js.map +1 -0
  229. package/dist/utils/version.d.ts +37 -0
  230. package/dist/utils/version.js +228 -0
  231. package/dist/utils/version.js.map +1 -0
  232. package/package.json +92 -0
  233. package/prismcast.png +0 -0
  234. package/prismcast.svg +74 -0
@@ -0,0 +1,913 @@
1
+ import { LOG } from "../utils/index.js";
2
+ import fs from "node:fs";
3
+ import { getValidPresetIds } from "./presets.js";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ const { promises: fsPromises } = fs;
7
+ /**
8
+ * Metadata for all configurable settings, organized by category.
9
+ */
10
+ export const CONFIG_METADATA = {
11
+ browser: [
12
+ {
13
+ description: "Path to Chrome executable. Leave empty to autodetect.",
14
+ envVar: "CHROME_BIN",
15
+ label: "Chrome Executable Path",
16
+ path: "browser.executablePath",
17
+ type: "path"
18
+ },
19
+ {
20
+ description: "Maximum wait after browser launch for the puppeteer-stream extension to initialize. The system polls for readiness and proceeds " +
21
+ "early when ready. Increase if streams start with blank frames.",
22
+ displayDivisor: 1000,
23
+ displayUnit: "seconds",
24
+ envVar: "BROWSER_INIT_TIMEOUT",
25
+ label: "Browser Init Timeout",
26
+ max: 30000,
27
+ min: 100,
28
+ path: "browser.initTimeout",
29
+ type: "integer",
30
+ unit: "ms"
31
+ }
32
+ ],
33
+ hdhr: [
34
+ {
35
+ description: "Enable HDHomeRun emulation for Plex integration. When enabled, PrismCast runs a second HTTP server that emulates an HDHomeRun tuner, " +
36
+ "allowing Plex to use PrismCast as a live TV source. In Plex, go to Settings > Live TV & DVR > Set Up Plex DVR and enter this server's address " +
37
+ "manually as IP:port (e.g., 192.168.1.100:5004).",
38
+ envVar: "HDHR_ENABLED",
39
+ label: "Enable HDHomeRun Emulation",
40
+ path: "hdhr.enabled",
41
+ type: "boolean"
42
+ },
43
+ {
44
+ dependsOn: "hdhr.enabled",
45
+ description: "TCP port for the HDHomeRun emulation server. This is the port you enter in Plex when manually adding the tuner (e.g., 192.168.1.100:5004).",
46
+ envVar: "HDHR_PORT",
47
+ label: "HDHomeRun Port",
48
+ max: 65535,
49
+ min: 1,
50
+ path: "hdhr.port",
51
+ type: "port"
52
+ },
53
+ {
54
+ dependsOn: "hdhr.enabled",
55
+ description: "Display name shown in Plex for this tuner. Helps identify PrismCast when you have multiple HDHomeRun devices.",
56
+ envVar: "HDHR_FRIENDLY_NAME",
57
+ label: "Friendly Name",
58
+ path: "hdhr.friendlyName",
59
+ type: "string"
60
+ }
61
+ ],
62
+ hls: [
63
+ {
64
+ description: "Target duration for each HLS segment. Shorter segments reduce latency but increase overhead.",
65
+ envVar: "HLS_SEGMENT_DURATION",
66
+ label: "Segment Duration",
67
+ max: 10,
68
+ min: 1,
69
+ path: "hls.segmentDuration",
70
+ type: "integer",
71
+ unit: "seconds"
72
+ },
73
+ {
74
+ description: "Maximum segments to keep in memory per stream. Controls buffer depth and memory usage.",
75
+ envVar: "HLS_MAX_SEGMENTS",
76
+ label: "Max Segments",
77
+ max: 60,
78
+ min: 3,
79
+ path: "hls.maxSegments",
80
+ type: "integer"
81
+ },
82
+ {
83
+ description: "Time before an idle HLS stream is terminated. Applies when no segment requests are received.",
84
+ displayDivisor: 1000,
85
+ displayUnit: "seconds",
86
+ envVar: "HLS_IDLE_TIMEOUT",
87
+ label: "Idle Timeout",
88
+ max: 300000,
89
+ min: 10000,
90
+ path: "hls.idleTimeout",
91
+ type: "integer",
92
+ unit: "ms"
93
+ }
94
+ ],
95
+ logging: [
96
+ {
97
+ description: "HTTP request logging level. \"none\" disables logging, \"errors\" logs only 4xx/5xx responses, \"filtered\" logs important requests while " +
98
+ "skipping high-frequency endpoints, \"all\" logs everything.",
99
+ envVar: "HTTP_LOG_LEVEL",
100
+ label: "HTTP Log Level",
101
+ path: "logging.httpLogLevel",
102
+ type: "string",
103
+ validValues: ["none", "errors", "filtered", "all"]
104
+ },
105
+ {
106
+ description: "Maximum log file size in bytes. When exceeded, the file is trimmed to half this size keeping the most recent logs.",
107
+ displayDivisor: 1048576,
108
+ displayPrecision: 1,
109
+ displayUnit: "MB",
110
+ envVar: "LOG_MAX_SIZE",
111
+ label: "Max Log Size",
112
+ max: 104857600,
113
+ min: 10240,
114
+ path: "logging.maxSize",
115
+ type: "integer",
116
+ unit: "bytes"
117
+ }
118
+ ],
119
+ playback: [
120
+ {
121
+ description: "Grace period for buffering before declaring a stall. Prevents false positives from brief network hiccups.",
122
+ displayDivisor: 1000,
123
+ displayUnit: "seconds",
124
+ envVar: "BUFFERING_GRACE_PERIOD",
125
+ label: "Buffering Grace Period",
126
+ max: 60000,
127
+ min: 1000,
128
+ path: "playback.bufferingGracePeriod",
129
+ type: "integer",
130
+ unit: "ms"
131
+ },
132
+ {
133
+ description: "Delay after clicking a channel selector before checking for video.",
134
+ displayDivisor: 1000,
135
+ displayUnit: "seconds",
136
+ envVar: "CHANNEL_SELECTOR_DELAY",
137
+ label: "Channel Selector Delay",
138
+ max: 30000,
139
+ min: 500,
140
+ path: "playback.channelSelectorDelay",
141
+ type: "integer",
142
+ unit: "ms"
143
+ },
144
+ {
145
+ description: "Delay after channel switch for stream to stabilize before health monitoring begins.",
146
+ displayDivisor: 1000,
147
+ displayUnit: "seconds",
148
+ envVar: "CHANNEL_SWITCH_DELAY",
149
+ label: "Channel Switch Delay",
150
+ max: 30000,
151
+ min: 500,
152
+ path: "playback.channelSwitchDelay",
153
+ type: "integer",
154
+ unit: "ms"
155
+ },
156
+ {
157
+ description: "Delay after clicking video element to initiate playback on Brightcove-based players. Currently unused — waitForVideoReady() " +
158
+ "handles the wait automatically.",
159
+ displayDivisor: 1000,
160
+ displayUnit: "seconds",
161
+ envVar: "CLICK_TO_PLAY_DELAY",
162
+ label: "Click to Play Delay",
163
+ max: 10000,
164
+ min: 100,
165
+ path: "playback.clickToPlayDelay",
166
+ type: "integer",
167
+ unit: "ms"
168
+ },
169
+ {
170
+ description: "Delay for iframe content to initialize before searching for video elements.",
171
+ displayDivisor: 1000,
172
+ displayUnit: "seconds",
173
+ envVar: "IFRAME_INIT_DELAY",
174
+ label: "Iframe Init Delay",
175
+ max: 30000,
176
+ min: 500,
177
+ path: "playback.iframeInitDelay",
178
+ type: "integer",
179
+ unit: "ms"
180
+ },
181
+ {
182
+ description: "Maximum full page navigations allowed within the reload window. Prevents reload loops on broken streams.",
183
+ envVar: "MAX_PAGE_RELOADS",
184
+ label: "Max Page Reloads",
185
+ max: 20,
186
+ min: 1,
187
+ path: "playback.maxPageReloads",
188
+ type: "integer"
189
+ },
190
+ {
191
+ description: "Interval between playback health checks. Shorter intervals detect problems faster but use more CPU.",
192
+ displayDivisor: 1000,
193
+ displayUnit: "seconds",
194
+ envVar: "MONITOR_INTERVAL",
195
+ label: "Monitor Interval",
196
+ max: 30000,
197
+ min: 500,
198
+ path: "playback.monitorInterval",
199
+ type: "integer",
200
+ unit: "ms"
201
+ },
202
+ {
203
+ description: "Time window for tracking page reload frequency. After this period, the reload counter resets.",
204
+ displayDivisor: 60000,
205
+ displayUnit: "minutes",
206
+ envVar: "PAGE_RELOAD_WINDOW",
207
+ label: "Page Reload Window",
208
+ max: 3600000,
209
+ min: 60000,
210
+ path: "playback.pageReloadWindow",
211
+ type: "integer",
212
+ unit: "ms"
213
+ },
214
+ {
215
+ description: "Delay after reloading video source before resuming monitoring.",
216
+ displayDivisor: 1000,
217
+ displayUnit: "seconds",
218
+ envVar: "SOURCE_RELOAD_DELAY",
219
+ label: "Source Reload Delay",
220
+ max: 30000,
221
+ min: 500,
222
+ path: "playback.sourceReloadDelay",
223
+ type: "integer",
224
+ unit: "ms"
225
+ },
226
+ {
227
+ description: "Consecutive stalled checks before triggering recovery.",
228
+ envVar: "STALL_COUNT_THRESHOLD",
229
+ label: "Stall Count Threshold",
230
+ max: 10,
231
+ min: 1,
232
+ path: "playback.stallCountThreshold",
233
+ type: "integer"
234
+ },
235
+ {
236
+ description: "Minimum change in video.currentTime (seconds) to consider playback progressing.",
237
+ envVar: "STALL_THRESHOLD",
238
+ label: "Stall Threshold",
239
+ max: 5,
240
+ min: 0.01,
241
+ path: "playback.stallThreshold",
242
+ type: "float",
243
+ unit: "seconds"
244
+ },
245
+ {
246
+ description: "Duration of healthy playback required before resetting escalation level. Prevents stutter loops.",
247
+ displayDivisor: 1000,
248
+ displayUnit: "seconds",
249
+ envVar: "SUSTAINED_PLAYBACK_REQUIRED",
250
+ label: "Sustained Playback Required",
251
+ max: 300000,
252
+ min: 10000,
253
+ path: "playback.sustainedPlaybackRequired",
254
+ type: "integer",
255
+ unit: "ms"
256
+ }
257
+ ],
258
+ recovery: [
259
+ {
260
+ description: "Random jitter added to retry delays. Prevents thundering herd on retries.",
261
+ displayDivisor: 1000,
262
+ displayUnit: "seconds",
263
+ envVar: "BACKOFF_JITTER",
264
+ label: "Backoff Jitter",
265
+ max: 10000,
266
+ min: 0,
267
+ path: "recovery.backoffJitter",
268
+ type: "integer",
269
+ unit: "ms"
270
+ },
271
+ {
272
+ description: "Failures within circuit breaker window that trigger stream termination.",
273
+ envVar: "CIRCUIT_BREAKER_THRESHOLD",
274
+ label: "Circuit Breaker Threshold",
275
+ max: 100,
276
+ min: 1,
277
+ path: "recovery.circuitBreakerThreshold",
278
+ type: "integer"
279
+ },
280
+ {
281
+ description: "Time window for counting failures toward circuit breaker.",
282
+ displayDivisor: 60000,
283
+ displayUnit: "minutes",
284
+ envVar: "CIRCUIT_BREAKER_WINDOW",
285
+ label: "Circuit Breaker Window",
286
+ max: 3600000,
287
+ min: 60000,
288
+ path: "recovery.circuitBreakerWindow",
289
+ type: "integer",
290
+ unit: "ms"
291
+ },
292
+ {
293
+ description: "Maximum delay between retry attempts. Exponential backoff is capped at this value.",
294
+ displayDivisor: 1000,
295
+ displayUnit: "seconds",
296
+ envVar: "MAX_BACKOFF_DELAY",
297
+ label: "Max Backoff Delay",
298
+ max: 60000,
299
+ min: 1000,
300
+ path: "recovery.maxBackoffDelay",
301
+ type: "integer",
302
+ unit: "ms"
303
+ },
304
+ {
305
+ description: "Interval between stale page cleanup runs. Identifies and closes orphaned browser pages.",
306
+ displayDivisor: 1000,
307
+ displayUnit: "seconds",
308
+ envVar: "STALE_PAGE_CLEANUP_INTERVAL",
309
+ label: "Stale Page Cleanup Interval",
310
+ max: 600000,
311
+ min: 10000,
312
+ path: "recovery.stalePageCleanupInterval",
313
+ type: "integer",
314
+ unit: "ms"
315
+ },
316
+ {
317
+ description: "Grace period before closing a page that appears stale. Prevents race conditions during initialization.",
318
+ displayDivisor: 1000,
319
+ displayUnit: "seconds",
320
+ envVar: "STALE_PAGE_GRACE_PERIOD",
321
+ label: "Stale Page Grace Period",
322
+ max: 120000,
323
+ min: 5000,
324
+ path: "recovery.stalePageGracePeriod",
325
+ type: "integer",
326
+ unit: "ms"
327
+ }
328
+ ],
329
+ server: [
330
+ {
331
+ description: "IP address to bind the HTTP server. Use 0.0.0.0 for all interfaces, 127.0.0.1 for local only.",
332
+ envVar: "HOST",
333
+ label: "Host",
334
+ path: "server.host",
335
+ type: "host"
336
+ },
337
+ {
338
+ description: "TCP port for the HTTP server. Channels DVR and other clients connect here.",
339
+ envVar: "PORT",
340
+ label: "Port",
341
+ max: 65535,
342
+ min: 1,
343
+ path: "server.port",
344
+ type: "port"
345
+ }
346
+ ],
347
+ streaming: [
348
+ {
349
+ description: "FFmpeg (recommended) provides reliable capture for long recordings. Native mode captures directly from Chrome without an external " +
350
+ "process, but may require stream recovery after 20-30 minutes of continuous use.",
351
+ disabledReason: "Native capture mode is temporarily disabled due to a Chrome bug that causes fMP4 MediaRecorder to produce corrupt output after " +
352
+ "20-30 minutes of continuous recording. FFmpeg mode is required until a future Chrome release resolves this issue.",
353
+ envVar: "CAPTURE_MODE",
354
+ label: "Capture Mode",
355
+ path: "streaming.captureMode",
356
+ type: "string",
357
+ validValues: ["ffmpeg", "native"]
358
+ },
359
+ {
360
+ description: "Video quality preset. Determines capture resolution. Bitrate and frame rate can be further customized.",
361
+ envVar: "QUALITY_PRESET",
362
+ label: "Quality Preset",
363
+ path: "streaming.qualityPreset",
364
+ type: "string",
365
+ validValues: getValidPresetIds()
366
+ },
367
+ {
368
+ description: "Audio bitrate for browser capture. HLS copies this stream directly (no re-encoding). 256kbps provides high-quality stereo audio.",
369
+ displayDivisor: 1000,
370
+ displayUnit: "kbps",
371
+ envVar: "AUDIO_BITRATE",
372
+ label: "Audio Bitrate",
373
+ max: 512000,
374
+ min: 32000,
375
+ path: "streaming.audioBitsPerSecond",
376
+ type: "integer",
377
+ unit: "bps"
378
+ },
379
+ {
380
+ description: "Target frame rate. 60fps is ideal for sports; 30fps works for most TV content.",
381
+ envVar: "FRAME_RATE",
382
+ label: "Frame Rate",
383
+ max: 60,
384
+ min: 30,
385
+ path: "streaming.frameRate",
386
+ type: "integer",
387
+ unit: "fps"
388
+ },
389
+ {
390
+ description: "Maximum simultaneous streams. Each stream uses a browser tab and resources.",
391
+ envVar: "MAX_CONCURRENT_STREAMS",
392
+ label: "Max Concurrent Streams",
393
+ max: 100,
394
+ min: 1,
395
+ path: "streaming.maxConcurrentStreams",
396
+ type: "integer"
397
+ },
398
+ {
399
+ description: "Maximum navigation retry attempts before giving up.",
400
+ envVar: "MAX_NAV_RETRIES",
401
+ label: "Max Navigation Retries",
402
+ max: 50,
403
+ min: 1,
404
+ path: "streaming.maxNavigationRetries",
405
+ type: "integer"
406
+ },
407
+ {
408
+ description: "Timeout for page navigation. Increase for slow networks or heavy pages.",
409
+ displayDivisor: 1000,
410
+ displayUnit: "seconds",
411
+ envVar: "NAV_TIMEOUT",
412
+ label: "Navigation Timeout",
413
+ max: 600000,
414
+ min: 1000,
415
+ path: "streaming.navigationTimeout",
416
+ type: "integer",
417
+ unit: "ms"
418
+ },
419
+ {
420
+ description: "Video bitrate for browser capture. HLS copies this stream directly (no re-encoding). 8Mbps suits 720p; 15-20Mbps for 1080p.",
421
+ displayDivisor: 1000000,
422
+ displayUnit: "Mbps",
423
+ envVar: "VIDEO_BITRATE",
424
+ label: "Video Bitrate",
425
+ max: 50000000,
426
+ min: 100000,
427
+ path: "streaming.videoBitsPerSecond",
428
+ type: "integer",
429
+ unit: "bps"
430
+ },
431
+ {
432
+ description: "Timeout for video element to become ready after navigation.",
433
+ displayDivisor: 1000,
434
+ displayUnit: "seconds",
435
+ envVar: "VIDEO_TIMEOUT",
436
+ label: "Video Timeout",
437
+ max: 600000,
438
+ min: 1000,
439
+ path: "streaming.videoTimeout",
440
+ type: "integer",
441
+ unit: "ms"
442
+ }
443
+ ]
444
+ };
445
+ /* The config file is stored in the same data directory as the Chrome profile (~/.prismcast).
446
+ */
447
+ const dataDir = path.join(os.homedir(), ".prismcast");
448
+ const configFilePath = path.join(dataDir, "config.json");
449
+ /**
450
+ * Returns the path to the user configuration file.
451
+ * @returns The absolute path to ~/.prismcast/config.json.
452
+ */
453
+ export function getConfigFilePath() {
454
+ return configFilePath;
455
+ }
456
+ /* These functions handle reading and writing the config file. All operations are async and handle errors gracefully.
457
+ */
458
+ /**
459
+ * 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
460
+ * JSON.
461
+ * @returns The loaded configuration with parse status.
462
+ */
463
+ export async function loadUserConfig() {
464
+ try {
465
+ const content = await fsPromises.readFile(configFilePath, "utf-8");
466
+ try {
467
+ const config = JSON.parse(content);
468
+ return { config, parseError: false };
469
+ }
470
+ catch (parseError) {
471
+ const message = (parseError instanceof Error) ? parseError.message : String(parseError);
472
+ LOG.warn("Invalid JSON in configuration file %s: %s. Using defaults.", configFilePath, message);
473
+ return { config: {}, parseError: true, parseErrorMessage: message };
474
+ }
475
+ }
476
+ catch (error) {
477
+ // File doesn't exist - this is normal, use defaults.
478
+ if (error.code === "ENOENT") {
479
+ return { config: {}, parseError: false };
480
+ }
481
+ // Other read errors - log and use defaults.
482
+ LOG.warn("Failed to read configuration file %s: %s. Using defaults.", configFilePath, (error instanceof Error) ? error.message : String(error));
483
+ return { config: {}, parseError: false };
484
+ }
485
+ }
486
+ /**
487
+ * Saves user configuration to the config file. Creates the data directory if it doesn't exist.
488
+ * @param config - The configuration to save.
489
+ * @throws If the file cannot be written.
490
+ */
491
+ export async function saveUserConfig(config) {
492
+ // Ensure data directory exists.
493
+ await fsPromises.mkdir(dataDir, { recursive: true });
494
+ // Write config with pretty formatting for readability.
495
+ const content = JSON.stringify(config, null, 2);
496
+ await fsPromises.writeFile(configFilePath, content + "\n", "utf-8");
497
+ LOG.info("Configuration saved to %s.", configFilePath);
498
+ }
499
+ /* These functions detect which settings are overridden by environment variables, so the UI can disable those fields and show appropriate warnings.
500
+ */
501
+ /**
502
+ * Returns a map of setting paths to their environment variable values for settings that are overridden by environment variables.
503
+ * @returns Map of path -> env var value for overridden settings.
504
+ */
505
+ export function getEnvOverrides() {
506
+ const overrides = new Map();
507
+ for (const settings of Object.values(CONFIG_METADATA)) {
508
+ for (const setting of settings) {
509
+ const envValue = setting.envVar ? process.env[setting.envVar] : undefined;
510
+ if (envValue !== undefined) {
511
+ overrides.set(setting.path, envValue);
512
+ }
513
+ }
514
+ }
515
+ return overrides;
516
+ }
517
+ /* These functions merge defaults, user config, and environment overrides into the final CONFIG object.
518
+ */
519
+ /**
520
+ * Hard-coded default configuration values. These are the baseline values used when neither user config nor environment variables provide a value.
521
+ */
522
+ export const DEFAULTS = {
523
+ browser: {
524
+ executablePath: null,
525
+ initTimeout: 1000
526
+ },
527
+ channels: {
528
+ disabledPredefined: [],
529
+ enabledProviders: []
530
+ },
531
+ hdhr: {
532
+ deviceId: "",
533
+ enabled: true,
534
+ friendlyName: "PrismCast",
535
+ port: 5004
536
+ },
537
+ hls: {
538
+ idleTimeout: 30000,
539
+ maxSegments: 10,
540
+ segmentDuration: 2
541
+ },
542
+ logging: {
543
+ httpLogLevel: "errors",
544
+ maxSize: 1048576
545
+ },
546
+ paths: {
547
+ chromeProfileName: "chromedata",
548
+ extensionDirName: "extension"
549
+ },
550
+ playback: {
551
+ bufferingGracePeriod: 10000,
552
+ channelSelectorDelay: 5000,
553
+ channelSwitchDelay: 4000,
554
+ clickToPlayDelay: 1000,
555
+ iframeInitDelay: 1500,
556
+ maxPageReloads: 3,
557
+ monitorInterval: 2000,
558
+ pageReloadWindow: 900000,
559
+ sourceReloadDelay: 2000,
560
+ stallCountThreshold: 2,
561
+ stallThreshold: 0.1,
562
+ sustainedPlaybackRequired: 60000
563
+ },
564
+ recovery: {
565
+ backoffJitter: 1000,
566
+ circuitBreakerThreshold: 10,
567
+ circuitBreakerWindow: 300000,
568
+ maxBackoffDelay: 3000,
569
+ stalePageCleanupInterval: 60000,
570
+ stalePageGracePeriod: 30000
571
+ },
572
+ server: {
573
+ host: "0.0.0.0",
574
+ port: 5589
575
+ },
576
+ streaming: {
577
+ audioBitsPerSecond: 256000,
578
+ captureMode: "ffmpeg",
579
+ frameRate: 60,
580
+ maxConcurrentStreams: 10,
581
+ maxNavigationRetries: 4,
582
+ navigationTimeout: 10000,
583
+ qualityPreset: "720p-high",
584
+ videoBitsPerSecond: 12000000,
585
+ videoTimeout: 10000
586
+ }
587
+ };
588
+ /**
589
+ * Parses an environment variable value according to the setting type.
590
+ * @param value - The raw environment variable value.
591
+ * @param type - The expected type of the setting.
592
+ * @returns The parsed value, or undefined if parsing fails.
593
+ */
594
+ function parseEnvValue(value, type) {
595
+ switch (type) {
596
+ case "boolean": {
597
+ // Accept common truthy values for environment variables.
598
+ const lower = value.toLowerCase();
599
+ return (lower === "true") || (lower === "1") || (lower === "yes");
600
+ }
601
+ case "float": {
602
+ const num = parseFloat(value);
603
+ return Number.isNaN(num) ? undefined : num;
604
+ }
605
+ case "integer":
606
+ case "port": {
607
+ const num = parseInt(value, 10);
608
+ return Number.isNaN(num) ? undefined : num;
609
+ }
610
+ case "host":
611
+ case "path": {
612
+ return value;
613
+ }
614
+ default: {
615
+ return value;
616
+ }
617
+ }
618
+ }
619
+ /**
620
+ * Gets a value from a nested object using a dot-separated path.
621
+ * @param obj - The object to read from.
622
+ * @param settingPath - Dot-separated path (e.g., "browser.viewport.width").
623
+ * @returns The value at the path, or undefined if not found.
624
+ */
625
+ export function getNestedValue(obj, settingPath) {
626
+ const parts = settingPath.split(".");
627
+ let current = obj;
628
+ for (const part of parts) {
629
+ if ((current === null) || (current === undefined) || (typeof current !== "object")) {
630
+ return undefined;
631
+ }
632
+ current = current[part];
633
+ }
634
+ return current;
635
+ }
636
+ /**
637
+ * Sets a value in a nested object using a dot-separated path, creating intermediate objects as needed.
638
+ * @param obj - The object to modify.
639
+ * @param settingPath - Dot-separated path (e.g., "browser.viewport.width").
640
+ * @param value - The value to set.
641
+ */
642
+ export function setNestedValue(obj, settingPath, value) {
643
+ const parts = settingPath.split(".");
644
+ let current = obj;
645
+ for (let i = 0; i < (parts.length - 1); i++) {
646
+ const part = parts[i];
647
+ if (current[part] === undefined) {
648
+ current[part] = {};
649
+ }
650
+ current = current[part];
651
+ }
652
+ current[parts[parts.length - 1]] = value;
653
+ }
654
+ /**
655
+ * Merges user configuration with defaults and environment overrides to produce the final configuration. Priority: env vars > user config > defaults.
656
+ * @param userConfig - User configuration from the config file.
657
+ * @returns The merged configuration.
658
+ */
659
+ export function mergeConfiguration(userConfig) {
660
+ // Start with a deep copy of defaults.
661
+ const config = JSON.parse(JSON.stringify(DEFAULTS));
662
+ // Apply user config values.
663
+ for (const settings of Object.values(CONFIG_METADATA)) {
664
+ for (const setting of settings) {
665
+ const userValue = getNestedValue(userConfig, setting.path);
666
+ if (userValue !== undefined) {
667
+ setNestedValue(config, setting.path, userValue);
668
+ }
669
+ }
670
+ }
671
+ /* These fields are stored in the user config file but are not part of CONFIG_METADATA because they are complex types (arrays, auto-generated strings) that don't
672
+ * fit the standard scalar setting model. When adding a new field here, you must also add corresponding preservation logic in filterDefaults() below AND in the
673
+ * POST /config handler in routes/config.ts (which must carry forward these fields from the existing file so the settings form doesn't wipe them).
674
+ */
675
+ if (Array.isArray(userConfig.channels?.disabledPredefined)) {
676
+ config.channels.disabledPredefined = [...userConfig.channels.disabledPredefined];
677
+ }
678
+ if (Array.isArray(userConfig.channels?.enabledProviders)) {
679
+ config.channels.enabledProviders = [...userConfig.channels.enabledProviders];
680
+ }
681
+ if ((typeof userConfig.hdhr?.deviceId === "string") && (userConfig.hdhr.deviceId.length > 0)) {
682
+ config.hdhr.deviceId = userConfig.hdhr.deviceId;
683
+ }
684
+ // Apply environment variable overrides (highest priority).
685
+ for (const settings of Object.values(CONFIG_METADATA)) {
686
+ for (const setting of settings) {
687
+ const envValue = setting.envVar ? process.env[setting.envVar] : undefined;
688
+ if (envValue !== undefined) {
689
+ const parsedValue = parseEnvValue(envValue, setting.type);
690
+ if (parsedValue !== undefined) {
691
+ setNestedValue(config, setting.path, parsedValue);
692
+ }
693
+ }
694
+ }
695
+ }
696
+ return config;
697
+ }
698
+ /* The settings "promoted" to the main Settings tab, organized into visual sections. These are the options most users might actually change. Everything else goes to
699
+ * the Advanced tab, grouped by storage category. Sections are displayed in array order.
700
+ */
701
+ const SETTINGS_TAB_SECTIONS = [
702
+ {
703
+ displayName: "Server",
704
+ id: "server",
705
+ paths: ["server.port", "server.host"]
706
+ },
707
+ {
708
+ displayName: "Browser",
709
+ id: "browser",
710
+ paths: ["browser.executablePath", "browser.initTimeout"]
711
+ },
712
+ {
713
+ displayName: "Capture",
714
+ id: "capture",
715
+ paths: ["streaming.captureMode", "streaming.qualityPreset", "streaming.videoBitsPerSecond", "streaming.audioBitsPerSecond", "streaming.frameRate"]
716
+ },
717
+ {
718
+ displayName: "HDHomeRun / Plex",
719
+ id: "hdhr",
720
+ paths: ["hdhr.enabled", "hdhr.port", "hdhr.friendlyName"]
721
+ }
722
+ ];
723
+ /* Display metadata for Advanced tab sections. The category field must match a key in CONFIG_METADATA. Entries are sorted alphabetically by category.
724
+ */
725
+ const ADVANCED_SECTION_META = [
726
+ { category: "hls", displayName: "HLS" },
727
+ { category: "logging", displayName: "Logging" },
728
+ { category: "playback", displayName: "Playback" },
729
+ { category: "recovery", displayName: "Recovery" },
730
+ { category: "streaming", displayName: "Streaming" }
731
+ ];
732
+ /**
733
+ * Returns all setting paths from CONFIG_METADATA.
734
+ * @returns Array of all setting paths.
735
+ */
736
+ function getAllSettingPaths() {
737
+ return Object.values(CONFIG_METADATA).flat().map((s) => s.path);
738
+ }
739
+ /**
740
+ * Looks up a setting by its path.
741
+ * @param settingPath - The dot-separated path (e.g., "streaming.videoBitsPerSecond").
742
+ * @returns The setting metadata, or undefined if not found.
743
+ */
744
+ export function getSettingByPath(settingPath) {
745
+ for (const settings of Object.values(CONFIG_METADATA)) {
746
+ const found = settings.find((s) => s.path === settingPath);
747
+ if (found) {
748
+ return found;
749
+ }
750
+ }
751
+ return undefined;
752
+ }
753
+ /**
754
+ * Returns the sections for the Settings tab with resolved setting metadata.
755
+ * @returns Array of section definitions.
756
+ */
757
+ export function getSettingsTabSections() {
758
+ return SETTINGS_TAB_SECTIONS.map((section) => ({
759
+ displayName: section.displayName,
760
+ id: section.id,
761
+ settings: section.paths
762
+ .map((p) => getSettingByPath(p))
763
+ .filter((s) => s !== undefined)
764
+ }));
765
+ }
766
+ /**
767
+ * Returns the UI tabs for the configuration interface. The Settings tab contains commonly-used options; the Advanced tab contains everything else.
768
+ * @returns Array of UI tab definitions.
769
+ */
770
+ export function getUITabs() {
771
+ // Derive settings tab paths from sections.
772
+ const settingsTabPaths = SETTINGS_TAB_SECTIONS.flatMap((s) => s.paths);
773
+ // Build Settings tab from sections.
774
+ const settingsTabSettings = settingsTabPaths
775
+ .map((p) => getSettingByPath(p))
776
+ .filter((s) => s !== undefined);
777
+ // Build Advanced tab from everything not in Settings.
778
+ const advancedPaths = getAllSettingPaths().filter((p) => !settingsTabPaths.includes(p));
779
+ const advancedSettings = advancedPaths
780
+ .map((p) => getSettingByPath(p))
781
+ .filter((s) => s !== undefined);
782
+ return [
783
+ {
784
+ description: "Configure common server and streaming options.",
785
+ displayName: "Settings",
786
+ id: "settings",
787
+ settings: settingsTabSettings
788
+ },
789
+ {
790
+ description: "Expert tuning options. The defaults work well for most setups.",
791
+ displayName: "Advanced",
792
+ id: "advanced",
793
+ settings: advancedSettings
794
+ }
795
+ ];
796
+ }
797
+ /**
798
+ * Returns the collapsible sections for the Advanced tab. Each section groups settings by their storage category.
799
+ * @returns Array of section definitions.
800
+ */
801
+ export function getAdvancedSections() {
802
+ // Derive settings tab paths from sections.
803
+ const settingsTabPaths = SETTINGS_TAB_SECTIONS.flatMap((s) => s.paths);
804
+ // Get all paths that belong in Advanced (not in Settings).
805
+ const advancedPaths = getAllSettingPaths().filter((p) => !settingsTabPaths.includes(p));
806
+ // Group by category (first path segment).
807
+ const byCategory = new Map();
808
+ for (const path of advancedPaths) {
809
+ const category = path.split(".")[0];
810
+ const setting = getSettingByPath(path);
811
+ if (setting) {
812
+ if (!byCategory.has(category)) {
813
+ byCategory.set(category, []);
814
+ }
815
+ byCategory.get(category)?.push(setting);
816
+ }
817
+ }
818
+ // Return sections in the defined order with display names.
819
+ return ADVANCED_SECTION_META
820
+ .filter((meta) => byCategory.has(meta.category))
821
+ .map((meta) => ({
822
+ displayName: meta.displayName,
823
+ id: meta.category,
824
+ settings: byCategory.get(meta.category) ?? []
825
+ }));
826
+ }
827
+ /* 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
828
+ * actually customized. It also ensures that when defaults change in a new version, users automatically get the new defaults for settings they haven't explicitly set.
829
+ */
830
+ /**
831
+ * Recursively removes empty objects from a nested object structure. An object is considered empty if it has no own enumerable properties, or if all its properties are
832
+ * themselves empty objects.
833
+ * @param obj - The object to clean.
834
+ * @returns A new object with empty nested objects removed.
835
+ */
836
+ function removeEmptyObjects(obj) {
837
+ const result = {};
838
+ for (const key of Object.keys(obj)) {
839
+ const value = obj[key];
840
+ // Recursively clean nested objects.
841
+ if ((value !== null) && (typeof value === "object") && !Array.isArray(value)) {
842
+ const cleaned = removeEmptyObjects(value);
843
+ // Only include if the cleaned object is not empty.
844
+ if (Object.keys(cleaned).length > 0) {
845
+ result[key] = cleaned;
846
+ }
847
+ }
848
+ else {
849
+ // Include non-object values as-is.
850
+ result[key] = value;
851
+ }
852
+ }
853
+ return result;
854
+ }
855
+ /**
856
+ * Checks if two values are equal for the purpose of default comparison. Handles null, undefined, and type coercion consistently.
857
+ * @param value - The value to check.
858
+ * @param defaultValue - The default value to compare against.
859
+ * @returns True if the values are considered equal.
860
+ */
861
+ export function isEqualToDefault(value, defaultValue) {
862
+ // Handle null/undefined cases.
863
+ if ((value === null) || (value === undefined)) {
864
+ return (defaultValue === null) || (defaultValue === undefined);
865
+ }
866
+ if ((defaultValue === null) || (defaultValue === undefined)) {
867
+ return false;
868
+ }
869
+ // Compare as strings for consistent comparison across types (handles number/string coercion). Config values are always primitives.
870
+ return String(value) === String(defaultValue);
871
+ }
872
+ /**
873
+ * 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
874
+ * customized. Empty nested objects are also removed.
875
+ * @param config - The user configuration to filter.
876
+ * @returns A new configuration object containing only non-default values.
877
+ */
878
+ export function filterDefaults(config) {
879
+ const filtered = {};
880
+ // Iterate over all known settings and check if the value differs from the default.
881
+ for (const settings of Object.values(CONFIG_METADATA)) {
882
+ for (const setting of settings) {
883
+ const value = getNestedValue(config, setting.path);
884
+ // Skip undefined values (setting not present in config).
885
+ if (value === undefined) {
886
+ continue;
887
+ }
888
+ const defaultValue = getNestedValue(DEFAULTS, setting.path);
889
+ // Only include if the value differs from the default.
890
+ if (!isEqualToDefault(value, defaultValue)) {
891
+ setNestedValue(filtered, setting.path, value);
892
+ }
893
+ }
894
+ }
895
+ /* Counterpart to the non-CONFIG_METADATA handling in mergeConfiguration() above. When adding a new complex field there, you must also add preservation logic
896
+ * here AND in the POST /config handler in routes/config.ts, otherwise the field will be lost when saving configuration.
897
+ */
898
+ const configChannelsDisabled = getNestedValue(config, "channels.disabledPredefined");
899
+ if (Array.isArray(configChannelsDisabled) && (configChannelsDisabled.length > 0)) {
900
+ setNestedValue(filtered, "channels.disabledPredefined", configChannelsDisabled);
901
+ }
902
+ const configEnabledProviders = getNestedValue(config, "channels.enabledProviders");
903
+ if (Array.isArray(configEnabledProviders) && (configEnabledProviders.length > 0)) {
904
+ setNestedValue(filtered, "channels.enabledProviders", configEnabledProviders);
905
+ }
906
+ const configDeviceId = getNestedValue(config, "hdhr.deviceId");
907
+ if ((typeof configDeviceId === "string") && (configDeviceId.length > 0)) {
908
+ setNestedValue(filtered, "hdhr.deviceId", configDeviceId);
909
+ }
910
+ // Remove any empty nested objects that resulted from filtering.
911
+ return removeEmptyObjects(filtered);
912
+ }
913
+ //# sourceMappingURL=userConfig.js.map