prismcast 1.8.0 → 1.9.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 (168) hide show
  1. package/README.md +6 -6
  2. package/dist/app.js +15 -5
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +4 -6
  5. package/dist/browser/cdp.js +13 -17
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.js +3 -3
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/precaching.d.ts +1 -1
  10. package/dist/browser/precaching.js +23 -23
  11. package/dist/browser/precaching.js.map +1 -1
  12. package/dist/browser/tuning/directv.js +4 -7
  13. package/dist/browser/tuning/directv.js.map +1 -1
  14. package/dist/browser/tuning/fox.js +1 -1
  15. package/dist/browser/tuning/fox.js.map +1 -1
  16. package/dist/browser/tuning/hbo.js +1 -1
  17. package/dist/browser/tuning/hbo.js.map +1 -1
  18. package/dist/browser/tuning/hulu.js +3 -3
  19. package/dist/browser/tuning/hulu.js.map +1 -1
  20. package/dist/browser/tuning/sling.js +2 -2
  21. package/dist/browser/tuning/sling.js.map +1 -1
  22. package/dist/channels/index.d.ts +1 -1
  23. package/dist/channels/index.js +416 -416
  24. package/dist/channels/index.js.map +1 -1
  25. package/dist/config/health.d.ts +3 -3
  26. package/dist/config/health.js +3 -3
  27. package/dist/config/index.js +2 -2
  28. package/dist/config/index.js.map +1 -1
  29. package/dist/config/persistence.d.ts +59 -0
  30. package/dist/config/persistence.js +127 -0
  31. package/dist/config/persistence.js.map +1 -0
  32. package/dist/config/servicePacks.d.ts +46 -0
  33. package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
  34. package/dist/config/servicePacks.js.map +1 -0
  35. package/dist/config/services.d.ts +202 -0
  36. package/dist/config/{providers.js → services.js} +277 -210
  37. package/dist/config/services.js.map +1 -0
  38. package/dist/config/sites.js +61 -61
  39. package/dist/config/sites.js.map +1 -1
  40. package/dist/config/userChannels.d.ts +36 -27
  41. package/dist/config/userChannels.js +300 -212
  42. package/dist/config/userChannels.js.map +1 -1
  43. package/dist/config/userConfig.d.ts +11 -9
  44. package/dist/config/userConfig.js +53 -60
  45. package/dist/config/userConfig.js.map +1 -1
  46. package/dist/config/userProfiles.d.ts +31 -15
  47. package/dist/config/userProfiles.js +139 -96
  48. package/dist/config/userProfiles.js.map +1 -1
  49. package/dist/hdhr/channelMap.d.ts +1 -1
  50. package/dist/hdhr/channelMap.js +4 -7
  51. package/dist/hdhr/channelMap.js.map +1 -1
  52. package/dist/hdhr/index.js +6 -9
  53. package/dist/hdhr/index.js.map +1 -1
  54. package/dist/native/index.js +3 -3
  55. package/dist/native/index.js.map +1 -1
  56. package/dist/native/intercept.js +86 -89
  57. package/dist/native/intercept.js.map +1 -1
  58. package/dist/native/probe.js +2 -2
  59. package/dist/native/probe.js.map +1 -1
  60. package/dist/native/proxy.js +4 -4
  61. package/dist/native/proxy.js.map +1 -1
  62. package/dist/routes/assets.js +3 -3
  63. package/dist/routes/assets.js.map +1 -1
  64. package/dist/routes/auth.js +3 -3
  65. package/dist/routes/auth.js.map +1 -1
  66. package/dist/routes/config/channels/index.d.ts +1 -1
  67. package/dist/routes/config/channels/index.js +1 -1
  68. package/dist/routes/config/channels/index.js.map +1 -1
  69. package/dist/routes/config/channels/routes.js +488 -460
  70. package/dist/routes/config/channels/routes.js.map +1 -1
  71. package/dist/routes/config/channels/table.d.ts +4 -4
  72. package/dist/routes/config/channels/table.js +206 -147
  73. package/dist/routes/config/channels/table.js.map +1 -1
  74. package/dist/routes/config/index.d.ts +2 -2
  75. package/dist/routes/config/index.js +3 -3
  76. package/dist/routes/config/index.js.map +1 -1
  77. package/dist/routes/config/{providers.js → services.js} +44 -39
  78. package/dist/routes/config/services.js.map +1 -0
  79. package/dist/routes/config/settings.js +47 -29
  80. package/dist/routes/config/settings.js.map +1 -1
  81. package/dist/routes/debug.js +5 -7
  82. package/dist/routes/debug.js.map +1 -1
  83. package/dist/routes/health.js +1 -1
  84. package/dist/routes/health.js.map +1 -1
  85. package/dist/routes/index.js +2 -2
  86. package/dist/routes/index.js.map +1 -1
  87. package/dist/routes/logs.js +4 -4
  88. package/dist/routes/logs.js.map +1 -1
  89. package/dist/routes/playlist.d.ts +4 -4
  90. package/dist/routes/playlist.js +56 -44
  91. package/dist/routes/playlist.js.map +1 -1
  92. package/dist/routes/root/content.js +58 -58
  93. package/dist/routes/root/content.js.map +1 -1
  94. package/dist/routes/root/scripts/channels.js +529 -572
  95. package/dist/routes/root/scripts/channels.js.map +1 -1
  96. package/dist/routes/root/scripts/config.js +838 -992
  97. package/dist/routes/root/scripts/config.js.map +1 -1
  98. package/dist/routes/root/scripts/shared.js +431 -197
  99. package/dist/routes/root/scripts/shared.js.map +1 -1
  100. package/dist/routes/root/scripts/status.js +247 -196
  101. package/dist/routes/root/scripts/status.js.map +1 -1
  102. package/dist/routes/root/styles.js +12 -4
  103. package/dist/routes/root/styles.js.map +1 -1
  104. package/dist/routes/services.d.ts +6 -0
  105. package/dist/routes/{providers.js → services.js} +43 -43
  106. package/dist/routes/services.js.map +1 -0
  107. package/dist/routes/ui.js +2 -0
  108. package/dist/routes/ui.js.map +1 -1
  109. package/dist/service/commands.js +5 -8
  110. package/dist/service/commands.js.map +1 -1
  111. package/dist/service/generators.d.ts +1 -1
  112. package/dist/service/generators.js +143 -41
  113. package/dist/service/generators.js.map +1 -1
  114. package/dist/streaming/hls.d.ts +4 -4
  115. package/dist/streaming/hls.js +24 -23
  116. package/dist/streaming/hls.js.map +1 -1
  117. package/dist/streaming/hlsResume.js +1 -1
  118. package/dist/streaming/hlsResume.js.map +1 -1
  119. package/dist/streaming/monitor.d.ts +2 -2
  120. package/dist/streaming/monitor.js +19 -13
  121. package/dist/streaming/monitor.js.map +1 -1
  122. package/dist/streaming/preroll.js +1 -1
  123. package/dist/streaming/preroll.js.map +1 -1
  124. package/dist/streaming/registry.js +4 -8
  125. package/dist/streaming/registry.js.map +1 -1
  126. package/dist/streaming/setup.d.ts +2 -2
  127. package/dist/streaming/setup.js +23 -22
  128. package/dist/streaming/setup.js.map +1 -1
  129. package/dist/streaming/showInfo.js +8 -10
  130. package/dist/streaming/showInfo.js.map +1 -1
  131. package/dist/streaming/statusEmitter.d.ts +4 -3
  132. package/dist/streaming/statusEmitter.js +3 -3
  133. package/dist/streaming/statusEmitter.js.map +1 -1
  134. package/dist/types/channels.d.ts +15 -11
  135. package/dist/types/config.d.ts +2 -2
  136. package/dist/types/index.d.ts +2 -2
  137. package/dist/types/profiles.d.ts +9 -9
  138. package/dist/types/selection.d.ts +1 -1
  139. package/dist/types/shared.d.ts +1 -1
  140. package/dist/types/streaming.d.ts +1 -1
  141. package/dist/utils/chromeFetch.js +1 -1
  142. package/dist/utils/debugFilter.js +3 -3
  143. package/dist/utils/debugFilter.js.map +1 -1
  144. package/dist/utils/fileLogger.js +4 -3
  145. package/dist/utils/fileLogger.js.map +1 -1
  146. package/dist/utils/format.d.ts +7 -1
  147. package/dist/utils/format.js +21 -1
  148. package/dist/utils/format.js.map +1 -1
  149. package/dist/utils/logger.js +17 -27
  150. package/dist/utils/logger.js.map +1 -1
  151. package/dist/utils/morganStream.js +2 -2
  152. package/dist/utils/morganStream.js.map +1 -1
  153. package/dist/utils/platform.js +3 -2
  154. package/dist/utils/platform.js.map +1 -1
  155. package/dist/utils/streamContext.d.ts +7 -0
  156. package/dist/utils/streamContext.js +10 -1
  157. package/dist/utils/streamContext.js.map +1 -1
  158. package/dist/utils/version.js +4 -4
  159. package/dist/utils/version.js.map +1 -1
  160. package/package.json +5 -7
  161. package/dist/config/providerPacks.d.ts +0 -46
  162. package/dist/config/providerPacks.js.map +0 -1
  163. package/dist/config/providers.d.ts +0 -175
  164. package/dist/config/providers.js.map +0 -1
  165. package/dist/routes/config/providers.js.map +0 -1
  166. package/dist/routes/providers.d.ts +0 -6
  167. package/dist/routes/providers.js.map +0 -1
  168. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -1,15 +1,48 @@
1
1
  import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString, stringifySorted } from "../../../utils/index.js";
2
2
  import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../../../channels/index.js";
3
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";
5
- import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.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";
4
+ import { VALID_SORT_FIELDS, compareChannelSort, getAllServiceTags, getCanonicalKey, getChannelServiceLabel, getEnabledServices, getResolvedChannel, getServiceDisplayName, getServiceGroup, getServiceSelection, getServiceTagForChannel, isServiceTagEnabled, resolvePredefinedVariant, resolveServiceKey, setEnabledServices, setServiceSelection } from "../../../config/services.js";
5
+ import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getTagRegistry, getUserChannels, isPredefinedChannel, isUserChannel, mutateChannels, resolveStoredChannel, saveServiceSelections, saveTagRegistry, setTagRegistry, tagsMatch, transformChannelTags, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
7
6
  import { CONFIG } from "../../../config/index.js";
7
+ import { FileStoreParseError } from "../../../config/persistence.js";
8
+ import { getDomainConfig } from "../../../config/sites.js";
8
9
  import { getProfiles } from "../../../config/profiles.js";
10
+ import { mutateConfig } from "../../../config/userConfig.js";
9
11
  import { updateChannelLogo } from "../../../streaming/showInfo.js";
12
+ /**
13
+ * Sends an appropriate error response for a caught error. FileStoreParseError (corrupt JSON file) produces a 400 with the parse error details. All other errors
14
+ * produce a 500 with the formatted error message. This is the single error response path for all channel route handlers.
15
+ * @param res - The Express response object.
16
+ * @param error - The caught error.
17
+ * @param action - Human-readable description of the failed action for the log message (e.g., "import channels", "toggle channel").
18
+ */
19
+ function sendErrorResponse(res, error, action) {
20
+ if (error instanceof FileStoreParseError) {
21
+ res.status(400).json({ error: error.message, success: false });
22
+ return;
23
+ }
24
+ LOG.error("Failed to %s: %s.", action, formatError(error));
25
+ res.status(500).json({ error: "Failed to " + action + ": " + formatError(error), success: false });
26
+ }
10
27
  // 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.
11
- const M3U_FIELDS = ["channelNumber", "name", "stationId", "tvgShift"];
28
+ const M3U_FIELDS = ["channelNumber", "guideTitle", "logoUrl", "name", "stationId", "tvgShift"];
12
29
  const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change.";
30
+ /**
31
+ * Builds a service filter warning when a URL's service tag is not in the active filter. Returns undefined when no filter is active, the tag is "direct", or the
32
+ * tag is already enabled. The client shows the returned warning as a toast with a one-click enable action.
33
+ * @param url - The channel URL to derive the service tag from.
34
+ * @returns The warning with service tag and display label, or undefined if no warning is needed.
35
+ */
36
+ function buildServiceFilterWarning(url) {
37
+ if (getEnabledServices().length === 0) {
38
+ return undefined;
39
+ }
40
+ const tag = getDomainConfig(url)?.serviceTag;
41
+ if (tag && (tag !== "direct") && !isServiceTagEnabled(tag)) {
42
+ return { serviceLabel: getServiceDisplayName(url), serviceTag: tag };
43
+ }
44
+ return undefined;
45
+ }
13
46
  /**
14
47
  * Checks whether a stored channel entry contains any fields that affect the M3U playlist. Used to decide whether to append the playlist reload hint when reverting
15
48
  * or removing an override.
@@ -28,17 +61,18 @@ async function disablePredefinedChannels(keys) {
28
61
  if (keys.length === 0) {
29
62
  return;
30
63
  }
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;
64
+ let updatedList = [];
65
+ await mutateConfig((config) => {
66
+ config.channels ??= {};
67
+ config.channels.disabledPredefined ??= [];
68
+ const disabledSet = new Set(config.channels.disabledPredefined);
69
+ for (const key of keys) {
70
+ disabledSet.add(key);
71
+ }
72
+ config.channels.disabledPredefined = [...disabledSet].toSorted();
73
+ updatedList = config.channels.disabledPredefined;
74
+ });
75
+ CONFIG.channels.disabledPredefined = updatedList;
42
76
  }
43
77
  /**
44
78
  * Enables one or more predefined channels by removing their keys from the disabledPredefined list in user config. Updates both the config file and the
@@ -49,17 +83,18 @@ async function enablePredefinedChannels(keys) {
49
83
  if (keys.length === 0) {
50
84
  return;
51
85
  }
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;
86
+ let updatedList = [];
87
+ await mutateConfig((config) => {
88
+ config.channels ??= {};
89
+ config.channels.disabledPredefined ??= [];
90
+ const disabledSet = new Set(config.channels.disabledPredefined);
91
+ for (const key of keys) {
92
+ disabledSet.delete(key);
93
+ }
94
+ config.channels.disabledPredefined = [...disabledSet].toSorted();
95
+ updatedList = config.channels.disabledPredefined;
96
+ });
97
+ CONFIG.channels.disabledPredefined = updatedList;
63
98
  }
64
99
  /**
65
100
  * Installs all channel-related route handlers on the Express application.
@@ -80,8 +115,7 @@ export function setupChannelRoutes(app) {
80
115
  res.send(stringifySorted(resolved) + "\n");
81
116
  }
82
117
  catch (error) {
83
- LOG.error("Failed to export channels: %s.", formatError(error));
84
- res.status(500).json({ error: "Failed to export channels: " + formatError(error) });
118
+ sendErrorResponse(res, error, "export channels");
85
119
  }
86
120
  });
87
121
  // POST /config/channels/import - Import channels from JSON, replacing all existing user channels.
@@ -95,8 +129,13 @@ export function setupChannelRoutes(app) {
95
129
  res.status(400).json({ error: "Validation errors:\n" + validationResult.errors.join("\n") });
96
130
  return;
97
131
  }
98
- // Save the imported channels, replacing all existing user channels.
99
- await saveUserChannels(validationResult.channels);
132
+ // Save the imported channels, replacing all existing user channels. Clear the current map and assign the imported entries.
133
+ await mutateChannels((channels) => {
134
+ for (const key of Object.keys(channels)) {
135
+ Reflect.deleteProperty(channels, key);
136
+ }
137
+ Object.assign(channels, validationResult.channels);
138
+ });
100
139
  const channelCount = Object.keys(validationResult.channels).length;
101
140
  // Return a full table patch since a bulk JSON import replaces all user channels — every row in the listing may have changed.
102
141
  const allKeys = getChannelListing().map((entry) => entry.key);
@@ -104,8 +143,7 @@ export function setupChannelRoutes(app) {
104
143
  patch: buildChannelTablePatch(allKeys, getProfiles()), success: true });
105
144
  }
106
145
  catch (error) {
107
- LOG.error("Failed to import channels: %s.", formatError(error));
108
- res.status(500).json({ error: "Failed to import channels: " + formatError(error) });
146
+ sendErrorResponse(res, error, "import channels");
109
147
  }
110
148
  });
111
149
  // POST /config/channels/import-m3u - Import channels from M3U playlist file.
@@ -134,9 +172,6 @@ export function setupChannelRoutes(app) {
134
172
  });
135
173
  return;
136
174
  }
137
- // Load existing user channels.
138
- const loadResult = await loadUserChannels();
139
- const existingChannels = loadResult.parseError ? {} : loadResult.channels;
140
175
  // Track import statistics.
141
176
  const affectedKeys = [];
142
177
  const conflicts = [];
@@ -145,7 +180,8 @@ export function setupChannelRoutes(app) {
145
180
  let imported = 0;
146
181
  let replaced = 0;
147
182
  let skipped = 0;
148
- // Process each parsed channel. Sanitize string values before validation to strip non-printable characters.
183
+ // Pre-validate and sanitize each parsed channel before entering the mutation. This keeps pure validation outside the serialized write lock.
184
+ const validEntries = [];
149
185
  for (const m3uChannel of parseResult.channels) {
150
186
  m3uChannel.name = sanitizeString(m3uChannel.name);
151
187
  m3uChannel.url = sanitizeString(m3uChannel.url);
@@ -174,19 +210,6 @@ export function setupChannelRoutes(app) {
174
210
  importErrors.push("Channel '" + m3uChannel.name + "': " + nameError);
175
211
  continue;
176
212
  }
177
- // Check for conflicts with existing channels.
178
- if (key in existingChannels) {
179
- conflicts.push(key);
180
- if (conflictMode === "skip") {
181
- skipped++;
182
- continue;
183
- }
184
- // Replace mode - count as replaced instead of imported.
185
- replaced++;
186
- }
187
- else {
188
- imported++;
189
- }
190
213
  // Build the channel object.
191
214
  const channel = {
192
215
  name: m3uChannel.name,
@@ -196,12 +219,29 @@ export function setupChannelRoutes(app) {
196
219
  if (m3uChannel.stationId) {
197
220
  channel.stationId = m3uChannel.stationId;
198
221
  }
199
- // Add to channels collection.
200
- existingChannels[key] = channel;
201
- affectedKeys.push(key);
222
+ validEntries.push({ channel, key });
202
223
  }
203
- // Save the updated channels.
204
- await saveUserChannels(existingChannels);
224
+ // Apply the validated entries inside the transactional mutation. Conflict detection happens against the current stored channels.
225
+ await mutateChannels((channels) => {
226
+ for (const { channel, key } of validEntries) {
227
+ // Check for conflicts with existing channels.
228
+ if (key in channels) {
229
+ conflicts.push(key);
230
+ if (conflictMode === "skip") {
231
+ skipped++;
232
+ continue;
233
+ }
234
+ // Replace mode - count as replaced instead of imported.
235
+ replaced++;
236
+ }
237
+ else {
238
+ imported++;
239
+ }
240
+ // Add to channels collection.
241
+ channels[key] = channel;
242
+ affectedKeys.push(key);
243
+ }
244
+ });
205
245
  // Log the import.
206
246
  LOG.info("M3U import completed: %d imported, %d replaced, %d skipped.", imported, replaced, skipped);
207
247
  // Build response with patch for in-place UI update.
@@ -216,13 +256,12 @@ export function setupChannelRoutes(app) {
216
256
  });
217
257
  }
218
258
  catch (error) {
219
- LOG.error("Failed to import M3U channels: %s.", formatError(error));
220
- res.status(500).json({ error: "Failed to import channels: " + formatError(error), success: false });
259
+ sendErrorResponse(res, error, "import channels");
221
260
  }
222
261
  });
223
262
  // 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
263
+ // re-enables a disabled predefined channel and sets its service, 'switch' changes the service selection for an existing channel, and 'remove' reverts a
264
+ // channel to its canonical service (disabling it if no alternative service is available). Channel writes and service selection changes are batched into
226
265
  // single saves to avoid redundant file I/O.
227
266
  app.post("/config/channels/modify", async (req, res) => {
228
267
  try {
@@ -233,21 +272,17 @@ export function setupChannelRoutes(app) {
233
272
  res.status(400).json({ error: "No channels provided.", success: false });
234
273
  return;
235
274
  }
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
275
  const affectedKeys = new Set();
242
276
  const disablePredefined = new Set();
243
277
  const errors = [];
244
278
  let added = 0;
245
279
  let removed = 0;
246
280
  let switched = 0;
247
- let channelsChanged = false;
248
- let selectionsChanged = false;
281
+ // Mutable flags tracking whether channels or selections were modified. Declared as a state object rather than bare booleans so that TypeScript's
282
+ // control-flow narrowing does not produce false positives when reading the flags after the mutateChannels callback (which mutates them via closure).
283
+ const modified = { channels: false, selections: false };
249
284
  // 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
285
+ // canonicalKey is provided, the channel is a service variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
251
286
  // at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
252
287
  function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
253
288
  const channel = {
@@ -260,113 +295,117 @@ export function setupChannelRoutes(app) {
260
295
  }
261
296
  return channel;
262
297
  }
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.");
298
+ // Process all entries inside a single transactional mutation to avoid TOCTOU races between load and save.
299
+ await mutateChannels((existingChannels) => {
300
+ // Build a set of all existing channel keys (predefined + user) for deduplication.
301
+ const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(existingChannels)]);
302
+ for (const entry of channels) {
303
+ const action = entry.action ?? "add";
304
+ const name = sanitizeString(entry.name?.trim() ?? "");
305
+ const serviceSlug = entry.serviceSlug?.trim() ?? "";
306
+ // Handle enable and switch. Enable re-enables a disabled predefined channel, then falls through to the switch logic to set the service selection.
307
+ // Switch changes the service selection for an existing channel to point to the browsed service's variant, creating the variant if needed.
308
+ if ((action === "switch") || (action === "enable")) {
309
+ const canonicalKey = entry.canonicalKey?.trim() ?? "";
310
+ if (!canonicalKey) {
311
+ errors.push("Entry for '" + name + "' missing canonical key.");
312
+ continue;
313
+ }
314
+ const variantKey = canonicalKey + "-" + serviceSlug;
315
+ // If no variant exists for this service, create one as a user channel. The canonicalKey parameter tells buildUserChannel to produce a variant
316
+ // (service-specific fields only, no identity fields).
317
+ if (!allKeys.has(variantKey)) {
318
+ existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""), canonicalKey);
319
+ allKeys.add(variantKey);
320
+ modified.channels = true;
321
+ }
322
+ setServiceSelection(canonicalKey, variantKey);
323
+ modified.selections = true;
324
+ switched++;
325
+ affectedKeys.add(canonicalKey);
273
326
  continue;
274
327
  }
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]);
328
+ // Handle service removal. Reverts the channel away from this service using a three-tier fallback: (1) clear the selection and let
329
+ // resolveServiceKey find the next enabled variant or canonical, (2) if the resolved service is still this service (no alternative exists),
330
+ // disable the predefined channel or delete the user channel.
331
+ if (action === "remove") {
332
+ const canonicalKey = entry.canonicalKey?.trim() ?? "";
333
+ if (!canonicalKey) {
334
+ errors.push("Remove entry for '" + name + "' missing canonical key.");
335
+ continue;
336
+ }
337
+ // Clear the service selection. resolveServiceKey will now return the canonical or the first enabled alternative variant.
338
+ setServiceSelection(canonicalKey, canonicalKey);
339
+ modified.selections = true;
340
+ // Check if the resolved service is still this service. If so, there is no alternative — disable or delete the channel.
341
+ const resolvedKey = resolveServiceKey(canonicalKey);
342
+ const resolvedTag = getServiceTagForChannel(resolvedKey);
343
+ if (resolvedTag === serviceSlug) {
344
+ // No alternative service exists. Disable predefined channels or delete user channels.
345
+ if (isPredefinedChannel(canonicalKey)) {
346
+ disablePredefined.add(canonicalKey);
347
+ }
348
+ else {
349
+ Reflect.deleteProperty(existingChannels, canonicalKey);
350
+ modified.channels = true;
351
+ }
352
+ }
353
+ removed++;
354
+ affectedKeys.add(canonicalKey);
355
+ continue;
280
356
  }
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;
357
+ // 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
358
+ // existing canonical). Channels that match existing canonicals appear as 'switch' state in the modal.
359
+ const url = sanitizeString(entry.url?.trim() ?? "");
360
+ const channelSelector = sanitizeString(entry.channelSelector?.trim() ?? "");
361
+ if (!name) {
362
+ errors.push("Channel entry missing name.");
363
+ continue;
288
364
  }
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.");
365
+ if (!url) {
366
+ errors.push("Channel '" + name + "' missing URL.");
302
367
  continue;
303
368
  }
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
- }
369
+ const urlError = validateChannelUrl(url);
370
+ if (urlError) {
371
+ errors.push("Channel '" + name + "': " + urlError);
372
+ continue;
319
373
  }
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;
374
+ const baseKey = generateChannelKey(name);
375
+ if (!baseKey) {
376
+ errors.push("Could not generate key for channel '" + name + "'.");
377
+ continue;
378
+ }
379
+ const canonicalExists = allKeys.has(baseKey);
380
+ const key = (canonicalExists && serviceSlug) ? baseKey + "-" + serviceSlug : baseKey;
381
+ // Skip channels that already exist in the lineup.
382
+ if (allKeys.has(key)) {
383
+ continue;
384
+ }
385
+ // When the key differs from baseKey, this is a service variant — pass baseKey as canonicalKey so buildUserChannel produces a variant with only
386
+ // service-specific fields. Standalone channels (key === baseKey) get the full channel object with identity fields.
387
+ const newChannel = buildUserChannel(entry, name, url, channelSelector, (key !== baseKey) ? baseKey : undefined);
388
+ existingChannels[key] = newChannel;
389
+ allKeys.add(key);
390
+ modified.channels = true;
391
+ added++;
392
+ affectedKeys.add(canonicalExists ? baseKey : key);
351
393
  }
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.
394
+ });
395
+ // Enable predefined channels that had the "enable" action. This must happen after the mutation completes since the config write makes the channel
396
+ // visible in the lineup.
397
+ const enableKeys = channels
398
+ .filter((e) => (e.action === "enable") && Boolean(e.canonicalKey?.trim()))
399
+ .map((e) => e.canonicalKey.trim());
400
+ if (enableKeys.length > 0) {
401
+ await enablePredefinedChannels(enableKeys);
402
+ }
403
+ // Save service selections when selections changed but no channel data was written. When channels were modified, the mutation already persisted
404
+ // selections as part of the channel write.
405
+ if (!modified.channels && modified.selections) {
406
+ await saveServiceSelections();
407
+ }
408
+ // Disable predefined channels that had no alternative service after removal.
370
409
  await disablePredefinedChannels([...disablePredefined]);
371
410
  for (const disabledKey of disablePredefined) {
372
411
  affectedKeys.add(disabledKey);
@@ -384,11 +423,14 @@ export function setupChannelRoutes(app) {
384
423
  }
385
424
  const message = (parts.length > 0 ? parts.join(" ") : "No changes made.") + PLAYLIST_HINT;
386
425
  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 });
426
+ // Check if the browsed service isn't in the active filter. All channels in a browse batch share the same service — derive the tag from the first add entry.
427
+ const firstAddUrl = (added > 0) ? channels.find((e) => (e.action === "add"))?.url : undefined;
428
+ const serviceWarning = firstAddUrl ? buildServiceFilterWarning(firstAddUrl) : undefined;
429
+ res.json({ added, errors, message, patch: buildChannelTablePatch([...affectedKeys], getProfiles()), removed, serviceWarning, success: true,
430
+ switched });
388
431
  }
389
432
  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 });
433
+ sendErrorResponse(res, error, "apply changes");
392
434
  }
393
435
  });
394
436
  // POST /config/channels/toggle-predefined - Toggle a single predefined channel's enabled/disabled state.
@@ -423,57 +465,55 @@ export function setupChannelRoutes(app) {
423
465
  res.json({ enabled, key, patch: buildChannelTablePatch([key], getProfiles()), success: true });
424
466
  }
425
467
  catch (error) {
426
- LOG.error("Failed to toggle predefined channel: %s.", formatError(error));
427
- res.status(500).json({ error: "Failed to toggle channel: " + formatError(error), success: false });
468
+ sendErrorResponse(res, error, "toggle channel");
428
469
  }
429
470
  });
430
- // POST /config/provider - Update provider selection for a multi-provider channel.
431
- app.post("/config/provider", async (req, res) => {
471
+ // POST /config/service - Update service selection for a multi-service channel.
472
+ app.post("/config/service", async (req, res) => {
432
473
  try {
433
474
  const body = req.body;
434
475
  const channelKey = body.channel?.trim();
435
- const providerKey = body.provider?.trim();
476
+ const serviceKey = body.service?.trim();
436
477
  // Validate channel key is provided.
437
478
  if (!channelKey) {
438
479
  res.status(400).json({ error: "Channel key is required.", success: false });
439
480
  return;
440
481
  }
441
- // Validate provider key is provided.
442
- if (!providerKey) {
443
- res.status(400).json({ error: "Provider key is required.", success: false });
482
+ // Validate service key is provided.
483
+ if (!serviceKey) {
484
+ res.status(400).json({ error: "Service key is required.", success: false });
444
485
  return;
445
486
  }
446
487
  // Canonicalize the channel key to ensure selections are stored under the canonical key, not variant keys.
447
488
  const canonicalKey = getCanonicalKey(channelKey);
448
- // Validate the channel has provider options.
449
- const providerGroup = getProviderGroup(canonicalKey);
450
- if (!providerGroup) {
451
- res.status(400).json({ error: "Channel '" + canonicalKey + "' does not have multiple providers.", success: false });
489
+ // Validate the channel has service options.
490
+ const serviceGroup = getServiceGroup(canonicalKey);
491
+ if (!serviceGroup) {
492
+ res.status(400).json({ error: "Channel '" + canonicalKey + "' does not have multiple services.", success: false });
452
493
  return;
453
494
  }
454
- // Validate the provider key is valid for this channel.
455
- const validProviderKeys = providerGroup.variants.map((v) => v.key);
456
- if (!validProviderKeys.includes(providerKey)) {
457
- res.status(400).json({ error: "Invalid provider '" + providerKey + "' for channel '" + canonicalKey + "'.", success: false });
495
+ // Validate the service key is valid for this channel.
496
+ const validServiceKeys = serviceGroup.variants.map((v) => v.key);
497
+ if (!validServiceKeys.includes(serviceKey)) {
498
+ res.status(400).json({ error: "Invalid service '" + serviceKey + "' for channel '" + canonicalKey + "'.", success: false });
458
499
  return;
459
500
  }
460
- // Update the provider selection.
461
- setProviderSelection(canonicalKey, providerKey);
501
+ // Update the service selection.
502
+ setServiceSelection(canonicalKey, serviceKey);
462
503
  // Save to disk.
463
- await saveProviderSelections();
504
+ await saveServiceSelections();
464
505
  // Resolve display names for logging before generating the row HTML.
465
506
  const canonicalChannel = getResolvedChannel(canonicalKey);
466
- const variantChannel = getResolvedChannel(providerKey);
507
+ const variantChannel = getResolvedChannel(serviceKey);
467
508
  const channelName = canonicalChannel?.name ?? canonicalKey;
468
- const providerLabel = variantChannel ? getChannelProviderLabel(variantChannel) : providerKey;
469
- LOG.info("Provider for %s changed to %s.", channelName, providerLabel);
509
+ const serviceLabel = variantChannel ? getChannelServiceLabel(variantChannel) : serviceKey;
510
+ LOG.info("Service for %s changed to %s.", channelName, serviceLabel);
470
511
  // Return a channel table patch so the client can update the row and summary counts in place.
471
512
  const profiles = getProfiles();
472
- res.json({ channel: canonicalKey, patch: buildChannelTablePatch([canonicalKey], profiles), provider: providerKey, success: true });
513
+ res.json({ channel: canonicalKey, patch: buildChannelTablePatch([canonicalKey], profiles), service: serviceKey, success: true });
473
514
  }
474
515
  catch (error) {
475
- LOG.error("Failed to update provider selection: %s.", formatError(error));
476
- res.status(500).json({ error: "Failed to update provider: " + formatError(error), success: false });
516
+ sendErrorResponse(res, error, "update service");
477
517
  }
478
518
  });
479
519
  // POST /config/channels/bulk-toggle-predefined - Toggle predefined channels by scope (all, pacific, east).
@@ -508,43 +548,41 @@ export function setupChannelRoutes(app) {
508
548
  break;
509
549
  }
510
550
  }
511
- // Load current config.
512
- const configResult = await loadUserConfig();
513
- const userConfig = configResult.config;
514
- // Initialize channels.disabledPredefined if not present.
515
- userConfig.channels ??= {};
551
+ let updatedList = [];
516
552
  const targetSet = new Set(targetKeys);
517
- if (enabled && (scope === "all")) {
518
- // Enable all is a full reset — clear the entire disabled list.
519
- userConfig.channels.disabledPredefined = [];
520
- }
521
- else if (enabled) {
522
- // Scoped enable: remove target keys from the disabled list (subtractive — preserves other disabled channels).
523
- userConfig.channels.disabledPredefined = (userConfig.channels.disabledPredefined ?? []).filter((k) => !targetSet.has(k));
524
- }
525
- else {
526
- // Disable: add target keys to the disabled list (additive — preserves other disabled channels).
527
- const existing = new Set(userConfig.channels.disabledPredefined ?? []);
528
- for (const k of targetKeys) {
529
- existing.add(k);
553
+ await mutateConfig((config) => {
554
+ config.channels ??= {};
555
+ if (enabled && (scope === "all")) {
556
+ // Enable all is a full reset - clear the entire disabled list.
557
+ config.channels.disabledPredefined = [];
530
558
  }
531
- userConfig.channels.disabledPredefined = [...existing].sort();
532
- }
533
- await saveUserConfig(userConfig);
559
+ else if (enabled) {
560
+ // Scoped enable: remove target keys from the disabled list (subtractive - preserves other disabled channels).
561
+ config.channels.disabledPredefined = (config.channels.disabledPredefined ?? []).filter((k) => !targetSet.has(k));
562
+ }
563
+ else {
564
+ // Disable: add target keys to the disabled list (additive - preserves other disabled channels).
565
+ const existing = new Set(config.channels.disabledPredefined ?? []);
566
+ for (const k of targetKeys) {
567
+ existing.add(k);
568
+ }
569
+ config.channels.disabledPredefined = [...existing].toSorted();
570
+ }
571
+ updatedList = config.channels.disabledPredefined;
572
+ });
534
573
  // Update the runtime CONFIG to reflect the change immediately.
535
- CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
574
+ CONFIG.channels.disabledPredefined = updatedList;
536
575
  const affected = targetKeys.length;
537
576
  const scopeLabel = (scope === "all") ? "All" : (scope === "pacific") ? "Pacific" : "East";
538
577
  LOG.info("%s predefined channels %s (%d affected).", scopeLabel, enabled ? "enabled" : "disabled", affected);
539
578
  res.json({ affected, enabled, keys: targetKeys, patch: buildChannelTablePatch(targetKeys, getProfiles()), scope, success: true });
540
579
  }
541
580
  catch (error) {
542
- LOG.error("Failed to toggle predefined channels: %s.", formatError(error));
543
- res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
581
+ sendErrorResponse(res, error, "toggle channels");
544
582
  }
545
583
  });
546
584
  // 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.
585
+ // start is 0. Overwrites existing channel numbers for affected channels. Only channels that are enabled and available by service filter are affected.
548
586
  app.post("/config/channels/auto-number", async (req, res) => {
549
587
  try {
550
588
  const body = req.body;
@@ -560,40 +598,35 @@ export function setupChannelRoutes(app) {
560
598
  res.status(400).json({ error: "Invalid sort field.", success: false });
561
599
  return;
562
600
  }
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);
601
+ // Get visible channels (enabled + available by service filter) sorted by the user's current sort order.
602
+ const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByService);
565
603
  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
604
  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);
605
+ await mutateChannels((channels) => {
606
+ if (clearMode) {
607
+ // Clear channel numbers from all visible channels. Null signals "clear this field" - the normalizer handles the storage conventions.
608
+ for (const entry of listing) {
609
+ const existing = channels[entry.key] ?? {};
610
+ existing.channelNumber = null;
611
+ channels[entry.key] = existing;
612
+ affectedKeys.push(entry.key);
613
+ }
580
614
  }
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;
615
+ else {
616
+ // Assign sequential numbers starting from the requested start value.
617
+ for (let i = 0; i < listing.length; i++) {
618
+ const entry = listing[i];
619
+ const num = start + i;
620
+ if (num > 99999) {
621
+ break;
622
+ }
623
+ const existing = channels[entry.key] ?? {};
624
+ existing.channelNumber = num;
625
+ channels[entry.key] = existing;
626
+ affectedKeys.push(entry.key);
589
627
  }
590
- const existing = result.channels[entry.key] ?? {};
591
- existing.channelNumber = num;
592
- result.channels[entry.key] = existing;
593
- affectedKeys.push(entry.key);
594
628
  }
595
- }
596
- await saveUserChannels(result.channels);
629
+ });
597
630
  const action = clearMode ? "Cleared" : "Numbered";
598
631
  LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
599
632
  const profiles = getProfiles();
@@ -603,8 +636,7 @@ export function setupChannelRoutes(app) {
603
636
  res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
604
637
  }
605
638
  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 });
639
+ sendErrorResponse(res, error, "auto-number channels");
608
640
  }
609
641
  });
610
642
  // POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
@@ -613,31 +645,29 @@ export function setupChannelRoutes(app) {
613
645
  try {
614
646
  const body = req.body;
615
647
  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
- }
648
+ // Get visible channels (enabled + available by service filter).
649
+ const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByService);
624
650
  const affectedKeys = [];
651
+ // Pre-compute which channels need updating. The listing snapshot is consistent since no concurrent mutation can change it before we enter the lock.
625
652
  for (const entry of listing) {
626
653
  const current = entry.channel.hdhrEnabled !== false;
627
654
  // Skip channels that already match the target state.
628
655
  if (current === enable) {
629
656
  continue;
630
657
  }
631
- const existing = result.channels[entry.key] ?? {};
632
- existing.hdhrEnabled = enable ? null : false;
633
- result.channels[entry.key] = existing;
634
658
  affectedKeys.push(entry.key);
635
659
  }
636
660
  if (affectedKeys.length === 0) {
637
661
  res.json({ affected: 0, message: "No changes needed.", success: true });
638
662
  return;
639
663
  }
640
- await saveUserChannels(result.channels);
664
+ await mutateChannels((channels) => {
665
+ for (const key of affectedKeys) {
666
+ const existing = channels[key] ?? {};
667
+ existing.hdhrEnabled = enable ? null : false;
668
+ channels[key] = existing;
669
+ }
670
+ });
641
671
  const action = enable ? "included in" : "excluded from";
642
672
  LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
643
673
  const profiles = getProfiles();
@@ -645,18 +675,17 @@ export function setupChannelRoutes(app) {
645
675
  res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
646
676
  }
647
677
  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 });
678
+ sendErrorResponse(res, error, "toggle HDHR settings");
650
679
  }
651
680
  });
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.
681
+ // POST /config/channels/bulk-tags - Add or remove a tag on all enabled, service-available channels. Operates on the same channel set as other bulk actions.
653
682
  // transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
654
683
  // modal body.
655
684
  app.post("/config/channels/bulk-tags", async (req, res) => {
656
685
  try {
657
686
  const body = req.body;
658
687
  const action = body.action;
659
- const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
688
+ const tag = typeof body.tag === "string" ? body.tag.trim() : "";
660
689
  if ((action !== "add") && (action !== "remove")) {
661
690
  res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
662
691
  return;
@@ -665,13 +694,13 @@ export function setupChannelRoutes(app) {
665
694
  res.status(400).json({ error: "Tag is required.", success: false });
666
695
  return;
667
696
  }
668
- // Validate the tag is in the active vocabulary.
669
- const vocabulary = new Set(getActiveTagVocabulary());
670
- if (!vocabulary.has(tag)) {
697
+ // Validate the tag is in the active vocabulary (case-insensitive).
698
+ const vocabularyLower = new Set(getActiveTagVocabulary().map((t) => t.toLowerCase()));
699
+ if (!vocabularyLower.has(tag.toLowerCase())) {
671
700
  res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
672
701
  return;
673
702
  }
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));
703
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.enabled && entry.availableByService, (tags) => (action === "add") ? (tags.includes(tag) ? tags : [...tags, tag]) : tags.filter((t) => t !== tag));
675
704
  if (error) {
676
705
  res.status(400).json({ error, success: false });
677
706
  return;
@@ -694,66 +723,62 @@ export function setupChannelRoutes(app) {
694
723
  });
695
724
  }
696
725
  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 });
726
+ sendErrorResponse(res, error, "update tags");
699
727
  }
700
728
  });
701
- // POST /config/provider-filter - Update the provider filter (enabled provider tags).
702
- app.post("/config/provider-filter", async (req, res) => {
729
+ // POST /config/service-filter - Update the service filter (enabled service tags).
730
+ app.post("/config/service-filter", async (req, res) => {
703
731
  try {
704
732
  const body = req.body;
705
- const tags = body.enabledProviders;
733
+ const tags = body.enabledServices;
706
734
  // Validate tags is an array.
707
735
  if (!Array.isArray(tags)) {
708
- res.status(400).json({ error: "enabledProviders must be an array.", success: false });
736
+ res.status(400).json({ error: "enabledServices must be an array.", success: false });
709
737
  return;
710
738
  }
711
- // Validate all tags are known. Tags already in enabledProviders are accepted even if no current channel or profile produces them — this allows stale tags to be
739
+ // Validate all tags are known. Tags already in enabledServices are accepted even if no current channel or profile produces them — this allows stale tags to be
712
740
  // removed via the UI without blocking the request.
713
- const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
714
- const currentTags = new Set(getEnabledProviders());
741
+ const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
742
+ const currentTags = new Set(getEnabledServices());
715
743
  for (const tag of tags) {
716
744
  if (!knownTags.has(tag) && !currentTags.has(tag)) {
717
- res.status(400).json({ error: "Unknown provider tag: " + tag, success: false });
745
+ res.status(400).json({ error: "Unknown service tag: " + tag, success: false });
718
746
  return;
719
747
  }
720
748
  }
721
749
  // Update module-level state.
722
- setEnabledProviders(tags);
750
+ setEnabledServices(tags);
723
751
  // Update runtime CONFIG.
724
- CONFIG.channels.enabledProviders = [...tags];
752
+ CONFIG.channels.enabledServices = [...tags];
725
753
  // Save to config file.
726
- const configResult = await loadUserConfig();
727
- const userConfig = configResult.config;
728
- userConfig.channels ??= {};
729
- userConfig.channels.enabledProviders = tags;
730
- await saveUserConfig(filterDefaults(userConfig));
731
- LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
754
+ await mutateConfig((config) => {
755
+ config.channels ??= {};
756
+ config.channels.enabledServices = tags;
757
+ });
758
+ LOG.info("Service filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all services");
732
759
  // Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
733
760
  const { counts, scopeCounts } = buildChannelTableState();
734
- res.json({ enabledProviders: tags, patch: { counts, rows: [], scopeCounts }, success: true });
761
+ res.json({ enabledServices: tags, patch: { counts, rows: [], scopeCounts }, success: true });
735
762
  }
736
763
  catch (error) {
737
- LOG.error("Failed to update provider filter: %s.", formatError(error));
738
- res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
764
+ sendErrorResponse(res, error, "update service filter");
739
765
  }
740
766
  });
741
- // POST /config/channels/setup-completed - Mark the Provider Setup flow as completed. Called when the wizard finishes or the user explicitly skips.
767
+ // POST /config/channels/setup-completed - Mark the Service Setup flow as completed. Called when the wizard finishes or the user explicitly skips.
742
768
  app.post("/config/channels/setup-completed", async (_req, res) => {
743
769
  try {
744
770
  CONFIG.channels.setupCompleted = true;
745
- const configResult = await loadUserConfig();
746
- configResult.config.channels ??= {};
747
- configResult.config.channels.setupCompleted = true;
748
- await saveUserConfig(configResult.config);
771
+ await mutateConfig((config) => {
772
+ config.channels ??= {};
773
+ config.channels.setupCompleted = true;
774
+ });
749
775
  // 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.
776
+ // via the modify endpoint, and the service filter may have changed — the patch ensures the table reflects the current state.
751
777
  const { counts, scopeCounts } = buildChannelTableState();
752
778
  res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
753
779
  }
754
780
  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 });
781
+ sendErrorResponse(res, error, "save setup state");
757
782
  }
758
783
  });
759
784
  // POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
@@ -791,28 +816,26 @@ export function setupChannelRoutes(app) {
791
816
  CONFIG.channels.channelSortDirection = body.sortDirection;
792
817
  }
793
818
  // Persist to config file.
794
- const configResult = await loadUserConfig();
795
- const userConfig = configResult.config;
796
- userConfig.channels ??= {};
797
- userConfig.channels.channelSortDirection = CONFIG.channels.channelSortDirection;
798
- userConfig.channels.channelSortField = CONFIG.channels.channelSortField;
799
- userConfig.channels.visibleColumns = CONFIG.channels.visibleColumns;
800
- await saveUserConfig(filterDefaults(userConfig));
819
+ await mutateConfig((config) => {
820
+ config.channels ??= {};
821
+ config.channels.channelSortDirection = CONFIG.channels.channelSortDirection;
822
+ config.channels.channelSortField = CONFIG.channels.channelSortField;
823
+ config.channels.visibleColumns = CONFIG.channels.visibleColumns;
824
+ });
801
825
  res.json({ success: true });
802
826
  }
803
827
  catch (error) {
804
- LOG.error("Failed to update display preferences: %s.", formatError(error));
805
- res.status(500).json({ error: "Failed to update display preferences: " + formatError(error), success: false });
828
+ sendErrorResponse(res, error, "update display preferences");
806
829
  }
807
830
  });
808
- // POST /config/provider-bulk-assign - Set all channels to a specific provider.
809
- app.post("/config/provider-bulk-assign", async (req, res) => {
831
+ // POST /config/service-bulk-assign - Set all channels to a specific service.
832
+ app.post("/config/service-bulk-assign", async (req, res) => {
810
833
  try {
811
834
  const body = req.body;
812
- const providerTag = body.provider?.trim();
813
- // Validate provider tag.
814
- if (!providerTag) {
815
- res.status(400).json({ error: "Provider tag is required.", success: false });
835
+ const serviceTag = body.service?.trim();
836
+ // Validate service tag.
837
+ if (!serviceTag) {
838
+ res.status(400).json({ error: "Service tag is required.", success: false });
816
839
  return;
817
840
  }
818
841
  let affected = 0;
@@ -821,17 +844,17 @@ export function setupChannelRoutes(app) {
821
844
  // Iterate all channels and set those with a matching variant.
822
845
  const listing = getChannelListing();
823
846
  for (const entry of listing) {
824
- const group = getProviderGroup(entry.key);
847
+ const group = getServiceGroup(entry.key);
825
848
  if (!group || (group.variants.length <= 1)) {
826
849
  continue;
827
850
  }
828
- // Find a variant matching the requested provider tag.
829
- const matchingVariant = group.variants.find((v) => (getProviderTagForChannel(v.key) === providerTag));
851
+ // Find a variant matching the requested service tag.
852
+ const matchingVariant = group.variants.find((v) => (getServiceTagForChannel(v.key) === serviceTag));
830
853
  if (matchingVariant) {
831
854
  // Snapshot the current selection before overwriting so the client can offer undo.
832
- const currentVariant = getProviderSelection(entry.key);
855
+ const currentVariant = getServiceSelection(entry.key);
833
856
  previousSelections[entry.key] = currentVariant ?? null;
834
- setProviderSelection(entry.key, matchingVariant.key);
857
+ setServiceSelection(entry.key, matchingVariant.key);
835
858
  affected++;
836
859
  // Collect the resolved profile name for client-side UI update.
837
860
  const resolvedChannel = getResolvedChannel(matchingVariant.key);
@@ -839,17 +862,16 @@ export function setupChannelRoutes(app) {
839
862
  }
840
863
  }
841
864
  // Save to disk.
842
- await saveProviderSelections();
843
- LOG.info("Bulk assign to '%s': %d of %d channels affected.", providerTag, affected, listing.length);
865
+ await saveServiceSelections();
866
+ LOG.info("Bulk assign to '%s': %d of %d channels affected.", serviceTag, affected, listing.length);
844
867
  res.json({ affected, previousSelections, selections, success: true, total: listing.length });
845
868
  }
846
869
  catch (error) {
847
- LOG.error("Failed to bulk assign provider: %s.", formatError(error));
848
- res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
870
+ sendErrorResponse(res, error, "bulk assign service");
849
871
  }
850
872
  });
851
- // POST /config/provider-bulk-restore - Restore previous provider selections (undo bulk assign).
852
- app.post("/config/provider-bulk-restore", async (req, res) => {
873
+ // POST /config/service-bulk-restore - Restore previous service selections (undo bulk assign).
874
+ app.post("/config/service-bulk-restore", async (req, res) => {
853
875
  try {
854
876
  const body = req.body;
855
877
  const previousSelections = body.selections;
@@ -860,21 +882,21 @@ export function setupChannelRoutes(app) {
860
882
  let restored = 0;
861
883
  const selections = {};
862
884
  for (const [key, variantKey] of Object.entries(previousSelections)) {
863
- const group = getProviderGroup(key);
885
+ const group = getServiceGroup(key);
864
886
  if (!group) {
865
887
  continue;
866
888
  }
867
889
  // A null value means the channel was using the default (canonical) selection. Restoring by setting the selection to the canonical key clears the override.
868
890
  if (variantKey === null) {
869
- setProviderSelection(key, key);
891
+ setServiceSelection(key, key);
870
892
  }
871
893
  else {
872
- // Validate the variant belongs to this channel's provider group before restoring.
894
+ // Validate the variant belongs to this channel's service group before restoring.
873
895
  const isValid = group.variants.some((v) => (v.key === variantKey));
874
896
  if (!isValid) {
875
897
  continue;
876
898
  }
877
- setProviderSelection(key, variantKey);
899
+ setServiceSelection(key, variantKey);
878
900
  }
879
901
  restored++;
880
902
  // Build the same selection response format as bulk assign for client-side UI updates.
@@ -883,13 +905,12 @@ export function setupChannelRoutes(app) {
883
905
  selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
884
906
  }
885
907
  // Save to disk.
886
- await saveProviderSelections();
908
+ await saveServiceSelections();
887
909
  LOG.info("Bulk restore: %d channel(s) reverted.", restored);
888
910
  res.json({ restored, selections, success: true });
889
911
  }
890
912
  catch (error) {
891
- LOG.error("Failed to bulk restore providers: %s.", formatError(error));
892
- res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
913
+ sendErrorResponse(res, error, "bulk restore services");
893
914
  }
894
915
  });
895
916
  // POST /config/channels - Handle channel add, edit, delete, and revert operations. Returns JSON response.
@@ -913,16 +934,12 @@ export function setupChannelRoutes(app) {
913
934
  res.status(400).json({ message: "Cannot revert '" + key + "': no override exists.", success: false });
914
935
  return;
915
936
  }
916
- // Remove the override.
917
- const result = await loadUserChannels();
918
- if (result.parseError) {
919
- res.status(400).json({ message: "Cannot revert channel: channels file contains invalid JSON.", success: false });
920
- return;
921
- }
922
- // Check if the override contains M3U-relevant fields before removing it.
923
- const revertHint = playlistHintForStored(result.channels[key]);
924
- Reflect.deleteProperty(result.channels, key);
925
- await saveUserChannels(result.channels);
937
+ // Remove the override. Check if it contains M3U-relevant fields before removing so the response can include the playlist reload hint.
938
+ let revertHint = "";
939
+ await mutateChannels((channels) => {
940
+ revertHint = playlistHintForStored(channels[key]);
941
+ Reflect.deleteProperty(channels, key);
942
+ });
926
943
  LOG.info("Channel '%s' reverted to predefined defaults.", key);
927
944
  res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
928
945
  patch: buildChannelTablePatch([key], profiles), success: true });
@@ -949,31 +966,28 @@ export function setupChannelRoutes(app) {
949
966
  return;
950
967
  }
951
968
  }
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);
969
+ // Update the channel inside a transactional mutation. For predefined channels, store a delta override with null to clear fields. For user channels, use
970
+ // undefined to delete. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet. Null signals "clear this field"
971
+ // for all channel types - the normalizer handles delta comparison for predefined channels and null-stripping for user channels.
972
+ await mutateChannels((channels) => {
973
+ const stored = channels[key] ?? {};
974
+ if (field === "channelNumber") {
975
+ stored.channelNumber = value ? parseInt(value, 10) : null;
976
+ }
977
+ else if (field === "hdhrEnabled") {
978
+ stored.hdhrEnabled = (value === "false") ? false : null;
979
+ }
980
+ else if (field === "tags") {
981
+ const tags = value ?
982
+ [...new Set(value.split(",").map((t) => t.trim()).filter((t) => t.length > 0))].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) :
983
+ [];
984
+ stored.tags = (tags.length > 0) ? tags : null;
985
+ }
986
+ else {
987
+ stored.stationId = value || null;
988
+ }
989
+ channels[key] = stored;
990
+ });
977
991
  const fieldLabels = {
978
992
  channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
979
993
  };
@@ -995,13 +1009,9 @@ export function setupChannelRoutes(app) {
995
1009
  return;
996
1010
  }
997
1011
  // Delete the channel.
998
- const result = await loadUserChannels();
999
- if (result.parseError) {
1000
- res.status(400).json({ message: "Cannot delete channel: channels file contains invalid JSON.", success: false });
1001
- return;
1002
- }
1003
- Reflect.deleteProperty(result.channels, key);
1004
- await saveUserChannels(result.channels);
1012
+ await mutateChannels((channels) => {
1013
+ Reflect.deleteProperty(channels, key);
1014
+ });
1005
1015
  LOG.info("User channel '%s' deleted.", key);
1006
1016
  // 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
1017
  // the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
@@ -1025,13 +1035,17 @@ export function setupChannelRoutes(app) {
1025
1035
  const name = sanitizeString(body.name ?? "");
1026
1036
  const url = sanitizeString(body.url ?? "");
1027
1037
  const profile = sanitizeString(body.profile ?? "");
1038
+ const guideTitle = sanitizeString(body.guideTitle ?? "");
1039
+ const logoUrl = sanitizeString(body.logoUrl ?? "");
1028
1040
  const stationId = sanitizeString(body.stationId ?? "");
1029
1041
  const channelSelector = sanitizeString(body.channelSelector ?? "");
1030
1042
  const channelNumberStr = sanitizeString(body.channelNumber ?? "");
1031
1043
  const hdhrEnabled = body.hdhrEnabled !== "false";
1032
- // Parse tags from comma-separated input. Trim, lowercase, filter empty, sort, deduplicate.
1044
+ // Parse tags from comma-separated input. Trim, filter empty, deduplicate, and sort case-insensitively.
1033
1045
  const tagsRaw = sanitizeString(body.tags ?? "");
1034
- const tags = tagsRaw ? [...new Set(tagsRaw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
1046
+ const tags = tagsRaw ?
1047
+ [...new Set(tagsRaw.split(",").map((t) => t.trim()).filter((t) => t.length > 0))].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) :
1048
+ [];
1035
1049
  // Validate channel number if provided.
1036
1050
  const channelNumberError = validateChannelNumber(channelNumberStr, key);
1037
1051
  if (channelNumberError) {
@@ -1064,18 +1078,6 @@ export function setupChannelRoutes(app) {
1064
1078
  res.status(400).json({ errors: formErrors, success: false });
1065
1079
  return;
1066
1080
  }
1067
- // Load existing user channels.
1068
- const result = await loadUserChannels();
1069
- if (result.parseError) {
1070
- // If channels file is corrupt, start fresh on add (which will create a valid file).
1071
- if (action === "add") {
1072
- result.channels = {};
1073
- }
1074
- else {
1075
- res.status(400).json({ message: "Cannot edit channel: channels file contains invalid JSON.", success: false });
1076
- return;
1077
- }
1078
- }
1079
1081
  let playlistChanged = false;
1080
1082
  // For predefined channels being edited, compute a delta of only the changed fields. For user-defined channels and adds, build a full channel object.
1081
1083
  const predefinedBase = getPredefinedChannel(key);
@@ -1085,7 +1087,9 @@ export function setupChannelRoutes(app) {
1085
1087
  const formValues = {
1086
1088
  channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
1087
1089
  channelSelector,
1090
+ guideTitle,
1088
1091
  hdhrEnabled,
1092
+ logoUrl,
1089
1093
  name,
1090
1094
  profile,
1091
1095
  stationId,
@@ -1100,11 +1104,11 @@ export function setupChannelRoutes(app) {
1100
1104
  }
1101
1105
  return (field === "channelNumber") ? val : val ?? "";
1102
1106
  };
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
1107
+ // First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected service's resolved channel, which
1104
1108
  // 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
1105
- // values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
1109
+ // values match the displayChannel exactly, the user saved without modification - no override should be created, and any existing override is preserved.
1106
1110
  // Tags are compared separately (array comparison) after the scalar fields.
1107
- const resolvedKey = resolveProviderKey(key);
1111
+ const resolvedKey = resolveServiceKey(key);
1108
1112
  const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
1109
1113
  const displayTags = getChannelEffectiveTags(displayChannel);
1110
1114
  const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
@@ -1137,12 +1141,14 @@ export function setupChannelRoutes(app) {
1137
1141
  const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
1138
1142
  (JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
1139
1143
  if (!hasChanges) {
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
1144
+ // The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away - treat it as an
1141
1145
  // implicit revert by removing the override and returning the predefined row HTML.
1142
1146
  if (isUserChannel(key)) {
1143
- const implicitRevertHint = playlistHintForStored(result.channels[key]);
1144
- Reflect.deleteProperty(result.channels, key);
1145
- await saveUserChannels(result.channels);
1147
+ let implicitRevertHint = "";
1148
+ await mutateChannels((channels) => {
1149
+ implicitRevertHint = playlistHintForStored(channels[key]);
1150
+ Reflect.deleteProperty(channels, key);
1151
+ });
1146
1152
  LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
1147
1153
  res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
1148
1154
  patch: buildChannelTablePatch([key], profiles), success: true });
@@ -1151,15 +1157,15 @@ export function setupChannelRoutes(app) {
1151
1157
  res.json({ key, message: "No changes to save.", success: true });
1152
1158
  return;
1153
1159
  }
1154
- // The delta has changes vs the canonical predefined. Before storing a custom override, check if the form values match any provider variant's predefined
1160
+ // The delta has changes vs the canonical predefined. Before storing a custom override, check if the form values match any service variant's predefined
1155
1161
  // definition. This handles the case where a user edits from a variant (e.g., Hulu), makes a change, saves (creating a custom override), then edits again
1156
- // and reverts the change. The URL and channelSelector still differ from the canonical predefined but match the variant — that's a revert to the variant,
1162
+ // and reverts the change. The URL and channelSelector still differ from the canonical predefined but match the variant - that's a revert to the variant,
1157
1163
  // not a new customization. We resolve each variant against pure PREDEFINED data (not the user-overridden channelsRef) to avoid contamination from the
1158
1164
  // current override.
1159
- const providerGroup = getProviderGroup(key);
1165
+ const serviceGroup = getServiceGroup(key);
1160
1166
  let matchedVariantKey;
1161
- if (providerGroup && isUserChannel(key)) {
1162
- for (const variant of providerGroup.variants) {
1167
+ if (serviceGroup && isUserChannel(key)) {
1168
+ for (const variant of serviceGroup.variants) {
1163
1169
  // Skip the canonical entry (already handled by the !hasChanges check above) and :predefined entries (synthetic entries for override UI).
1164
1170
  if ((variant.key === key) || variant.key.includes(":")) {
1165
1171
  continue;
@@ -1173,29 +1179,38 @@ export function setupChannelRoutes(app) {
1173
1179
  }
1174
1180
  }
1175
1181
  if (matchedVariantKey) {
1176
- // Form values match a known variant — revert the override and switch back to that variant.
1177
- const variantRevertHint = playlistHintForStored(result.channels[key]);
1178
- Reflect.deleteProperty(result.channels, key);
1179
- setProviderSelection(key, matchedVariantKey);
1180
- await saveUserChannels(result.channels);
1182
+ // Form values match a known variant - revert the override and switch back to that variant.
1183
+ let variantRevertHint = "";
1184
+ await mutateChannels((channels) => {
1185
+ variantRevertHint = playlistHintForStored(channels[key]);
1186
+ Reflect.deleteProperty(channels, key);
1187
+ });
1188
+ setServiceSelection(key, matchedVariantKey);
1181
1189
  LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
1182
1190
  res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
1183
1191
  patch: buildChannelTablePatch([key], profiles), success: true });
1184
1192
  return;
1185
1193
  }
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
1194
+ // No variant match - store the delta and switch the service selection to the canonical key (the custom override). This ensures the service dropdown shows
1187
1195
  // "Custom" after saving, which is the expected behavior when a user customizes a predefined channel.
1188
- setProviderSelection(key, key);
1189
- result.channels[key] = delta;
1196
+ setServiceSelection(key, key);
1197
+ await mutateChannels((channels) => {
1198
+ channels[key] = delta;
1199
+ });
1190
1200
  playlistChanged = M3U_FIELDS.some((f) => f in delta);
1191
1201
  }
1192
1202
  else {
1193
- // User-defined channel or add: build a full channel object. For edits, snapshot the old channel to detect M3U-relevant changes.
1194
- const oldChannel = ((action === "edit") && (key in result.channels)) ? result.channels[key] : undefined;
1203
+ // User-defined channel or add: build a full channel object.
1195
1204
  const channel = {
1196
1205
  name,
1197
1206
  url
1198
1207
  };
1208
+ if (guideTitle) {
1209
+ channel.guideTitle = guideTitle;
1210
+ }
1211
+ if (logoUrl) {
1212
+ channel.logoUrl = logoUrl;
1213
+ }
1199
1214
  if (profile) {
1200
1215
  channel.profile = profile;
1201
1216
  }
@@ -1215,13 +1230,20 @@ export function setupChannelRoutes(app) {
1215
1230
  if (!hdhrEnabled) {
1216
1231
  channel.hdhrEnabled = false;
1217
1232
  }
1218
- result.channels[key] = channel;
1219
- // For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
1220
- playlistChanged = (action === "add") ||
1221
- ((oldChannel !== undefined) &&
1222
- M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
1233
+ // For edits, snapshot the old channel inside the mutation to detect M3U-relevant changes. For adds the playlist always changes.
1234
+ await mutateChannels((channels) => {
1235
+ if (action === "edit") {
1236
+ const oldChannel = (key in channels) ? channels[key] : undefined;
1237
+ channels[key] = channel;
1238
+ playlistChanged = (oldChannel !== undefined) &&
1239
+ M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]);
1240
+ }
1241
+ else {
1242
+ channels[key] = channel;
1243
+ playlistChanged = true;
1244
+ }
1245
+ });
1223
1246
  }
1224
- await saveUserChannels(result.channels);
1225
1247
  // Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
1226
1248
  if (stationId) {
1227
1249
  updateChannelLogo(name || key, stationId);
@@ -1231,18 +1253,20 @@ export function setupChannelRoutes(app) {
1231
1253
  // Generate HTML for the channel row so the client can update the DOM without a full page reload.
1232
1254
  // Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
1233
1255
  const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
1234
- // Return success response with patch for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
1256
+ // Check if the new channel's service isn't in the active filter.
1257
+ const serviceWarning = (action === "add") ? buildServiceFilterWarning(url) : undefined;
1258
+ // Return success response with patch for client-side DOM update. Changes take effect immediately due to hot-reloading in mutateChannels().
1235
1259
  res.json({
1236
1260
  isNew: action === "add",
1237
1261
  key,
1238
1262
  message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
1239
1263
  patch: buildChannelTablePatch([key], profiles),
1264
+ serviceWarning,
1240
1265
  success: true
1241
1266
  });
1242
1267
  }
1243
1268
  catch (error) {
1244
- LOG.error("Failed to save channel: %s.", formatError(error));
1245
- res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
1269
+ sendErrorResponse(res, error, "save channel");
1246
1270
  }
1247
1271
  });
1248
1272
  // Tag Management Endpoints.
@@ -1261,7 +1285,7 @@ export function setupChannelRoutes(app) {
1261
1285
  app.post("/config/tags", async (req, res) => {
1262
1286
  try {
1263
1287
  const body = req.body;
1264
- const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
1288
+ const tag = typeof body.tag === "string" ? body.tag.trim() : "";
1265
1289
  if (tag.length === 0) {
1266
1290
  res.status(400).json({ error: "Tag name is required.", success: false });
1267
1291
  return;
@@ -1270,19 +1294,19 @@ export function setupChannelRoutes(app) {
1270
1294
  res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1271
1295
  return;
1272
1296
  }
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 });
1297
+ if (!(/^[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?$/).test(tag)) {
1298
+ res.status(400).json({ error: "Tag name must start and end with a letter or number, and contain only letters, numbers, spaces, and hyphens.", success: false });
1275
1299
  return;
1276
1300
  }
1277
- // Check if the tag already exists in the active vocabulary.
1278
- const vocabulary = new Set(getActiveTagVocabulary());
1279
- if (vocabulary.has(tag)) {
1301
+ // Check if the tag already exists in the active vocabulary (case-insensitive).
1302
+ const vocabularyLower = new Set(getActiveTagVocabulary().map((t) => t.toLowerCase()));
1303
+ if (vocabularyLower.has(tag.toLowerCase())) {
1280
1304
  res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
1281
1305
  return;
1282
1306
  }
1283
1307
  // Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
1284
1308
  const registry = getTagRegistry();
1285
- if (registry.deletedTags.includes(tag)) {
1309
+ if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
1286
1310
  res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
1287
1311
  return;
1288
1312
  }
@@ -1297,8 +1321,7 @@ export function setupChannelRoutes(app) {
1297
1321
  });
1298
1322
  }
1299
1323
  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 });
1324
+ sendErrorResponse(res, error, "create tag");
1302
1325
  }
1303
1326
  });
1304
1327
  // 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
@@ -1306,36 +1329,38 @@ export function setupChannelRoutes(app) {
1306
1329
  // override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
1307
1330
  app.delete("/config/tags/:tag", async (req, res) => {
1308
1331
  try {
1309
- const tag = req.params.tag?.toLowerCase() ?? "";
1332
+ const tag = req.params.tag?.trim() ?? "";
1310
1333
  if (tag.length === 0) {
1311
1334
  res.status(400).json({ error: "Tag name is required.", success: false });
1312
1335
  return;
1313
1336
  }
1314
1337
  const registry = getTagRegistry();
1315
- const isPredefined = PREDEFINED_TAGS.includes(tag);
1316
- const isUserTag = registry.tags.includes(tag);
1338
+ const isPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, tag));
1339
+ const isUserTag = registry.tags.some((t) => tagsMatch(t, tag));
1317
1340
  if (!isPredefined && !isUserTag) {
1318
1341
  res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
1319
1342
  return;
1320
1343
  }
1321
1344
  if (isPredefined) {
1345
+ // Find the canonical predefined form for storage.
1346
+ const canonicalTag = PREDEFINED_TAGS.find((t) => tagsMatch(t, tag)) ?? tag;
1322
1347
  // Already deleted — no-op, return current state without unnecessary I/O.
1323
- if (registry.deletedTags.includes(tag)) {
1348
+ if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
1324
1349
  res.json({
1325
1350
  active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1326
1351
  registry: getTagRegistry(), success: true
1327
1352
  });
1328
1353
  return;
1329
1354
  }
1330
- registry.deletedTags.push(tag);
1355
+ registry.deletedTags.push(canonicalTag);
1331
1356
  }
1332
1357
  else {
1333
1358
  // Remove from user tags.
1334
- registry.tags = registry.tags.filter((t) => t !== tag);
1359
+ registry.tags = registry.tags.filter((t) => !tagsMatch(t, tag));
1335
1360
  }
1336
1361
  setTagRegistry(registry);
1337
1362
  // 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));
1363
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.some((t) => tagsMatch(t, tag)) === true, (tags) => tags.filter((t) => !tagsMatch(t, tag)));
1339
1364
  if (error) {
1340
1365
  res.status(400).json({ error, success: false });
1341
1366
  return;
@@ -1351,8 +1376,7 @@ export function setupChannelRoutes(app) {
1351
1376
  });
1352
1377
  }
1353
1378
  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 });
1379
+ sendErrorResponse(res, error, "delete tag");
1356
1380
  }
1357
1381
  });
1358
1382
  // POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
@@ -1361,25 +1385,28 @@ export function setupChannelRoutes(app) {
1361
1385
  app.post("/config/tags/restore", async (req, res) => {
1362
1386
  try {
1363
1387
  const body = req.body;
1364
- const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
1388
+ const tag = typeof body.tag === "string" ? body.tag.trim() : "";
1365
1389
  if (tag.length === 0) {
1366
1390
  res.status(400).json({ error: "Tag name is required.", success: false });
1367
1391
  return;
1368
1392
  }
1369
1393
  const registry = getTagRegistry();
1370
- if (!registry.deletedTags.includes(tag)) {
1394
+ if (!registry.deletedTags.some((t) => tagsMatch(t, tag))) {
1371
1395
  res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
1372
1396
  return;
1373
1397
  }
1374
- registry.deletedTags = registry.deletedTags.filter((t) => t !== tag);
1398
+ registry.deletedTags = registry.deletedTags.filter((t) => !tagsMatch(t, tag));
1375
1399
  setTagRegistry(registry);
1376
1400
  // Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
1377
1401
  // delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
1378
1402
  // predefined definition, cleanly reverting the channel to its default state.
1403
+ // Resolve the canonical predefined tag name for the restored tag so channel data uses the predefined casing.
1404
+ const canonicalTag = PREDEFINED_TAGS.find((t) => tagsMatch(t, tag)) ?? tag;
1379
1405
  const { affectedKeys, error } = await transformChannelTags((entry) => {
1380
1406
  const predefined = getPredefinedChannel(entry.key);
1381
- return (predefined?.tags?.includes(tag) === true) && (entry.channel.tags?.includes(tag) !== true);
1382
- }, (tags) => [...tags, tag]);
1407
+ return (predefined?.tags?.some((t) => tagsMatch(t, tag)) === true) &&
1408
+ (entry.channel.tags?.some((t) => tagsMatch(t, tag)) !== true);
1409
+ }, (tags) => [...tags, canonicalTag]);
1383
1410
  if (error) {
1384
1411
  res.status(400).json({ error, success: false });
1385
1412
  return;
@@ -1395,8 +1422,7 @@ export function setupChannelRoutes(app) {
1395
1422
  });
1396
1423
  }
1397
1424
  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 });
1425
+ sendErrorResponse(res, error, "restore tag");
1400
1426
  }
1401
1427
  });
1402
1428
  // POST /config/tags/rename - Rename a tag atomically across the vocabulary and all channel assignments. For predefined tags, the old name is added to
@@ -1405,19 +1431,20 @@ export function setupChannelRoutes(app) {
1405
1431
  app.post("/config/tags/rename", async (req, res) => {
1406
1432
  try {
1407
1433
  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() : "";
1434
+ const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim() : "";
1435
+ const newTag = typeof body.newTag === "string" ? body.newTag.trim() : "";
1410
1436
  if (!oldTag || !newTag) {
1411
1437
  res.status(400).json({ error: "Both old and new tag names are required.", success: false });
1412
1438
  return;
1413
1439
  }
1440
+ // Exact comparison — case-only renames (e.g., "sports" → "Sports") are valid since tags are freeform with preserved casing.
1414
1441
  if (oldTag === newTag) {
1415
1442
  res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
1416
1443
  return;
1417
1444
  }
1418
- if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(newTag)) {
1445
+ if (!(/^[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?$/).test(newTag)) {
1419
1446
  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
1447
+ error: "Tag name must start and end with a letter or number, and contain only letters, numbers, spaces, and hyphens.", success: false
1421
1448
  });
1422
1449
  return;
1423
1450
  }
@@ -1425,34 +1452,36 @@ export function setupChannelRoutes(app) {
1425
1452
  res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1426
1453
  return;
1427
1454
  }
1428
- // Validate old tag exists in the active vocabulary.
1429
- const vocabulary = new Set(getActiveTagVocabulary());
1430
- if (!vocabulary.has(oldTag)) {
1455
+ // Validate old tag exists in the active vocabulary (case-insensitive).
1456
+ const vocabularyLower = new Set(getActiveTagVocabulary().map((t) => t.toLowerCase()));
1457
+ if (!vocabularyLower.has(oldTag.toLowerCase())) {
1431
1458
  res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
1432
1459
  return;
1433
1460
  }
1434
- // Validate new tag doesn't already exist.
1435
- if (vocabulary.has(newTag)) {
1461
+ // Validate new tag doesn't collide with a different existing tag (case-insensitive). A case-only rename of the same tag (e.g., "sports" → "Sports") is
1462
+ // allowed since it's changing the display form of the same identity.
1463
+ if (vocabularyLower.has(newTag.toLowerCase()) && !tagsMatch(oldTag, newTag)) {
1436
1464
  res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
1437
1465
  return;
1438
1466
  }
1439
1467
  // Update the tag registry: replace old with new in the appropriate list.
1440
1468
  const registry = getTagRegistry();
1441
- const oldIsPredefined = PREDEFINED_TAGS.includes(oldTag);
1469
+ const oldIsPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, oldTag));
1442
1470
  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);
1471
+ // Predefined tag: "delete" the old (add to deletedTags using the canonical predefined form) and create the new as a user tag.
1472
+ const canonicalOld = PREDEFINED_TAGS.find((t) => tagsMatch(t, oldTag)) ?? oldTag;
1473
+ if (!registry.deletedTags.some((t) => tagsMatch(t, oldTag))) {
1474
+ registry.deletedTags.push(canonicalOld);
1446
1475
  }
1447
1476
  registry.tags.push(newTag);
1448
1477
  }
1449
1478
  else {
1450
1479
  // User tag: replace in the user tags list.
1451
- registry.tags = registry.tags.map((t) => (t === oldTag) ? newTag : t);
1480
+ registry.tags = registry.tags.map((t) => tagsMatch(t, oldTag) ? newTag : t);
1452
1481
  }
1453
1482
  setTagRegistry(registry);
1454
1483
  // 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));
1484
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.some((t) => tagsMatch(t, oldTag)) === true, (tags) => tags.map((t) => tagsMatch(t, oldTag) ? newTag : t));
1456
1485
  if (error) {
1457
1486
  res.status(400).json({ error, success: false });
1458
1487
  return;
@@ -1469,8 +1498,7 @@ export function setupChannelRoutes(app) {
1469
1498
  });
1470
1499
  }
1471
1500
  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 });
1501
+ sendErrorResponse(res, error, "rename tag");
1474
1502
  }
1475
1503
  });
1476
1504
  }