prismcast 1.6.0 → 1.8.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 (229) hide show
  1. package/dist/app.js +9 -8
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/cdp.js +1 -1
  4. package/dist/browser/cdp.js.map +1 -1
  5. package/dist/browser/channelSelection.d.ts +10 -34
  6. package/dist/browser/channelSelection.js +19 -96
  7. package/dist/browser/channelSelection.js.map +1 -1
  8. package/dist/browser/display.d.ts +19 -0
  9. package/dist/browser/display.js +16 -11
  10. package/dist/browser/display.js.map +1 -1
  11. package/dist/browser/index.d.ts +9 -46
  12. package/dist/browser/index.js +136 -191
  13. package/dist/browser/index.js.map +1 -1
  14. package/dist/browser/login.d.ts +65 -0
  15. package/dist/browser/login.js +196 -0
  16. package/dist/browser/login.js.map +1 -0
  17. package/dist/browser/precaching.js +12 -0
  18. package/dist/browser/precaching.js.map +1 -1
  19. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  20. package/dist/browser/tuning/comcastPolymer.js +655 -0
  21. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  22. package/dist/browser/tuning/cox.d.ts +2 -0
  23. package/dist/browser/tuning/cox.js +14 -0
  24. package/dist/browser/tuning/cox.js.map +1 -0
  25. package/dist/browser/tuning/directv.js +1 -1
  26. package/dist/browser/tuning/directv.js.map +1 -1
  27. package/dist/browser/tuning/fox.js +2 -2
  28. package/dist/browser/tuning/fox.js.map +1 -1
  29. package/dist/browser/tuning/hbo.js +1 -1
  30. package/dist/browser/tuning/hbo.js.map +1 -1
  31. package/dist/browser/tuning/hulu.js +1 -1
  32. package/dist/browser/tuning/hulu.js.map +1 -1
  33. package/dist/browser/tuning/shared.d.ts +46 -0
  34. package/dist/browser/tuning/shared.js +100 -0
  35. package/dist/browser/tuning/shared.js.map +1 -0
  36. package/dist/browser/tuning/sling.js +1 -1
  37. package/dist/browser/tuning/sling.js.map +1 -1
  38. package/dist/browser/tuning/spectrum.js +1 -1
  39. package/dist/browser/tuning/spectrum.js.map +1 -1
  40. package/dist/browser/tuning/thumbnailRow.js +1 -1
  41. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  42. package/dist/browser/tuning/tileClick.js +1 -1
  43. package/dist/browser/tuning/tileClick.js.map +1 -1
  44. package/dist/browser/tuning/xfinity.js +10 -603
  45. package/dist/browser/tuning/xfinity.js.map +1 -1
  46. package/dist/browser/tuning/youtubeTv.js +1 -1
  47. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  48. package/dist/browser/video.d.ts +18 -0
  49. package/dist/browser/video.js +69 -107
  50. package/dist/browser/video.js.map +1 -1
  51. package/dist/channels/index.d.ts +4 -2
  52. package/dist/channels/index.js +2261 -800
  53. package/dist/channels/index.js.map +1 -1
  54. package/dist/config/health.js +30 -17
  55. package/dist/config/health.js.map +1 -1
  56. package/dist/config/index.js +37 -63
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/profiles.js +10 -10
  59. package/dist/config/profiles.js.map +1 -1
  60. package/dist/config/providerPacks.js +4 -1
  61. package/dist/config/providerPacks.js.map +1 -1
  62. package/dist/config/providers.d.ts +12 -5
  63. package/dist/config/providers.js +133 -104
  64. package/dist/config/providers.js.map +1 -1
  65. package/dist/config/sites.js +15 -9
  66. package/dist/config/sites.js.map +1 -1
  67. package/dist/config/userChannels.d.ts +108 -7
  68. package/dist/config/userChannels.js +374 -44
  69. package/dist/config/userChannels.js.map +1 -1
  70. package/dist/config/userConfig.d.ts +3 -1
  71. package/dist/config/userConfig.js +31 -19
  72. package/dist/config/userConfig.js.map +1 -1
  73. package/dist/config/userProfiles.js +27 -39
  74. package/dist/config/userProfiles.js.map +1 -1
  75. package/dist/hdhr/channelMap.d.ts +1 -1
  76. package/dist/hdhr/channelMap.js +4 -4
  77. package/dist/hdhr/channelMap.js.map +1 -1
  78. package/dist/hdhr/discover.js +7 -15
  79. package/dist/hdhr/discover.js.map +1 -1
  80. package/dist/index.js +9 -15
  81. package/dist/index.js.map +1 -1
  82. package/dist/native/index.d.ts +3 -0
  83. package/dist/native/index.js +16 -25
  84. package/dist/native/index.js.map +1 -1
  85. package/dist/native/probe.d.ts +4 -3
  86. package/dist/native/probe.js +36 -12
  87. package/dist/native/probe.js.map +1 -1
  88. package/dist/native/proxy.d.ts +2 -0
  89. package/dist/native/proxy.js +369 -326
  90. package/dist/native/proxy.js.map +1 -1
  91. package/dist/routes/assets.js +26 -48
  92. package/dist/routes/assets.js.map +1 -1
  93. package/dist/routes/channels.js +5 -11
  94. package/dist/routes/channels.js.map +1 -1
  95. package/dist/routes/components.d.ts +43 -8
  96. package/dist/routes/components.js +122 -19
  97. package/dist/routes/components.js.map +1 -1
  98. package/dist/routes/config/channels/index.d.ts +2 -2
  99. package/dist/routes/config/channels/index.js +1 -1
  100. package/dist/routes/config/channels/index.js.map +1 -1
  101. package/dist/routes/config/channels/routes.js +749 -71
  102. package/dist/routes/config/channels/routes.js.map +1 -1
  103. package/dist/routes/config/channels/table.d.ts +93 -2
  104. package/dist/routes/config/channels/table.js +594 -104
  105. package/dist/routes/config/channels/table.js.map +1 -1
  106. package/dist/routes/config/index.d.ts +1 -1
  107. package/dist/routes/config/index.js +1 -1
  108. package/dist/routes/config/index.js.map +1 -1
  109. package/dist/routes/config/providers.d.ts +6 -6
  110. package/dist/routes/config/providers.js +76 -102
  111. package/dist/routes/config/providers.js.map +1 -1
  112. package/dist/routes/config/settings.js +57 -36
  113. package/dist/routes/config/settings.js.map +1 -1
  114. package/dist/routes/icons.d.ts +16 -0
  115. package/dist/routes/icons.js +44 -0
  116. package/dist/routes/icons.js.map +1 -0
  117. package/dist/routes/index.d.ts +0 -16
  118. package/dist/routes/index.js +0 -17
  119. package/dist/routes/index.js.map +1 -1
  120. package/dist/routes/playlist.d.ts +6 -4
  121. package/dist/routes/playlist.js +68 -27
  122. package/dist/routes/playlist.js.map +1 -1
  123. package/dist/routes/providers.js +106 -3
  124. package/dist/routes/providers.js.map +1 -1
  125. package/dist/routes/root/content.js +96 -40
  126. package/dist/routes/root/content.js.map +1 -1
  127. package/dist/routes/root/index.js +2 -1
  128. package/dist/routes/root/index.js.map +1 -1
  129. package/dist/routes/root/scripts/channels.js +955 -310
  130. package/dist/routes/root/scripts/channels.js.map +1 -1
  131. package/dist/routes/root/scripts/config.js +695 -569
  132. package/dist/routes/root/scripts/config.js.map +1 -1
  133. package/dist/routes/root/scripts/index.d.ts +1 -0
  134. package/dist/routes/root/scripts/index.js +1 -0
  135. package/dist/routes/root/scripts/index.js.map +1 -1
  136. package/dist/routes/root/scripts/shared.d.ts +6 -0
  137. package/dist/routes/root/scripts/shared.js +437 -0
  138. package/dist/routes/root/scripts/shared.js.map +1 -0
  139. package/dist/routes/root/scripts/status.js +33 -24
  140. package/dist/routes/root/scripts/status.js.map +1 -1
  141. package/dist/routes/root/styles.js +157 -11
  142. package/dist/routes/root/styles.js.map +1 -1
  143. package/dist/routes/ui.d.ts +1 -1
  144. package/dist/routes/ui.js +1 -1
  145. package/dist/routes/ui.js.map +1 -1
  146. package/dist/service/commands.js +4 -35
  147. package/dist/service/commands.js.map +1 -1
  148. package/dist/streaming/codec.d.ts +20 -0
  149. package/dist/streaming/codec.js +68 -0
  150. package/dist/streaming/codec.js.map +1 -0
  151. package/dist/streaming/fmp4Segmenter.d.ts +2 -0
  152. package/dist/streaming/fmp4Segmenter.js +7 -4
  153. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  154. package/dist/streaming/hls.js +274 -188
  155. package/dist/streaming/hls.js.map +1 -1
  156. package/dist/streaming/hlsResume.d.ts +9 -3
  157. package/dist/streaming/hlsResume.js +15 -8
  158. package/dist/streaming/hlsResume.js.map +1 -1
  159. package/dist/streaming/hlsSegments.d.ts +1 -2
  160. package/dist/streaming/hlsSegments.js +48 -46
  161. package/dist/streaming/hlsSegments.js.map +1 -1
  162. package/dist/streaming/lifecycle.js +8 -12
  163. package/dist/streaming/lifecycle.js.map +1 -1
  164. package/dist/streaming/monitor.js +625 -585
  165. package/dist/streaming/monitor.js.map +1 -1
  166. package/dist/streaming/mpegts.js +1 -1
  167. package/dist/streaming/mpegts.js.map +1 -1
  168. package/dist/streaming/preroll.d.ts +41 -20
  169. package/dist/streaming/preroll.js +157 -76
  170. package/dist/streaming/preroll.js.map +1 -1
  171. package/dist/streaming/recovery.d.ts +0 -6
  172. package/dist/streaming/recovery.js +0 -8
  173. package/dist/streaming/recovery.js.map +1 -1
  174. package/dist/streaming/registry.d.ts +36 -9
  175. package/dist/streaming/registry.js +6 -9
  176. package/dist/streaming/registry.js.map +1 -1
  177. package/dist/streaming/setup.d.ts +3 -3
  178. package/dist/streaming/setup.js +38 -22
  179. package/dist/streaming/setup.js.map +1 -1
  180. package/dist/streaming/showInfo.d.ts +7 -6
  181. package/dist/streaming/showInfo.js +156 -36
  182. package/dist/streaming/showInfo.js.map +1 -1
  183. package/dist/streaming/statusEmitter.d.ts +14 -2
  184. package/dist/streaming/statusEmitter.js +22 -4
  185. package/dist/streaming/statusEmitter.js.map +1 -1
  186. package/dist/types/channels.d.ts +34 -0
  187. package/dist/types/config.d.ts +3 -2
  188. package/dist/types/index.d.ts +3 -2
  189. package/dist/types/index.js +1 -0
  190. package/dist/types/index.js.map +1 -1
  191. package/dist/types/profiles.d.ts +8 -3
  192. package/dist/types/selection.d.ts +1 -0
  193. package/dist/types/shared.d.ts +1 -1
  194. package/dist/types/streaming.d.ts +7 -15
  195. package/dist/types/streaming.js +5 -1
  196. package/dist/types/streaming.js.map +1 -1
  197. package/dist/upgrade/commands.js +1 -16
  198. package/dist/upgrade/commands.js.map +1 -1
  199. package/dist/utils/cliOutput.d.ts +10 -0
  200. package/dist/utils/cliOutput.js +17 -0
  201. package/dist/utils/cliOutput.js.map +1 -0
  202. package/dist/utils/debugFilter.d.ts +0 -5
  203. package/dist/utils/debugFilter.js +5 -11
  204. package/dist/utils/debugFilter.js.map +1 -1
  205. package/dist/utils/delay.d.ts +12 -0
  206. package/dist/utils/delay.js +20 -0
  207. package/dist/utils/delay.js.map +1 -1
  208. package/dist/utils/evaluate.js +6 -11
  209. package/dist/utils/evaluate.js.map +1 -1
  210. package/dist/utils/ffmpeg.d.ts +13 -5
  211. package/dist/utils/ffmpeg.js +32 -11
  212. package/dist/utils/ffmpeg.js.map +1 -1
  213. package/dist/utils/format.d.ts +20 -3
  214. package/dist/utils/format.js +32 -6
  215. package/dist/utils/format.js.map +1 -1
  216. package/dist/utils/index.d.ts +2 -0
  217. package/dist/utils/index.js +2 -0
  218. package/dist/utils/index.js.map +1 -1
  219. package/dist/utils/logEmitter.js +1 -1
  220. package/dist/utils/logEmitter.js.map +1 -1
  221. package/dist/utils/retry.d.ts +15 -9
  222. package/dist/utils/retry.js +8 -26
  223. package/dist/utils/retry.js.map +1 -1
  224. package/dist/utils/timing.d.ts +5 -0
  225. package/dist/utils/timing.js +13 -0
  226. package/dist/utils/timing.js.map +1 -0
  227. package/dist/utils/version.js +2 -2
  228. package/dist/utils/version.js.map +1 -1
  229. package/package.json +5 -5
@@ -1,10 +1,12 @@
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";
1
+ import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString, stringifySorted } from "../../../utils/index.js";
2
+ import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../../../channels/index.js";
3
+ import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState, generateTagFilterContent, generateTagManagerBody } from "./table.js";
4
+ import { VALID_SORT_FIELDS, compareChannelSort, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
4
5
  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";
6
+ import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getTagRegistry, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveTagRegistry, saveUserChannels, setTagRegistry, transformChannelTags, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
6
7
  import { CONFIG } from "../../../config/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.
@@ -33,7 +77,7 @@ export function setupChannelRoutes(app) {
33
77
  }
34
78
  res.setHeader("Content-Type", "application/json");
35
79
  res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
36
- res.send(JSON.stringify(resolved, null, 2) + "\n");
80
+ res.send(stringifySorted(resolved) + "\n");
37
81
  }
38
82
  catch (error) {
39
83
  LOG.error("Failed to export channels: %s.", formatError(error));
@@ -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. When
250
+ // canonicalKey is provided, the channel is a provider variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
251
+ // at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
252
+ function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
253
+ const channel = {
254
+ ...(canonicalKey ? { canonicalKey } : { name: channelName }),
255
+ channelSelector: selector || undefined,
256
+ url: channelUrl
257
+ };
258
+ if (!canonicalKey && entry.stationId) {
259
+ channel.stationId = sanitizeString(entry.stationId);
260
+ }
261
+ return channel;
262
+ }
263
+ for (const entry of channels) {
264
+ const action = entry.action ?? "add";
265
+ const name = sanitizeString(entry.name?.trim() ?? "");
266
+ const providerSlug = entry.providerSlug?.trim() ?? "";
267
+ // Handle enable and switch. Enable re-enables a disabled predefined channel, then falls through to the switch logic to set the provider selection.
268
+ // Switch changes the provider selection for an existing channel to point to the browsed provider's variant, creating the variant if needed.
269
+ if ((action === "switch") || (action === "enable")) {
270
+ const canonicalKey = entry.canonicalKey?.trim() ?? "";
271
+ if (!canonicalKey) {
272
+ errors.push("Entry for '" + name + "' missing canonical key.");
273
+ continue;
274
+ }
275
+ // Re-enable the channel if it was disabled. Must complete before setting the provider selection since the config write makes the channel
276
+ // visible in the lineup.
277
+ if (action === "enable") {
278
+ // eslint-disable-next-line no-await-in-loop -- Sequential: config state must be updated before setting provider selection.
279
+ await enablePredefinedChannels([canonicalKey]);
280
+ }
281
+ const variantKey = canonicalKey + "-" + providerSlug;
282
+ // If no variant exists for this provider, create one as a user channel. The canonicalKey parameter tells buildUserChannel to produce a variant
283
+ // (provider-specific fields only, no identity fields).
284
+ if (!allKeys.has(variantKey)) {
285
+ existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""), canonicalKey);
286
+ allKeys.add(variantKey);
287
+ channelsChanged = true;
288
+ }
289
+ setProviderSelection(canonicalKey, variantKey);
290
+ selectionsChanged = true;
291
+ switched++;
292
+ affectedKeys.add(canonicalKey);
293
+ continue;
294
+ }
295
+ // Handle provider removal. Reverts the channel away from this provider using a three-tier fallback: (1) clear the selection and let
296
+ // resolveProviderKey find the next enabled variant or canonical, (2) if the resolved provider is still this provider (no alternative exists),
297
+ // disable the predefined channel or delete the user channel.
298
+ if (action === "remove") {
299
+ const canonicalKey = entry.canonicalKey?.trim() ?? "";
300
+ if (!canonicalKey) {
301
+ errors.push("Remove entry for '" + name + "' missing canonical key.");
302
+ continue;
303
+ }
304
+ // Clear the provider selection. resolveProviderKey will now return the canonical or the first enabled alternative variant.
305
+ setProviderSelection(canonicalKey, canonicalKey);
306
+ selectionsChanged = true;
307
+ // Check if the resolved provider is still this provider. If so, there is no alternative — disable or delete the channel.
308
+ const resolvedKey = resolveProviderKey(canonicalKey);
309
+ const resolvedTag = getProviderTagForChannel(resolvedKey);
310
+ if (resolvedTag === providerSlug) {
311
+ // No alternative provider exists. Disable predefined channels or delete user channels.
312
+ if (isPredefinedChannel(canonicalKey)) {
313
+ disablePredefined.add(canonicalKey);
314
+ }
315
+ else {
316
+ Reflect.deleteProperty(existingChannels, canonicalKey);
317
+ channelsChanged = true;
318
+ }
319
+ }
320
+ removed++;
321
+ affectedKeys.add(canonicalKey);
322
+ continue;
323
+ }
324
+ // 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
325
+ // existing canonical). Channels that match existing canonicals appear as 'switch' state in the modal instead.
326
+ const url = sanitizeString(entry.url?.trim() ?? "");
327
+ const channelSelector = sanitizeString(entry.channelSelector?.trim() ?? "");
328
+ if (!name) {
329
+ errors.push("Channel entry missing name.");
330
+ continue;
331
+ }
332
+ if (!url) {
333
+ errors.push("Channel '" + name + "' missing URL.");
334
+ continue;
335
+ }
336
+ const urlError = validateChannelUrl(url);
337
+ if (urlError) {
338
+ errors.push("Channel '" + name + "': " + urlError);
339
+ continue;
340
+ }
341
+ const baseKey = generateChannelKey(name);
342
+ if (!baseKey) {
343
+ errors.push("Could not generate key for channel '" + name + "'.");
344
+ continue;
345
+ }
346
+ const canonicalExists = allKeys.has(baseKey);
347
+ const key = (canonicalExists && providerSlug) ? baseKey + "-" + providerSlug : baseKey;
348
+ // Skip channels that already exist in the lineup.
349
+ if (allKeys.has(key)) {
350
+ continue;
351
+ }
352
+ // When the key differs from baseKey, this is a provider variant — pass baseKey as canonicalKey so buildUserChannel produces a variant with only
353
+ // provider-specific fields. Standalone channels (key === baseKey) get the full channel object with identity fields.
354
+ const newChannel = buildUserChannel(entry, name, url, channelSelector, (key !== baseKey) ? baseKey : undefined);
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,168 @@ 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. Null signals "clear this field" — the normalizer in saveUserChannels() handles the storage conventions.
575
+ for (const entry of listing) {
576
+ const existing = result.channels[entry.key] ?? {};
577
+ existing.channelNumber = null;
578
+ result.channels[entry.key] = existing;
579
+ affectedKeys.push(entry.key);
580
+ }
581
+ }
582
+ else {
583
+ // Assign sequential numbers starting from the requested start value.
584
+ for (let i = 0; i < listing.length; i++) {
585
+ const entry = listing[i];
586
+ const num = start + i;
587
+ if (num > 99999) {
588
+ break;
589
+ }
590
+ const existing = result.channels[entry.key] ?? {};
591
+ existing.channelNumber = num;
592
+ result.channels[entry.key] = existing;
593
+ affectedKeys.push(entry.key);
594
+ }
595
+ }
596
+ await saveUserChannels(result.channels);
597
+ const action = clearMode ? "Cleared" : "Numbered";
598
+ LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
599
+ const profiles = getProfiles();
600
+ const message = clearMode ?
601
+ "Cleared channel numbers from " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT :
602
+ "Numbered " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT;
603
+ res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
604
+ }
605
+ catch (error) {
606
+ LOG.error("Failed to auto-number channels: %s.", formatError(error));
607
+ res.status(500).json({ error: "Failed to auto-number channels: " + formatError(error), success: false });
608
+ }
609
+ });
610
+ // POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
611
+ // cleared). When false, all channels are excluded (hdhrEnabled set to false).
612
+ app.post("/config/channels/hdhr-bulk", async (req, res) => {
613
+ try {
614
+ const body = req.body;
615
+ const enable = body.enable === true;
616
+ // Get visible channels (enabled + available by provider filter).
617
+ const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByProvider);
618
+ // Load user channels for modification.
619
+ const result = await loadUserChannels();
620
+ if (result.parseError) {
621
+ res.status(400).json({ error: "Cannot update HDHR settings: channels file contains invalid JSON.", success: false });
622
+ return;
623
+ }
624
+ const affectedKeys = [];
625
+ for (const entry of listing) {
626
+ const current = entry.channel.hdhrEnabled !== false;
627
+ // Skip channels that already match the target state.
628
+ if (current === enable) {
629
+ continue;
630
+ }
631
+ const existing = result.channels[entry.key] ?? {};
632
+ existing.hdhrEnabled = enable ? null : false;
633
+ result.channels[entry.key] = existing;
634
+ affectedKeys.push(entry.key);
635
+ }
636
+ if (affectedKeys.length === 0) {
637
+ res.json({ affected: 0, message: "No changes needed.", success: true });
638
+ return;
639
+ }
640
+ await saveUserChannels(result.channels);
641
+ const action = enable ? "included in" : "excluded from";
642
+ LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
643
+ const profiles = getProfiles();
644
+ const message = String(affectedKeys.length) + " channel(s) " + action + " the HDHomeRun lineup.";
645
+ res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
646
+ }
647
+ catch (error) {
648
+ LOG.error("Failed to bulk-toggle HDHR: %s.", formatError(error));
649
+ res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
650
+ }
651
+ });
652
+ // POST /config/channels/bulk-tags - Add or remove a tag on all enabled, provider-available channels. Operates on the same channel set as other bulk actions.
653
+ // transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
654
+ // modal body.
655
+ app.post("/config/channels/bulk-tags", async (req, res) => {
656
+ try {
657
+ const body = req.body;
658
+ const action = body.action;
659
+ const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
660
+ if ((action !== "add") && (action !== "remove")) {
661
+ res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
662
+ return;
663
+ }
664
+ if (!tag) {
665
+ res.status(400).json({ error: "Tag is required.", success: false });
666
+ return;
667
+ }
668
+ // Validate the tag is in the active vocabulary.
669
+ const vocabulary = new Set(getActiveTagVocabulary());
670
+ if (!vocabulary.has(tag)) {
671
+ res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
672
+ return;
673
+ }
674
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.enabled && entry.availableByProvider, (tags) => (action === "add") ? (tags.includes(tag) ? tags : [...tags, tag]) : tags.filter((t) => t !== tag));
675
+ if (error) {
676
+ res.status(400).json({ error, success: false });
677
+ return;
678
+ }
679
+ if (affectedKeys.length === 0) {
680
+ res.json({ affected: 0, message: "No changes needed.", success: true });
681
+ return;
682
+ }
683
+ const verb = (action === "add") ? "added to" : "removed from";
684
+ LOG.info("Bulk tag %s: %s on %d channels.", action, tag, affectedKeys.length);
685
+ const profiles = getProfiles();
686
+ const message = "Tag '" + tag + "' " + verb + " " + String(affectedKeys.length) + " channel(s).";
687
+ res.json({
688
+ affected: affectedKeys.length,
689
+ filterContent: generateTagFilterContent(),
690
+ message,
691
+ modalBody: generateTagManagerBody(),
692
+ patch: buildChannelTablePatch(affectedKeys, profiles),
693
+ success: true
694
+ });
695
+ }
696
+ catch (error) {
697
+ LOG.error("Failed to bulk-update tags: %s.", formatError(error));
698
+ res.status(500).json({ error: "Failed to update tags: " + formatError(error), success: false });
699
+ }
700
+ });
342
701
  // POST /config/provider-filter - Update the provider filter (enabled provider tags).
343
702
  app.post("/config/provider-filter", async (req, res) => {
344
703
  try {
@@ -370,13 +729,33 @@ export function setupChannelRoutes(app) {
370
729
  userConfig.channels.enabledProviders = tags;
371
730
  await saveUserConfig(filterDefaults(userConfig));
372
731
  LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
373
- res.json({ enabledProviders: tags, success: true });
732
+ // Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
733
+ const { counts, scopeCounts } = buildChannelTableState();
734
+ res.json({ enabledProviders: tags, patch: { counts, rows: [], scopeCounts }, success: true });
374
735
  }
375
736
  catch (error) {
376
737
  LOG.error("Failed to update provider filter: %s.", formatError(error));
377
738
  res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
378
739
  }
379
740
  });
741
+ // POST /config/channels/setup-completed - Mark the Provider Setup flow as completed. Called when the wizard finishes or the user explicitly skips.
742
+ app.post("/config/channels/setup-completed", async (_req, res) => {
743
+ try {
744
+ CONFIG.channels.setupCompleted = true;
745
+ const configResult = await loadUserConfig();
746
+ configResult.config.channels ??= {};
747
+ configResult.config.channels.setupCompleted = true;
748
+ await saveUserConfig(configResult.config);
749
+ // 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
750
+ // via the modify endpoint, and the provider filter may have changed — the patch ensures the table reflects the current state.
751
+ const { counts, scopeCounts } = buildChannelTableState();
752
+ res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
753
+ }
754
+ catch (error) {
755
+ LOG.error("Failed to save setup completed state: %s.", formatError(error));
756
+ res.status(500).json({ error: "Failed to save setup state: " + formatError(error), success: false });
757
+ }
758
+ });
380
759
  // POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
381
760
  app.post("/config/channels/display-prefs", async (req, res) => {
382
761
  try {
@@ -545,10 +924,64 @@ export function setupChannelRoutes(app) {
545
924
  Reflect.deleteProperty(result.channels, key);
546
925
  await saveUserChannels(result.channels);
547
926
  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 });
927
+ res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
928
+ patch: buildChannelTablePatch([key], profiles), success: true });
929
+ return;
930
+ }
931
+ // 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
932
+ // number, station ID, and HDHR lineup toggles.
933
+ if (action === "inline-edit") {
934
+ if (!key) {
935
+ res.status(400).json({ message: "Channel key is required.", success: false });
936
+ return;
937
+ }
938
+ const field = body.field;
939
+ const value = sanitizeString(body.value ?? "");
940
+ if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId") && (field !== "tags")) {
941
+ res.status(400).json({ message: "Invalid inline-edit field.", success: false });
942
+ return;
943
+ }
944
+ // Validate channel number if the field is channelNumber.
945
+ if (field === "channelNumber") {
946
+ const numberError = validateChannelNumber(value, key);
947
+ if (numberError) {
948
+ res.status(400).json({ message: numberError, success: false });
949
+ return;
950
+ }
951
+ }
952
+ // Load and update the channel. For predefined channels, store a delta override with null to clear fields. For user channels, use undefined to delete.
953
+ const result = await loadUserChannels();
954
+ if (result.parseError) {
955
+ res.status(400).json({ message: "Cannot edit: channels file contains invalid JSON.", success: false });
956
+ return;
957
+ }
958
+ // Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet. Null signals
959
+ // "clear this field" for all channel types — the normalizer in saveUserChannels() handles delta comparison for predefined channels and null-stripping for
960
+ // user channels.
961
+ const stored = result.channels[key] ?? {};
962
+ if (field === "channelNumber") {
963
+ stored.channelNumber = value ? parseInt(value, 10) : null;
964
+ }
965
+ else if (field === "hdhrEnabled") {
966
+ stored.hdhrEnabled = (value === "false") ? false : null;
967
+ }
968
+ else if (field === "tags") {
969
+ const tags = value ? [...new Set(value.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
970
+ stored.tags = (tags.length > 0) ? tags : null;
971
+ }
972
+ else {
973
+ stored.stationId = value || null;
974
+ }
975
+ result.channels[key] = stored;
976
+ await saveUserChannels(result.channels);
977
+ const fieldLabels = {
978
+ channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
979
+ };
980
+ const fieldLabel = fieldLabels[field] ?? field;
981
+ const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
982
+ LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
983
+ const playlistHint = (field === "hdhrEnabled") ? "" : PLAYLIST_HINT;
984
+ res.json({ key, message: fieldLabel + " updated." + playlistHint, patch: buildChannelTablePatch([key], profiles), success: true });
552
985
  return;
553
986
  }
554
987
  // Handle delete action.
@@ -570,12 +1003,10 @@ export function setupChannelRoutes(app) {
570
1003
  Reflect.deleteProperty(result.channels, key);
571
1004
  await saveUserChannels(result.channels);
572
1005
  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 });
1006
+ // 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
1007
+ // the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
1008
+ res.json({ key, message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT,
1009
+ patch: buildChannelTablePatch([key], profiles), success: true });
579
1010
  return;
580
1011
  }
581
1012
  // Handle add and edit actions.
@@ -597,21 +1028,14 @@ export function setupChannelRoutes(app) {
597
1028
  const stationId = sanitizeString(body.stationId ?? "");
598
1029
  const channelSelector = sanitizeString(body.channelSelector ?? "");
599
1030
  const channelNumberStr = sanitizeString(body.channelNumber ?? "");
1031
+ const hdhrEnabled = body.hdhrEnabled !== "false";
1032
+ // Parse tags from comma-separated input. Trim, lowercase, filter empty, sort, deduplicate.
1033
+ const tagsRaw = sanitizeString(body.tags ?? "");
1034
+ const tags = tagsRaw ? [...new Set(tagsRaw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
600
1035
  // 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
- }
1036
+ const channelNumberError = validateChannelNumber(channelNumberStr, key);
1037
+ if (channelNumberError) {
1038
+ formErrors.channelNumber = channelNumberError;
615
1039
  }
616
1040
  // Validate key (only for add action, not edit).
617
1041
  if (action === "add") {
@@ -657,32 +1081,41 @@ export function setupChannelRoutes(app) {
657
1081
  const predefinedBase = getPredefinedChannel(key);
658
1082
  if ((action === "edit") && predefinedBase) {
659
1083
  // 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.
1084
+ // computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined; hdhrEnabled uses boolean.
661
1085
  const formValues = {
662
1086
  channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
663
1087
  channelSelector,
1088
+ hdhrEnabled,
664
1089
  name,
665
1090
  profile,
666
1091
  stationId,
667
1092
  url
668
1093
  };
669
1094
  // 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.
1095
+ // channelNumber stays as number | undefined since the form value uses the same representation. hdhrEnabled defaults to true when absent.
671
1096
  const channelValue = (ch, field) => {
672
1097
  const val = ch[field];
1098
+ if (field === "hdhrEnabled") {
1099
+ return val !== false;
1100
+ }
673
1101
  return (field === "channelNumber") ? val : val ?? "";
674
1102
  };
675
1103
  // 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
676
1104
  // may differ from the canonical predefined base when a variant is selected (e.g., the Hulu variant has a different URL and channelSelector). If the submitted
677
1105
  // values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
1106
+ // Tags are compared separately (array comparison) after the scalar fields.
678
1107
  const resolvedKey = resolveProviderKey(key);
679
1108
  const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
680
- if (Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field))) {
1109
+ const displayTags = getChannelEffectiveTags(displayChannel);
1110
+ const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
1111
+ const tagsUnchanged = JSON.stringify(tags) === JSON.stringify(displayTags);
1112
+ if (scalarUnchanged && tagsUnchanged) {
681
1113
  res.json({ key, message: "No changes to save.", success: true });
682
1114
  return;
683
1115
  }
684
1116
  // Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
685
- // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
1117
+ // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear). Tags are handled
1118
+ // separately from the scalar loop since they require array comparison.
686
1119
  const delta = {};
687
1120
  let hasChanges = false;
688
1121
  for (const field of Object.keys(formValues)) {
@@ -692,8 +1125,17 @@ export function setupChannelRoutes(app) {
692
1125
  hasChanges = true;
693
1126
  }
694
1127
  }
695
- // Helper to check if form values match a given channel's properties.
696
- const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field));
1128
+ // Tags delta: compare the submitted tags against the predefined base's vocabulary-filtered tags. Using effective tags (not raw) ensures that editing an
1129
+ // unrelated field while a predefined tag is deleted from the vocabulary doesn't spuriously bake the deletion into the channel's stored delta.
1130
+ const predefinedEffectiveTags = getChannelEffectiveTags(predefinedBase);
1131
+ if (JSON.stringify(tags) !== JSON.stringify(predefinedEffectiveTags)) {
1132
+ delta.tags = (tags.length > 0) ? tags : null;
1133
+ hasChanges = true;
1134
+ }
1135
+ // Helper to check if form values (scalars + tags) match a given channel's properties. Tags use vocabulary-filtered comparison so variant matching works
1136
+ // correctly when predefined tags are deleted from the vocabulary.
1137
+ const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
1138
+ (JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
697
1139
  if (!hasChanges) {
698
1140
  // The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away — treat it as an
699
1141
  // implicit revert by removing the override and returning the predefined row HTML.
@@ -702,9 +1144,8 @@ export function setupChannelRoutes(app) {
702
1144
  Reflect.deleteProperty(result.channels, key);
703
1145
  await saveUserChannels(result.channels);
704
1146
  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 });
1147
+ res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
1148
+ patch: buildChannelTablePatch([key], profiles), success: true });
708
1149
  return;
709
1150
  }
710
1151
  res.json({ key, message: "No changes to save.", success: true });
@@ -738,9 +1179,8 @@ export function setupChannelRoutes(app) {
738
1179
  setProviderSelection(key, matchedVariantKey);
739
1180
  await saveUserChannels(result.channels);
740
1181
  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 });
1182
+ res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
1183
+ patch: buildChannelTablePatch([key], profiles), success: true });
744
1184
  return;
745
1185
  }
746
1186
  // 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 +1208,13 @@ export function setupChannelRoutes(app) {
768
1208
  if (channelNumberStr) {
769
1209
  channel.channelNumber = parseInt(channelNumberStr, 10);
770
1210
  }
1211
+ if (tags.length > 0) {
1212
+ channel.tags = tags;
1213
+ }
1214
+ // Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
1215
+ if (!hdhrEnabled) {
1216
+ channel.hdhrEnabled = false;
1217
+ }
771
1218
  result.channels[key] = channel;
772
1219
  // For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
773
1220
  playlistChanged = (action === "add") ||
@@ -775,18 +1222,21 @@ export function setupChannelRoutes(app) {
775
1222
  M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
776
1223
  }
777
1224
  await saveUserChannels(result.channels);
1225
+ // Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
1226
+ if (stationId) {
1227
+ updateChannelLogo(name || key, stationId);
1228
+ }
778
1229
  const actionLabel = (action === "add") ? "added" : "updated";
779
1230
  LOG.info("User channel '%s' %s.", key, actionLabel);
780
1231
  // 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
1232
  // Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
783
1233
  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().
1234
+ // Return success response with patch for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
785
1235
  res.json({
786
- html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow },
787
1236
  isNew: action === "add",
788
1237
  key,
789
1238
  message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
1239
+ patch: buildChannelTablePatch([key], profiles),
790
1240
  success: true
791
1241
  });
792
1242
  }
@@ -795,5 +1245,233 @@ export function setupChannelRoutes(app) {
795
1245
  res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
796
1246
  }
797
1247
  });
1248
+ // Tag Management Endpoints.
1249
+ // GET /config/tags - Returns the tag vocabulary and registry state. The active vocabulary is the computed merge of predefined tags (minus deleted) plus user
1250
+ // tags. The registry contains the raw user state for the tag management UI.
1251
+ app.get("/config/tags", (_req, res) => {
1252
+ res.json({
1253
+ active: getActiveTagVocabulary(),
1254
+ predefined: [...PREDEFINED_TAGS],
1255
+ registry: getTagRegistry(),
1256
+ success: true
1257
+ });
1258
+ });
1259
+ // POST /config/tags - Create a new user tag. Validates the tag name (lowercase alphanumeric + hyphens, max 30 chars) and checks for duplicates against the
1260
+ // active vocabulary and deleted predefined tags (which should be restored, not re-created).
1261
+ app.post("/config/tags", async (req, res) => {
1262
+ try {
1263
+ const body = req.body;
1264
+ const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
1265
+ if (tag.length === 0) {
1266
+ res.status(400).json({ error: "Tag name is required.", success: false });
1267
+ return;
1268
+ }
1269
+ if (tag.length > 30) {
1270
+ res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1271
+ return;
1272
+ }
1273
+ if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(tag)) {
1274
+ res.status(400).json({ error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false });
1275
+ return;
1276
+ }
1277
+ // Check if the tag already exists in the active vocabulary.
1278
+ const vocabulary = new Set(getActiveTagVocabulary());
1279
+ if (vocabulary.has(tag)) {
1280
+ res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
1281
+ return;
1282
+ }
1283
+ // Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
1284
+ const registry = getTagRegistry();
1285
+ if (registry.deletedTags.includes(tag)) {
1286
+ res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
1287
+ return;
1288
+ }
1289
+ // Add the tag to the user registry and persist. No channels are affected (new tag has no assignments yet).
1290
+ registry.tags.push(tag);
1291
+ setTagRegistry(registry);
1292
+ await saveTagRegistry();
1293
+ LOG.info("Created tag: %s.", tag);
1294
+ res.json({
1295
+ active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1296
+ registry: getTagRegistry(), success: true
1297
+ });
1298
+ }
1299
+ catch (error) {
1300
+ LOG.error("Failed to create tag: %s.", formatError(error));
1301
+ res.status(500).json({ error: "Failed to create tag: " + formatError(error), success: false });
1302
+ }
1303
+ });
1304
+ // DELETE /config/tags/:tag - Delete a tag from the vocabulary and cascade to all channel assignments. For predefined tags, the tag is added to deletedTags. For
1305
+ // user-created tags, the tag is removed from the user registry. In both cases, the tag is stripped from every channel that has it — predefined channels via delta
1306
+ // override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
1307
+ app.delete("/config/tags/:tag", async (req, res) => {
1308
+ try {
1309
+ const tag = req.params.tag?.toLowerCase() ?? "";
1310
+ if (tag.length === 0) {
1311
+ res.status(400).json({ error: "Tag name is required.", success: false });
1312
+ return;
1313
+ }
1314
+ const registry = getTagRegistry();
1315
+ const isPredefined = PREDEFINED_TAGS.includes(tag);
1316
+ const isUserTag = registry.tags.includes(tag);
1317
+ if (!isPredefined && !isUserTag) {
1318
+ res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
1319
+ return;
1320
+ }
1321
+ if (isPredefined) {
1322
+ // Already deleted — no-op, return current state without unnecessary I/O.
1323
+ if (registry.deletedTags.includes(tag)) {
1324
+ res.json({
1325
+ active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1326
+ registry: getTagRegistry(), success: true
1327
+ });
1328
+ return;
1329
+ }
1330
+ registry.deletedTags.push(tag);
1331
+ }
1332
+ else {
1333
+ // Remove from user tags.
1334
+ registry.tags = registry.tags.filter((t) => t !== tag);
1335
+ }
1336
+ setTagRegistry(registry);
1337
+ // Cascade: strip the deleted tag from all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
1338
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(tag) === true, (tags) => tags.filter((t) => t !== tag));
1339
+ if (error) {
1340
+ res.status(400).json({ error, success: false });
1341
+ return;
1342
+ }
1343
+ LOG.info("Deleted tag '%s' from vocabulary and %d channel assignments.", tag, affectedKeys.length);
1344
+ const profiles = getProfiles();
1345
+ res.json({
1346
+ active: getActiveTagVocabulary(),
1347
+ filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1348
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1349
+ registry: getTagRegistry(),
1350
+ success: true
1351
+ });
1352
+ }
1353
+ catch (error) {
1354
+ LOG.error("Failed to delete tag: %s.", formatError(error));
1355
+ res.status(500).json({ error: "Failed to delete tag: " + formatError(error), success: false });
1356
+ }
1357
+ });
1358
+ // POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
1359
+ // cascade-restores the tag on predefined channels whose definition includes it. This reverses the cascade delete — predefined channels go back to their default
1360
+ // tag assignments. User channels are not affected (their tag assignments were permanently removed during cascade delete with no source of truth to restore from).
1361
+ app.post("/config/tags/restore", async (req, res) => {
1362
+ try {
1363
+ const body = req.body;
1364
+ const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
1365
+ if (tag.length === 0) {
1366
+ res.status(400).json({ error: "Tag name is required.", success: false });
1367
+ return;
1368
+ }
1369
+ const registry = getTagRegistry();
1370
+ if (!registry.deletedTags.includes(tag)) {
1371
+ res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
1372
+ return;
1373
+ }
1374
+ registry.deletedTags = registry.deletedTags.filter((t) => t !== tag);
1375
+ setTagRegistry(registry);
1376
+ // Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
1377
+ // delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
1378
+ // predefined definition, cleanly reverting the channel to its default state.
1379
+ const { affectedKeys, error } = await transformChannelTags((entry) => {
1380
+ const predefined = getPredefinedChannel(entry.key);
1381
+ return (predefined?.tags?.includes(tag) === true) && (entry.channel.tags?.includes(tag) !== true);
1382
+ }, (tags) => [...tags, tag]);
1383
+ if (error) {
1384
+ res.status(400).json({ error, success: false });
1385
+ return;
1386
+ }
1387
+ LOG.info("Restored predefined tag '%s' on %d channels.", tag, affectedKeys.length);
1388
+ const profiles = getProfiles();
1389
+ res.json({
1390
+ active: getActiveTagVocabulary(),
1391
+ filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1392
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1393
+ registry: getTagRegistry(),
1394
+ success: true
1395
+ });
1396
+ }
1397
+ catch (error) {
1398
+ LOG.error("Failed to restore tag: %s.", formatError(error));
1399
+ res.status(500).json({ error: "Failed to restore tag: " + formatError(error), success: false });
1400
+ }
1401
+ });
1402
+ // POST /config/tags/rename - Rename a tag atomically across the vocabulary and all channel assignments. For predefined tags, the old name is added to
1403
+ // deletedTags and the new name is added to user tags. For user tags, the old name is replaced with the new name. All channels with the old tag get the
1404
+ // new tag substituted in their stored data (predefined channels via delta, user channels via direct modification).
1405
+ app.post("/config/tags/rename", async (req, res) => {
1406
+ try {
1407
+ const body = req.body;
1408
+ const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim().toLowerCase() : "";
1409
+ const newTag = typeof body.newTag === "string" ? body.newTag.trim().toLowerCase() : "";
1410
+ if (!oldTag || !newTag) {
1411
+ res.status(400).json({ error: "Both old and new tag names are required.", success: false });
1412
+ return;
1413
+ }
1414
+ if (oldTag === newTag) {
1415
+ res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
1416
+ return;
1417
+ }
1418
+ if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(newTag)) {
1419
+ res.status(400).json({
1420
+ error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false
1421
+ });
1422
+ return;
1423
+ }
1424
+ if (newTag.length > 30) {
1425
+ res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1426
+ return;
1427
+ }
1428
+ // Validate old tag exists in the active vocabulary.
1429
+ const vocabulary = new Set(getActiveTagVocabulary());
1430
+ if (!vocabulary.has(oldTag)) {
1431
+ res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
1432
+ return;
1433
+ }
1434
+ // Validate new tag doesn't already exist.
1435
+ if (vocabulary.has(newTag)) {
1436
+ res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
1437
+ return;
1438
+ }
1439
+ // Update the tag registry: replace old with new in the appropriate list.
1440
+ const registry = getTagRegistry();
1441
+ const oldIsPredefined = PREDEFINED_TAGS.includes(oldTag);
1442
+ if (oldIsPredefined) {
1443
+ // Predefined tag: "delete" the old (add to deletedTags) and create the new as a user tag.
1444
+ if (!registry.deletedTags.includes(oldTag)) {
1445
+ registry.deletedTags.push(oldTag);
1446
+ }
1447
+ registry.tags.push(newTag);
1448
+ }
1449
+ else {
1450
+ // User tag: replace in the user tags list.
1451
+ registry.tags = registry.tags.map((t) => (t === oldTag) ? newTag : t);
1452
+ }
1453
+ setTagRegistry(registry);
1454
+ // Cascade: substitute the old tag with the new tag across all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
1455
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(oldTag) === true, (tags) => tags.map((t) => (t === oldTag) ? newTag : t));
1456
+ if (error) {
1457
+ res.status(400).json({ error, success: false });
1458
+ return;
1459
+ }
1460
+ LOG.info("Renamed tag '%s' to '%s' across %d channels.", oldTag, newTag, affectedKeys.length);
1461
+ const profiles = getProfiles();
1462
+ res.json({
1463
+ active: getActiveTagVocabulary(),
1464
+ filterContent: generateTagFilterContent(),
1465
+ modalBody: generateTagManagerBody(),
1466
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1467
+ registry: getTagRegistry(),
1468
+ success: true
1469
+ });
1470
+ }
1471
+ catch (error) {
1472
+ LOG.error("Failed to rename tag: %s.", formatError(error));
1473
+ res.status(500).json({ error: "Failed to rename tag: " + formatError(error), success: false });
1474
+ }
1475
+ });
798
1476
  }
799
1477
  //# sourceMappingURL=routes.js.map