prismcast 1.5.2 → 1.7.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 (239) hide show
  1. package/README.md +44 -28
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +63 -5
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.js +1 -1
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/channelSelection.d.ts +10 -34
  8. package/dist/browser/channelSelection.js +31 -96
  9. package/dist/browser/channelSelection.js.map +1 -1
  10. package/dist/browser/display.d.ts +19 -0
  11. package/dist/browser/display.js +16 -11
  12. package/dist/browser/display.js.map +1 -1
  13. package/dist/browser/index.d.ts +31 -54
  14. package/dist/browser/index.js +334 -272
  15. package/dist/browser/index.js.map +1 -1
  16. package/dist/browser/login.d.ts +65 -0
  17. package/dist/browser/login.js +193 -0
  18. package/dist/browser/login.js.map +1 -0
  19. package/dist/browser/precaching.js +15 -3
  20. package/dist/browser/precaching.js.map +1 -1
  21. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  22. package/dist/browser/tuning/comcastPolymer.js +655 -0
  23. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  24. package/dist/browser/tuning/cox.d.ts +2 -0
  25. package/dist/browser/tuning/cox.js +14 -0
  26. package/dist/browser/tuning/cox.js.map +1 -0
  27. package/dist/browser/tuning/directv.js +14 -1
  28. package/dist/browser/tuning/directv.js.map +1 -1
  29. package/dist/browser/tuning/fox.js +14 -2
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.js +12 -1
  32. package/dist/browser/tuning/hbo.js.map +1 -1
  33. package/dist/browser/tuning/hulu.js +24 -1
  34. package/dist/browser/tuning/hulu.js.map +1 -1
  35. package/dist/browser/tuning/shared.d.ts +46 -0
  36. package/dist/browser/tuning/shared.js +100 -0
  37. package/dist/browser/tuning/shared.js.map +1 -0
  38. package/dist/browser/tuning/sling.js +35 -5
  39. package/dist/browser/tuning/sling.js.map +1 -1
  40. package/dist/browser/tuning/spectrum.js +13 -1
  41. package/dist/browser/tuning/spectrum.js.map +1 -1
  42. package/dist/browser/tuning/thumbnailRow.js +1 -1
  43. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  44. package/dist/browser/tuning/tileClick.js +1 -1
  45. package/dist/browser/tuning/tileClick.js.map +1 -1
  46. package/dist/browser/tuning/xfinity.d.ts +2 -0
  47. package/dist/browser/tuning/xfinity.js +14 -0
  48. package/dist/browser/tuning/xfinity.js.map +1 -0
  49. package/dist/browser/tuning/youtubeTv.js +15 -1
  50. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  51. package/dist/browser/video.d.ts +20 -0
  52. package/dist/browser/video.js +168 -124
  53. package/dist/browser/video.js.map +1 -1
  54. package/dist/channels/index.d.ts +2 -2
  55. package/dist/channels/index.js +2075 -683
  56. package/dist/channels/index.js.map +1 -1
  57. package/dist/config/health.d.ts +28 -28
  58. package/dist/config/health.js +72 -54
  59. package/dist/config/health.js.map +1 -1
  60. package/dist/config/index.js +29 -64
  61. package/dist/config/index.js.map +1 -1
  62. package/dist/config/paths.d.ts +12 -0
  63. package/dist/config/paths.js +16 -0
  64. package/dist/config/paths.js.map +1 -1
  65. package/dist/config/profiles.d.ts +3 -2
  66. package/dist/config/profiles.js +45 -34
  67. package/dist/config/profiles.js.map +1 -1
  68. package/dist/config/providerPacks.js +4 -1
  69. package/dist/config/providerPacks.js.map +1 -1
  70. package/dist/config/providers.d.ts +19 -5
  71. package/dist/config/providers.js +123 -93
  72. package/dist/config/providers.js.map +1 -1
  73. package/dist/config/sites.d.ts +28 -0
  74. package/dist/config/sites.js +104 -127
  75. package/dist/config/sites.js.map +1 -1
  76. package/dist/config/userChannels.d.ts +53 -2
  77. package/dist/config/userChannels.js +222 -13
  78. package/dist/config/userChannels.js.map +1 -1
  79. package/dist/config/userConfig.d.ts +2 -1
  80. package/dist/config/userConfig.js +6 -15
  81. package/dist/config/userConfig.js.map +1 -1
  82. package/dist/config/userProfiles.d.ts +0 -1
  83. package/dist/config/userProfiles.js +18 -10
  84. package/dist/config/userProfiles.js.map +1 -1
  85. package/dist/hdhr/channelMap.d.ts +1 -1
  86. package/dist/hdhr/channelMap.js +4 -4
  87. package/dist/hdhr/channelMap.js.map +1 -1
  88. package/dist/index.js +25 -22
  89. package/dist/index.js.map +1 -1
  90. package/dist/native/decrypt.d.ts +32 -0
  91. package/dist/native/decrypt.js +85 -0
  92. package/dist/native/decrypt.js.map +1 -0
  93. package/dist/native/index.d.ts +61 -0
  94. package/dist/native/index.js +342 -0
  95. package/dist/native/index.js.map +1 -0
  96. package/dist/native/intercept.d.ts +35 -0
  97. package/dist/native/intercept.js +184 -0
  98. package/dist/native/intercept.js.map +1 -0
  99. package/dist/native/probe.d.ts +49 -0
  100. package/dist/native/probe.js +294 -0
  101. package/dist/native/probe.js.map +1 -0
  102. package/dist/native/proxy.d.ts +54 -0
  103. package/dist/native/proxy.js +947 -0
  104. package/dist/native/proxy.js.map +1 -0
  105. package/dist/routes/components.d.ts +43 -0
  106. package/dist/routes/components.js +122 -0
  107. package/dist/routes/components.js.map +1 -1
  108. package/dist/routes/config/channels/index.d.ts +2 -2
  109. package/dist/routes/config/channels/index.js +1 -1
  110. package/dist/routes/config/channels/index.js.map +1 -1
  111. package/dist/routes/config/channels/routes.js +443 -65
  112. package/dist/routes/config/channels/routes.js.map +1 -1
  113. package/dist/routes/config/channels/table.d.ts +76 -0
  114. package/dist/routes/config/channels/table.js +369 -89
  115. package/dist/routes/config/channels/table.js.map +1 -1
  116. package/dist/routes/config/index.d.ts +1 -1
  117. package/dist/routes/config/index.js +1 -1
  118. package/dist/routes/config/index.js.map +1 -1
  119. package/dist/routes/config/providers.d.ts +6 -6
  120. package/dist/routes/config/providers.js +73 -99
  121. package/dist/routes/config/providers.js.map +1 -1
  122. package/dist/routes/debug.js +5 -5
  123. package/dist/routes/debug.js.map +1 -1
  124. package/dist/routes/hls.js +12 -2
  125. package/dist/routes/hls.js.map +1 -1
  126. package/dist/routes/index.d.ts +0 -15
  127. package/dist/routes/index.js +2 -16
  128. package/dist/routes/index.js.map +1 -1
  129. package/dist/routes/providers.js +106 -3
  130. package/dist/routes/providers.js.map +1 -1
  131. package/dist/routes/root/content.js +124 -47
  132. package/dist/routes/root/content.js.map +1 -1
  133. package/dist/routes/root/index.js +2 -1
  134. package/dist/routes/root/index.js.map +1 -1
  135. package/dist/routes/root/scripts/channels.js +772 -312
  136. package/dist/routes/root/scripts/channels.js.map +1 -1
  137. package/dist/routes/root/scripts/config.js +596 -544
  138. package/dist/routes/root/scripts/config.js.map +1 -1
  139. package/dist/routes/root/scripts/index.d.ts +1 -0
  140. package/dist/routes/root/scripts/index.js +1 -0
  141. package/dist/routes/root/scripts/index.js.map +1 -1
  142. package/dist/routes/root/scripts/shared.d.ts +6 -0
  143. package/dist/routes/root/scripts/shared.js +391 -0
  144. package/dist/routes/root/scripts/shared.js.map +1 -0
  145. package/dist/routes/root/scripts/status.js +49 -33
  146. package/dist/routes/root/scripts/status.js.map +1 -1
  147. package/dist/routes/root/styles.js +117 -10
  148. package/dist/routes/root/styles.js.map +1 -1
  149. package/dist/service/commands.js +2 -1
  150. package/dist/service/commands.js.map +1 -1
  151. package/dist/streaming/fmp4Segmenter.d.ts +5 -0
  152. package/dist/streaming/fmp4Segmenter.js +146 -42
  153. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  154. package/dist/streaming/hls.d.ts +24 -19
  155. package/dist/streaming/hls.js +603 -297
  156. package/dist/streaming/hls.js.map +1 -1
  157. package/dist/streaming/hlsResume.d.ts +16 -3
  158. package/dist/streaming/hlsResume.js +35 -8
  159. package/dist/streaming/hlsResume.js.map +1 -1
  160. package/dist/streaming/hlsSegments.d.ts +41 -2
  161. package/dist/streaming/hlsSegments.js +131 -28
  162. package/dist/streaming/hlsSegments.js.map +1 -1
  163. package/dist/streaming/lifecycle.js +27 -13
  164. package/dist/streaming/lifecycle.js.map +1 -1
  165. package/dist/streaming/monitor.d.ts +1 -0
  166. package/dist/streaming/monitor.js +862 -474
  167. package/dist/streaming/monitor.js.map +1 -1
  168. package/dist/streaming/mp4Parser.d.ts +52 -6
  169. package/dist/streaming/mp4Parser.js +156 -30
  170. package/dist/streaming/mp4Parser.js.map +1 -1
  171. package/dist/streaming/mpegts.d.ts +2 -2
  172. package/dist/streaming/mpegts.js +156 -109
  173. package/dist/streaming/mpegts.js.map +1 -1
  174. package/dist/streaming/playlistBuilder.d.ts +38 -0
  175. package/dist/streaming/playlistBuilder.js +66 -0
  176. package/dist/streaming/playlistBuilder.js.map +1 -0
  177. package/dist/streaming/preroll.d.ts +126 -0
  178. package/dist/streaming/preroll.js +434 -0
  179. package/dist/streaming/preroll.js.map +1 -0
  180. package/dist/streaming/pretune.d.ts +8 -0
  181. package/dist/streaming/pretune.js +226 -0
  182. package/dist/streaming/pretune.js.map +1 -0
  183. package/dist/streaming/recovery.d.ts +0 -6
  184. package/dist/streaming/recovery.js +0 -8
  185. package/dist/streaming/recovery.js.map +1 -1
  186. package/dist/streaming/registry.d.ts +57 -9
  187. package/dist/streaming/registry.js +26 -7
  188. package/dist/streaming/registry.js.map +1 -1
  189. package/dist/streaming/setup.d.ts +8 -1
  190. package/dist/streaming/setup.js +71 -27
  191. package/dist/streaming/setup.js.map +1 -1
  192. package/dist/streaming/showInfo.d.ts +29 -4
  193. package/dist/streaming/showInfo.js +210 -38
  194. package/dist/streaming/showInfo.js.map +1 -1
  195. package/dist/streaming/statusEmitter.d.ts +20 -6
  196. package/dist/streaming/statusEmitter.js +31 -7
  197. package/dist/streaming/statusEmitter.js.map +1 -1
  198. package/dist/types/channels.d.ts +32 -0
  199. package/dist/types/config.d.ts +2 -2
  200. package/dist/types/index.d.ts +2 -2
  201. package/dist/types/index.js.map +1 -1
  202. package/dist/types/profiles.d.ts +13 -1
  203. package/dist/types/selection.d.ts +5 -1
  204. package/dist/types/shared.d.ts +1 -1
  205. package/dist/types/streaming.d.ts +7 -0
  206. package/dist/utils/chromeFetch.d.ts +20 -0
  207. package/dist/utils/chromeFetch.js +43 -0
  208. package/dist/utils/chromeFetch.js.map +1 -0
  209. package/dist/utils/debugFilter.d.ts +0 -5
  210. package/dist/utils/debugFilter.js +20 -11
  211. package/dist/utils/debugFilter.js.map +1 -1
  212. package/dist/utils/delay.d.ts +28 -0
  213. package/dist/utils/delay.js +35 -1
  214. package/dist/utils/delay.js.map +1 -1
  215. package/dist/utils/evaluate.js +6 -11
  216. package/dist/utils/evaluate.js.map +1 -1
  217. package/dist/utils/ffmpeg.d.ts +14 -12
  218. package/dist/utils/ffmpeg.js +114 -134
  219. package/dist/utils/ffmpeg.js.map +1 -1
  220. package/dist/utils/format.d.ts +6 -0
  221. package/dist/utils/format.js +8 -0
  222. package/dist/utils/format.js.map +1 -1
  223. package/dist/utils/index.d.ts +3 -0
  224. package/dist/utils/index.js +3 -0
  225. package/dist/utils/index.js.map +1 -1
  226. package/dist/utils/logEmitter.js +1 -1
  227. package/dist/utils/logEmitter.js.map +1 -1
  228. package/dist/utils/pid.d.ts +40 -0
  229. package/dist/utils/pid.js +83 -0
  230. package/dist/utils/pid.js.map +1 -0
  231. package/dist/utils/retry.d.ts +15 -9
  232. package/dist/utils/retry.js +7 -23
  233. package/dist/utils/retry.js.map +1 -1
  234. package/dist/utils/timing.d.ts +5 -0
  235. package/dist/utils/timing.js +13 -0
  236. package/dist/utils/timing.js.map +1 -0
  237. package/dist/utils/version.js +2 -2
  238. package/dist/utils/version.js.map +1 -1
  239. package/package.json +7 -7
@@ -1,10 +1,12 @@
1
1
  import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
2
- import { VALID_OPTIONAL_COLUMNS, generateChannelRowHtml } from "./table.js";
3
- import { VALID_SORT_FIELDS, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
2
+ import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState } from "./table.js";
3
+ import { VALID_SORT_FIELDS, compareChannelSort, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
4
4
  import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.js";
5
- import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getPredefinedScopeCounts, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
5
+ import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
6
6
  import { CONFIG } from "../../../config/index.js";
7
+ import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
7
8
  import { getProfiles } from "../../../config/profiles.js";
9
+ import { updateChannelLogo } from "../../../streaming/showInfo.js";
8
10
  // Fields that appear in the generated M3U playlist and affect Channels DVR's view of the channel. Used to decide whether the playlist reload hint is shown.
9
11
  const M3U_FIELDS = ["channelNumber", "name", "stationId", "tvgShift"];
10
12
  const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change.";
@@ -17,6 +19,48 @@ const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change."
17
19
  function playlistHintForStored(stored) {
18
20
  return M3U_FIELDS.some((f) => f in stored) ? PLAYLIST_HINT : "";
19
21
  }
22
+ /**
23
+ * Disables one or more predefined channels by adding their keys to the disabledPredefined list in user config. Updates both the config file and the runtime
24
+ * CONFIG object. Shared by the toggle-predefined and bulk-add handlers to avoid duplicating the config load/save/update pattern.
25
+ * @param keys - The predefined channel keys to disable.
26
+ */
27
+ async function disablePredefinedChannels(keys) {
28
+ if (keys.length === 0) {
29
+ return;
30
+ }
31
+ const configResult = await loadUserConfig();
32
+ const userConfig = configResult.config;
33
+ userConfig.channels ??= {};
34
+ userConfig.channels.disabledPredefined ??= [];
35
+ const disabledSet = new Set(userConfig.channels.disabledPredefined);
36
+ for (const key of keys) {
37
+ disabledSet.add(key);
38
+ }
39
+ userConfig.channels.disabledPredefined = [...disabledSet].sort();
40
+ await saveUserConfig(userConfig);
41
+ CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
42
+ }
43
+ /**
44
+ * Enables one or more predefined channels by removing their keys from the disabledPredefined list in user config. Updates both the config file and the
45
+ * runtime CONFIG object. Shared by the toggle-predefined and bulk-add handlers to avoid duplicating the config load/save/update pattern.
46
+ * @param keys - The predefined channel keys to enable.
47
+ */
48
+ async function enablePredefinedChannels(keys) {
49
+ if (keys.length === 0) {
50
+ return;
51
+ }
52
+ const configResult = await loadUserConfig();
53
+ const userConfig = configResult.config;
54
+ userConfig.channels ??= {};
55
+ userConfig.channels.disabledPredefined ??= [];
56
+ const disabledSet = new Set(userConfig.channels.disabledPredefined);
57
+ for (const key of keys) {
58
+ disabledSet.delete(key);
59
+ }
60
+ userConfig.channels.disabledPredefined = [...disabledSet].sort();
61
+ await saveUserConfig(userConfig);
62
+ CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
63
+ }
20
64
  /**
21
65
  * Installs all channel-related route handlers on the Express application.
22
66
  * @param app - The Express application.
@@ -54,8 +98,10 @@ export function setupChannelRoutes(app) {
54
98
  // Save the imported channels, replacing all existing user channels.
55
99
  await saveUserChannels(validationResult.channels);
56
100
  const channelCount = Object.keys(validationResult.channels).length;
57
- // Send success response. Changes take effect immediately due to hot-reloading in saveUserChannels().
58
- res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
101
+ // Return a full table patch since a bulk JSON import replaces all user channels — every row in the listing may have changed.
102
+ const allKeys = getChannelListing().map((entry) => entry.key);
103
+ res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.",
104
+ patch: buildChannelTablePatch(allKeys, getProfiles()), success: true });
59
105
  }
60
106
  catch (error) {
61
107
  LOG.error("Failed to import channels: %s.", formatError(error));
@@ -92,6 +138,7 @@ export function setupChannelRoutes(app) {
92
138
  const loadResult = await loadUserChannels();
93
139
  const existingChannels = loadResult.parseError ? {} : loadResult.channels;
94
140
  // Track import statistics.
141
+ const affectedKeys = [];
95
142
  const conflicts = [];
96
143
  const importErrors = [];
97
144
  const seenKeys = new Set();
@@ -151,16 +198,18 @@ export function setupChannelRoutes(app) {
151
198
  }
152
199
  // Add to channels collection.
153
200
  existingChannels[key] = channel;
201
+ affectedKeys.push(key);
154
202
  }
155
203
  // Save the updated channels.
156
204
  await saveUserChannels(existingChannels);
157
205
  // Log the import.
158
206
  LOG.info("M3U import completed: %d imported, %d replaced, %d skipped.", imported, replaced, skipped);
159
- // Build response.
207
+ // Build response with patch for in-place UI update.
160
208
  res.json({
161
209
  conflicts,
162
210
  errors: [...parseResult.errors, ...importErrors],
163
211
  imported,
212
+ patch: ((imported > 0) || (replaced > 0)) ? buildChannelTablePatch(affectedKeys, getProfiles()) : undefined,
164
213
  replaced,
165
214
  skipped,
166
215
  success: true
@@ -171,6 +220,177 @@ export function setupChannelRoutes(app) {
171
220
  res.status(500).json({ error: "Failed to import channels: " + formatError(error), success: false });
172
221
  }
173
222
  });
223
+ // POST /config/channels/modify - Apply channel modifications from the browse modal. Handles four action types: 'add' creates new user channels, 'enable'
224
+ // re-enables a disabled predefined channel and sets its provider, 'switch' changes the provider selection for an existing channel, and 'remove' reverts a
225
+ // channel to its canonical provider (disabling it if no alternative provider is available). Channel writes and provider selection changes are batched into
226
+ // single saves to avoid redundant file I/O.
227
+ app.post("/config/channels/modify", async (req, res) => {
228
+ try {
229
+ const body = req.body;
230
+ const channels = body.channels;
231
+ // Validate that channels array is provided and non-empty.
232
+ if (!Array.isArray(channels) || (channels.length === 0)) {
233
+ res.status(400).json({ error: "No channels provided.", success: false });
234
+ return;
235
+ }
236
+ // Load existing user channels.
237
+ const loadResult = await loadUserChannels();
238
+ const existingChannels = loadResult.parseError ? {} : loadResult.channels;
239
+ // Build a set of all existing channel keys (predefined + user) for deduplication.
240
+ const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(existingChannels)]);
241
+ const affectedKeys = new Set();
242
+ const disablePredefined = new Set();
243
+ const errors = [];
244
+ let added = 0;
245
+ let removed = 0;
246
+ let switched = 0;
247
+ let channelsChanged = false;
248
+ let selectionsChanged = false;
249
+ // Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic.
250
+ function buildUserChannel(entry, channelName, channelUrl, selector) {
251
+ const channel = {
252
+ channelSelector: selector || undefined,
253
+ name: channelName,
254
+ url: channelUrl
255
+ };
256
+ if (entry.stationId) {
257
+ channel.stationId = sanitizeString(entry.stationId);
258
+ }
259
+ return channel;
260
+ }
261
+ for (const entry of channels) {
262
+ const action = entry.action ?? "add";
263
+ const name = sanitizeString(entry.name?.trim() ?? "");
264
+ const providerSlug = entry.providerSlug?.trim() ?? "";
265
+ // Handle enable and switch. Enable re-enables a disabled predefined channel, then falls through to the switch logic to set the provider selection.
266
+ // Switch changes the provider selection for an existing channel to point to the browsed provider's variant, creating the variant if needed.
267
+ if ((action === "switch") || (action === "enable")) {
268
+ const canonicalKey = entry.canonicalKey?.trim() ?? "";
269
+ if (!canonicalKey) {
270
+ errors.push("Entry for '" + name + "' missing canonical key.");
271
+ continue;
272
+ }
273
+ // Re-enable the channel if it was disabled. Must complete before setting the provider selection since the config write makes the channel
274
+ // visible in the lineup.
275
+ if (action === "enable") {
276
+ // eslint-disable-next-line no-await-in-loop -- Sequential: config state must be updated before setting provider selection.
277
+ await enablePredefinedChannels([canonicalKey]);
278
+ }
279
+ const variantKey = canonicalKey + "-" + providerSlug;
280
+ // If no variant exists for this provider, create one as a user channel.
281
+ if (!allKeys.has(variantKey)) {
282
+ existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""));
283
+ allKeys.add(variantKey);
284
+ channelsChanged = true;
285
+ }
286
+ setProviderSelection(canonicalKey, variantKey);
287
+ selectionsChanged = true;
288
+ switched++;
289
+ affectedKeys.add(canonicalKey);
290
+ continue;
291
+ }
292
+ // Handle provider removal. Reverts the channel away from this provider using a three-tier fallback: (1) clear the selection and let
293
+ // resolveProviderKey find the next enabled variant or canonical, (2) if the resolved provider is still this provider (no alternative exists),
294
+ // disable the predefined channel or delete the user channel.
295
+ if (action === "remove") {
296
+ const canonicalKey = entry.canonicalKey?.trim() ?? "";
297
+ if (!canonicalKey) {
298
+ errors.push("Remove entry for '" + name + "' missing canonical key.");
299
+ continue;
300
+ }
301
+ // Clear the provider selection. resolveProviderKey will now return the canonical or the first enabled alternative variant.
302
+ setProviderSelection(canonicalKey, canonicalKey);
303
+ selectionsChanged = true;
304
+ // Check if the resolved provider is still this provider. If so, there is no alternative — disable or delete the channel.
305
+ const resolvedKey = resolveProviderKey(canonicalKey);
306
+ const resolvedTag = getProviderTagForChannel(resolvedKey);
307
+ if (resolvedTag === providerSlug) {
308
+ // No alternative provider exists. Disable predefined channels or delete user channels.
309
+ if (isPredefinedChannel(canonicalKey)) {
310
+ disablePredefined.add(canonicalKey);
311
+ }
312
+ else {
313
+ Reflect.deleteProperty(existingChannels, canonicalKey);
314
+ channelsChanged = true;
315
+ }
316
+ }
317
+ removed++;
318
+ affectedKeys.add(canonicalKey);
319
+ continue;
320
+ }
321
+ // Handle add. Creates a new user channel for channels not yet in the lineup. The browse modal sends 'add' only for genuinely new channels (no
322
+ // existing canonical). Channels that match existing canonicals appear as 'switch' state in the modal instead.
323
+ const url = sanitizeString(entry.url?.trim() ?? "");
324
+ const channelSelector = sanitizeString(entry.channelSelector?.trim() ?? "");
325
+ if (!name) {
326
+ errors.push("Channel entry missing name.");
327
+ continue;
328
+ }
329
+ if (!url) {
330
+ errors.push("Channel '" + name + "' missing URL.");
331
+ continue;
332
+ }
333
+ const urlError = validateChannelUrl(url);
334
+ if (urlError) {
335
+ errors.push("Channel '" + name + "': " + urlError);
336
+ continue;
337
+ }
338
+ const baseKey = generateChannelKey(name);
339
+ if (!baseKey) {
340
+ errors.push("Could not generate key for channel '" + name + "'.");
341
+ continue;
342
+ }
343
+ const canonicalExists = allKeys.has(baseKey);
344
+ const key = (canonicalExists && providerSlug) ? baseKey + "-" + providerSlug : baseKey;
345
+ // Skip channels that already exist in the lineup.
346
+ if (allKeys.has(key)) {
347
+ continue;
348
+ }
349
+ const newChannel = buildUserChannel(entry, name, url, channelSelector);
350
+ // When this is a provider variant (key differs from baseKey), store the canonical relationship explicitly so that buildProviderGroups can
351
+ // associate the user channel with its canonical via the canonicalKey field.
352
+ if (key !== baseKey) {
353
+ newChannel.canonicalKey = baseKey;
354
+ }
355
+ existingChannels[key] = newChannel;
356
+ allKeys.add(key);
357
+ channelsChanged = true;
358
+ added++;
359
+ affectedKeys.add(canonicalExists ? baseKey : key);
360
+ }
361
+ // Save changes. saveUserChannels() persists both channel data and provider selections in a single file write, so we only need saveProviderSelections()
362
+ // (which writes the same file using the module-level cache) when selections changed but no new channels were added.
363
+ if (channelsChanged) {
364
+ await saveUserChannels(existingChannels);
365
+ }
366
+ else if (selectionsChanged) {
367
+ await saveProviderSelections();
368
+ }
369
+ // Disable predefined channels that had no alternative provider after removal.
370
+ await disablePredefinedChannels([...disablePredefined]);
371
+ for (const disabledKey of disablePredefined) {
372
+ affectedKeys.add(disabledKey);
373
+ }
374
+ // Build a descriptive response message.
375
+ const parts = [];
376
+ if (added > 0) {
377
+ parts.push("Added " + String(added) + " channel" + (added === 1 ? "" : "s") + ".");
378
+ }
379
+ if (switched > 0) {
380
+ parts.push("Switched " + String(switched) + " channel" + (switched === 1 ? "" : "s") + ".");
381
+ }
382
+ if (removed > 0) {
383
+ parts.push("Reverted " + String(removed) + " channel" + (removed === 1 ? "" : "s") + ".");
384
+ }
385
+ const message = (parts.length > 0 ? parts.join(" ") : "No changes made.") + PLAYLIST_HINT;
386
+ LOG.info("Browse channels completed: %d added, %d switched, %d removed.", added, switched, removed);
387
+ res.json({ added, errors, message, patch: buildChannelTablePatch([...affectedKeys], getProfiles()), removed, success: true, switched });
388
+ }
389
+ catch (error) {
390
+ LOG.error("Failed to apply browse changes: %s.", formatError(error));
391
+ res.status(500).json({ error: "Failed to apply changes: " + formatError(error), success: false });
392
+ }
393
+ });
174
394
  // POST /config/channels/toggle-predefined - Toggle a single predefined channel's enabled/disabled state.
175
395
  app.post("/config/channels/toggle-predefined", async (req, res) => {
176
396
  try {
@@ -192,28 +412,15 @@ export function setupChannelRoutes(app) {
192
412
  res.status(400).json({ error: "Channel '" + key + "' is not a predefined channel.", success: false });
193
413
  return;
194
414
  }
195
- // Load current config.
196
- const configResult = await loadUserConfig();
197
- const userConfig = configResult.config;
198
- // Initialize channels.disabledPredefined if not present.
199
- userConfig.channels ??= {};
200
- userConfig.channels.disabledPredefined ??= [];
201
- const disabledSet = new Set(userConfig.channels.disabledPredefined);
415
+ // Enable or disable the channel using the shared helpers that handle config file I/O and runtime CONFIG update.
202
416
  if (enabled) {
203
- // Enable: remove from disabled list.
204
- disabledSet.delete(key);
417
+ await enablePredefinedChannels([key]);
205
418
  }
206
419
  else {
207
- // Disable: add to disabled list.
208
- disabledSet.add(key);
420
+ await disablePredefinedChannels([key]);
209
421
  }
210
- // Update and save config.
211
- userConfig.channels.disabledPredefined = [...disabledSet].sort();
212
- await saveUserConfig(userConfig);
213
- // Update the runtime CONFIG to reflect the change immediately.
214
- CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
215
422
  LOG.info("Predefined channel '%s' %s.", key, enabled ? "enabled" : "disabled");
216
- res.json({ counts: getPredefinedScopeCounts(), enabled, key, success: true });
423
+ res.json({ enabled, key, patch: buildChannelTablePatch([key], getProfiles()), success: true });
217
424
  }
218
425
  catch (error) {
219
426
  LOG.error("Failed to toggle predefined channel: %s.", formatError(error));
@@ -260,10 +467,9 @@ export function setupChannelRoutes(app) {
260
467
  const channelName = canonicalChannel?.name ?? canonicalKey;
261
468
  const providerLabel = variantChannel ? getChannelProviderLabel(variantChannel) : providerKey;
262
469
  LOG.info("Provider for %s changed to %s.", channelName, providerLabel);
263
- // Return full row HTML so the client can replace both the display and edit rows, keeping the edit form in sync with the selected provider.
470
+ // Return a channel table patch so the client can update the row and summary counts in place.
264
471
  const profiles = getProfiles();
265
- const rowHtml = generateChannelRowHtml(canonicalKey, profiles);
266
- res.json({ channel: canonicalKey, html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, provider: providerKey, success: true });
472
+ res.json({ channel: canonicalKey, patch: buildChannelTablePatch([canonicalKey], profiles), provider: providerKey, success: true });
267
473
  }
268
474
  catch (error) {
269
475
  LOG.error("Failed to update provider selection: %s.", formatError(error));
@@ -330,15 +536,121 @@ export function setupChannelRoutes(app) {
330
536
  const affected = targetKeys.length;
331
537
  const scopeLabel = (scope === "all") ? "All" : (scope === "pacific") ? "Pacific" : "East";
332
538
  LOG.info("%s predefined channels %s (%d affected).", scopeLabel, enabled ? "enabled" : "disabled", affected);
333
- // Compute updated counts for all three scopes so the client can update all toggle rows from a single response.
334
- const counts = getPredefinedScopeCounts();
335
- res.json({ affected, counts, enabled, keys: targetKeys, scope, success: true });
539
+ res.json({ affected, enabled, keys: targetKeys, patch: buildChannelTablePatch(targetKeys, getProfiles()), scope, success: true });
336
540
  }
337
541
  catch (error) {
338
542
  LOG.error("Failed to toggle predefined channels: %s.", formatError(error));
339
543
  res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
340
544
  }
341
545
  });
546
+ // POST /config/channels/auto-number - Assign sequential channel numbers to visible channels in the current sort order, or clear all channel numbers when
547
+ // start is 0. Overwrites existing channel numbers for affected channels. Only channels that are enabled and available by provider filter are affected.
548
+ app.post("/config/channels/auto-number", async (req, res) => {
549
+ try {
550
+ const body = req.body;
551
+ const start = (typeof body.start === "number") ? body.start : 1;
552
+ const clearMode = (start === 0);
553
+ const sortField = body.sortField ?? "name";
554
+ const sortDir = (body.sortDirection === "desc") ? "desc" : "asc";
555
+ if (!clearMode && ((start < 1) || (start > 99999))) {
556
+ res.status(400).json({ error: "Starting number must be between 1 and 99999.", success: false });
557
+ return;
558
+ }
559
+ if (!VALID_SORT_FIELDS.has(sortField)) {
560
+ res.status(400).json({ error: "Invalid sort field.", success: false });
561
+ return;
562
+ }
563
+ // Get visible channels (enabled + available by provider filter) sorted by the user's current sort order.
564
+ const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByProvider);
565
+ listing.sort((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
566
+ // Load user channels for modification.
567
+ const result = await loadUserChannels();
568
+ if (result.parseError) {
569
+ res.status(400).json({ error: "Cannot auto-number: channels file contains invalid JSON.", success: false });
570
+ return;
571
+ }
572
+ const affectedKeys = [];
573
+ if (clearMode) {
574
+ // Clear channel numbers from all visible channels. For predefined channels, use null to signal removal in the delta. For user channels, use undefined.
575
+ for (const entry of listing) {
576
+ const existing = result.channels[entry.key] ?? {};
577
+ const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
578
+ existing.channelNumber = clearValue;
579
+ result.channels[entry.key] = existing;
580
+ affectedKeys.push(entry.key);
581
+ }
582
+ }
583
+ else {
584
+ // Assign sequential numbers starting from the requested start value.
585
+ for (let i = 0; i < listing.length; i++) {
586
+ const entry = listing[i];
587
+ const num = start + i;
588
+ if (num > 99999) {
589
+ break;
590
+ }
591
+ const existing = result.channels[entry.key] ?? {};
592
+ existing.channelNumber = num;
593
+ result.channels[entry.key] = existing;
594
+ affectedKeys.push(entry.key);
595
+ }
596
+ }
597
+ await saveUserChannels(result.channels);
598
+ const action = clearMode ? "Cleared" : "Numbered";
599
+ LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
600
+ const profiles = getProfiles();
601
+ const message = clearMode ?
602
+ "Cleared channel numbers from " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT :
603
+ "Numbered " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT;
604
+ res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
605
+ }
606
+ catch (error) {
607
+ LOG.error("Failed to auto-number channels: %s.", formatError(error));
608
+ res.status(500).json({ error: "Failed to auto-number channels: " + formatError(error), success: false });
609
+ }
610
+ });
611
+ // POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
612
+ // cleared). When false, all channels are excluded (hdhrEnabled set to false).
613
+ app.post("/config/channels/hdhr-bulk", async (req, res) => {
614
+ try {
615
+ const body = req.body;
616
+ const enable = body.enable === true;
617
+ // Get visible channels (enabled + available by provider filter).
618
+ const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByProvider);
619
+ // Load user channels for modification.
620
+ const result = await loadUserChannels();
621
+ if (result.parseError) {
622
+ res.status(400).json({ error: "Cannot update HDHR settings: channels file contains invalid JSON.", success: false });
623
+ return;
624
+ }
625
+ const affectedKeys = [];
626
+ for (const entry of listing) {
627
+ const current = entry.channel.hdhrEnabled !== false;
628
+ // Skip channels that already match the target state.
629
+ if (current === enable) {
630
+ continue;
631
+ }
632
+ const existing = result.channels[entry.key] ?? {};
633
+ const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
634
+ existing.hdhrEnabled = enable ? clearValue : false;
635
+ result.channels[entry.key] = existing;
636
+ affectedKeys.push(entry.key);
637
+ }
638
+ if (affectedKeys.length === 0) {
639
+ res.json({ affected: 0, message: "No changes needed.", success: true });
640
+ return;
641
+ }
642
+ await saveUserChannels(result.channels);
643
+ const action = enable ? "included in" : "excluded from";
644
+ LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
645
+ const profiles = getProfiles();
646
+ const message = String(affectedKeys.length) + " channel(s) " + action + " the HDHomeRun lineup.";
647
+ res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
648
+ }
649
+ catch (error) {
650
+ LOG.error("Failed to bulk-toggle HDHR: %s.", formatError(error));
651
+ res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
652
+ }
653
+ });
342
654
  // POST /config/provider-filter - Update the provider filter (enabled provider tags).
343
655
  app.post("/config/provider-filter", async (req, res) => {
344
656
  try {
@@ -370,13 +682,33 @@ export function setupChannelRoutes(app) {
370
682
  userConfig.channels.enabledProviders = tags;
371
683
  await saveUserConfig(filterDefaults(userConfig));
372
684
  LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
373
- res.json({ enabledProviders: tags, success: true });
685
+ // Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
686
+ const { counts, scopeCounts } = buildChannelTableState();
687
+ res.json({ enabledProviders: tags, patch: { counts, rows: [], scopeCounts }, success: true });
374
688
  }
375
689
  catch (error) {
376
690
  LOG.error("Failed to update provider filter: %s.", formatError(error));
377
691
  res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
378
692
  }
379
693
  });
694
+ // POST /config/channels/setup-completed - Mark the Provider Setup flow as completed. Called when the wizard finishes or the user explicitly skips.
695
+ app.post("/config/channels/setup-completed", async (_req, res) => {
696
+ try {
697
+ CONFIG.channels.setupCompleted = true;
698
+ const configResult = await loadUserConfig();
699
+ configResult.config.channels ??= {};
700
+ configResult.config.channels.setupCompleted = true;
701
+ await saveUserConfig(configResult.config);
702
+ // Return a full patch so the client can refresh the channel table after the setup wizard completes. The wizard's browse step may have added channels
703
+ // via the modify endpoint, and the provider filter may have changed — the patch ensures the table reflects the current state.
704
+ const { counts, scopeCounts } = buildChannelTableState();
705
+ res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
706
+ }
707
+ catch (error) {
708
+ LOG.error("Failed to save setup completed state: %s.", formatError(error));
709
+ res.status(500).json({ error: "Failed to save setup state: " + formatError(error), success: false });
710
+ }
711
+ });
380
712
  // POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
381
713
  app.post("/config/channels/display-prefs", async (req, res) => {
382
714
  try {
@@ -545,10 +877,59 @@ export function setupChannelRoutes(app) {
545
877
  Reflect.deleteProperty(result.channels, key);
546
878
  await saveUserChannels(result.channels);
547
879
  LOG.info("Channel '%s' reverted to predefined defaults.", key);
548
- // Generate the predefined row HTML so the client can replace the override row.
549
- const rowHtml = generateChannelRowHtml(key, profiles);
550
- res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
551
- message: "Channel '" + key + "' reverted to defaults." + revertHint, success: true });
880
+ res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
881
+ patch: buildChannelTablePatch([key], profiles), success: true });
882
+ return;
883
+ }
884
+ // Handle inline-edit action — update a single field on an existing channel without requiring all fields. Used by the inline cell editing UI for channel
885
+ // number, station ID, and HDHR lineup toggles.
886
+ if (action === "inline-edit") {
887
+ if (!key) {
888
+ res.status(400).json({ message: "Channel key is required.", success: false });
889
+ return;
890
+ }
891
+ const field = body.field;
892
+ const value = sanitizeString(body.value ?? "");
893
+ if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId")) {
894
+ res.status(400).json({ message: "Invalid inline-edit field.", success: false });
895
+ return;
896
+ }
897
+ // Validate channel number if the field is channelNumber.
898
+ if (field === "channelNumber") {
899
+ const numberError = validateChannelNumber(value, key);
900
+ if (numberError) {
901
+ res.status(400).json({ message: numberError, success: false });
902
+ return;
903
+ }
904
+ }
905
+ // Load and update the channel. For predefined channels, store a delta override with null to clear fields. For user channels, use undefined to delete.
906
+ const result = await loadUserChannels();
907
+ if (result.parseError) {
908
+ res.status(400).json({ message: "Cannot edit: channels file contains invalid JSON.", success: false });
909
+ return;
910
+ }
911
+ // Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet.
912
+ const stored = result.channels[key] ?? {};
913
+ const clearValue = isPredefinedChannel(key) ? null : undefined;
914
+ if (field === "channelNumber") {
915
+ stored.channelNumber = value ? parseInt(value, 10) : clearValue;
916
+ }
917
+ else if (field === "hdhrEnabled") {
918
+ // hdhrEnabled: "true" means enabled (clear the override), "false" means disabled. For predefined channels, null clears the delta. For user channels,
919
+ // delete the field entirely to revert to the default (included).
920
+ stored.hdhrEnabled = (value === "false") ? false : clearValue;
921
+ }
922
+ else {
923
+ stored.stationId = value || clearValue;
924
+ }
925
+ result.channels[key] = stored;
926
+ await saveUserChannels(result.channels);
927
+ const fieldLabels = { channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID" };
928
+ const fieldLabel = fieldLabels[field] ?? field;
929
+ const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
930
+ LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
931
+ const playlistHint = (field === "hdhrEnabled") ? "" : PLAYLIST_HINT;
932
+ res.json({ key, message: fieldLabel + " updated." + playlistHint, patch: buildChannelTablePatch([key], profiles), success: true });
552
933
  return;
553
934
  }
554
935
  // Handle delete action.
@@ -570,12 +951,10 @@ export function setupChannelRoutes(app) {
570
951
  Reflect.deleteProperty(result.channels, key);
571
952
  await saveUserChannels(result.channels);
572
953
  LOG.info("User channel '%s' deleted.", key);
573
- // If a predefined channel exists with the same key, generate its HTML so the client can replace the user channel row with the predefined version instead of
574
- // just removing it. Without this, deleting a user override of a predefined channel would leave the predefined channel invisible until a page refresh.
575
- const predefined = isPredefinedChannel(key) ? generateChannelRowHtml(key, profiles) : undefined;
576
- // Return success response with key for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
577
- res.json({ html: predefined, key,
578
- message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT, success: true });
954
+ // Return a patch. If a predefined channel exists with the same key, the patch includes an "update" row for the predefined version (so the client replaces
955
+ // the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
956
+ res.json({ key, message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT,
957
+ patch: buildChannelTablePatch([key], profiles), success: true });
579
958
  return;
580
959
  }
581
960
  // Handle add and edit actions.
@@ -597,21 +976,11 @@ export function setupChannelRoutes(app) {
597
976
  const stationId = sanitizeString(body.stationId ?? "");
598
977
  const channelSelector = sanitizeString(body.channelSelector ?? "");
599
978
  const channelNumberStr = sanitizeString(body.channelNumber ?? "");
979
+ const hdhrEnabled = body.hdhrEnabled !== "false";
600
980
  // Validate channel number if provided.
601
- if (channelNumberStr) {
602
- const num = parseInt(channelNumberStr, 10);
603
- if (Number.isNaN(num) || (num < 1) || (num > 99999)) {
604
- formErrors.channelNumber = "Channel number must be between 1 and 99999.";
605
- }
606
- else {
607
- // Check for duplicate channel numbers across all resolved channels.
608
- for (const entry of getChannelListing()) {
609
- if ((entry.channel.channelNumber === num) && (entry.key !== key)) {
610
- formErrors.channelNumber = "Channel number " + String(num) + " is already used by '" + entry.key + "'.";
611
- break;
612
- }
613
- }
614
- }
981
+ const channelNumberError = validateChannelNumber(channelNumberStr, key);
982
+ if (channelNumberError) {
983
+ formErrors.channelNumber = channelNumberError;
615
984
  }
616
985
  // Validate key (only for add action, not edit).
617
986
  if (action === "add") {
@@ -657,19 +1026,23 @@ export function setupChannelRoutes(app) {
657
1026
  const predefinedBase = getPredefinedChannel(key);
658
1027
  if ((action === "edit") && predefinedBase) {
659
1028
  // Build a record of submitted form values keyed by channel property name. This record drives both the displayChannel comparison and the predefined delta
660
- // computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined.
1029
+ // computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined; hdhrEnabled uses boolean.
661
1030
  const formValues = {
662
1031
  channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
663
1032
  channelSelector,
1033
+ hdhrEnabled,
664
1034
  name,
665
1035
  profile,
666
1036
  stationId,
667
1037
  url
668
1038
  };
669
1039
  // Helper to read a comparable value from a Channel object. String fields default to "" when undefined so they match the form's empty-string representation.
670
- // channelNumber stays as number | undefined since the form value uses the same representation.
1040
+ // channelNumber stays as number | undefined since the form value uses the same representation. hdhrEnabled defaults to true when absent.
671
1041
  const channelValue = (ch, field) => {
672
1042
  const val = ch[field];
1043
+ if (field === "hdhrEnabled") {
1044
+ return val !== false;
1045
+ }
673
1046
  return (field === "channelNumber") ? val : val ?? "";
674
1047
  };
675
1048
  // First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
@@ -702,9 +1075,8 @@ export function setupChannelRoutes(app) {
702
1075
  Reflect.deleteProperty(result.channels, key);
703
1076
  await saveUserChannels(result.channels);
704
1077
  LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
705
- const rowHtml = generateChannelRowHtml(key, profiles);
706
- res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
707
- message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint, success: true });
1078
+ res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
1079
+ patch: buildChannelTablePatch([key], profiles), success: true });
708
1080
  return;
709
1081
  }
710
1082
  res.json({ key, message: "No changes to save.", success: true });
@@ -738,9 +1110,8 @@ export function setupChannelRoutes(app) {
738
1110
  setProviderSelection(key, matchedVariantKey);
739
1111
  await saveUserChannels(result.channels);
740
1112
  LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
741
- const rowHtml = generateChannelRowHtml(key, profiles);
742
- res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
743
- message: "Channel '" + key + "' reverted to defaults." + variantRevertHint, success: true });
1113
+ res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
1114
+ patch: buildChannelTablePatch([key], profiles), success: true });
744
1115
  return;
745
1116
  }
746
1117
  // No variant match — store the delta and switch the provider selection to the canonical key (the custom override). This ensures the provider dropdown shows
@@ -768,6 +1139,10 @@ export function setupChannelRoutes(app) {
768
1139
  if (channelNumberStr) {
769
1140
  channel.channelNumber = parseInt(channelNumberStr, 10);
770
1141
  }
1142
+ // Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
1143
+ if (!hdhrEnabled) {
1144
+ channel.hdhrEnabled = false;
1145
+ }
771
1146
  result.channels[key] = channel;
772
1147
  // For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
773
1148
  playlistChanged = (action === "add") ||
@@ -775,18 +1150,21 @@ export function setupChannelRoutes(app) {
775
1150
  M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
776
1151
  }
777
1152
  await saveUserChannels(result.channels);
1153
+ // Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
1154
+ if (stationId) {
1155
+ updateChannelLogo(name || key, stationId);
1156
+ }
778
1157
  const actionLabel = (action === "add") ? "added" : "updated";
779
1158
  LOG.info("User channel '%s' %s.", key, actionLabel);
780
1159
  // Generate HTML for the channel row so the client can update the DOM without a full page reload.
781
- const rowHtml = generateChannelRowHtml(key, profiles);
782
1160
  // Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
783
1161
  const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
784
- // Return success response with HTML for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
1162
+ // Return success response with patch for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
785
1163
  res.json({
786
- html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow },
787
1164
  isNew: action === "add",
788
1165
  key,
789
1166
  message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
1167
+ patch: buildChannelTablePatch([key], profiles),
790
1168
  success: true
791
1169
  });
792
1170
  }