prismcast 1.7.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 (208) hide show
  1. package/README.md +6 -6
  2. package/dist/app.js +24 -13
  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/login.js +11 -8
  10. package/dist/browser/login.js.map +1 -1
  11. package/dist/browser/precaching.d.ts +1 -1
  12. package/dist/browser/precaching.js +23 -23
  13. package/dist/browser/precaching.js.map +1 -1
  14. package/dist/browser/tuning/directv.js +4 -7
  15. package/dist/browser/tuning/directv.js.map +1 -1
  16. package/dist/browser/tuning/fox.js +1 -1
  17. package/dist/browser/tuning/fox.js.map +1 -1
  18. package/dist/browser/tuning/hbo.js +1 -1
  19. package/dist/browser/tuning/hbo.js.map +1 -1
  20. package/dist/browser/tuning/hulu.js +3 -3
  21. package/dist/browser/tuning/hulu.js.map +1 -1
  22. package/dist/browser/tuning/sling.js +2 -2
  23. package/dist/browser/tuning/sling.js.map +1 -1
  24. package/dist/channels/index.d.ts +2 -0
  25. package/dist/channels/index.js +438 -252
  26. package/dist/channels/index.js.map +1 -1
  27. package/dist/config/health.d.ts +3 -3
  28. package/dist/config/health.js +13 -18
  29. package/dist/config/health.js.map +1 -1
  30. package/dist/config/index.js +11 -2
  31. package/dist/config/index.js.map +1 -1
  32. package/dist/config/persistence.d.ts +59 -0
  33. package/dist/config/persistence.js +127 -0
  34. package/dist/config/persistence.js.map +1 -0
  35. package/dist/config/profiles.js +10 -10
  36. package/dist/config/profiles.js.map +1 -1
  37. package/dist/config/servicePacks.d.ts +46 -0
  38. package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
  39. package/dist/config/servicePacks.js.map +1 -0
  40. package/dist/config/services.d.ts +202 -0
  41. package/dist/config/services.js +758 -0
  42. package/dist/config/services.js.map +1 -0
  43. package/dist/config/sites.js +61 -61
  44. package/dist/config/sites.js.map +1 -1
  45. package/dist/config/userChannels.d.ts +89 -27
  46. package/dist/config/userChannels.js +400 -184
  47. package/dist/config/userChannels.js.map +1 -1
  48. package/dist/config/userConfig.d.ts +13 -9
  49. package/dist/config/userConfig.js +78 -60
  50. package/dist/config/userConfig.js.map +1 -1
  51. package/dist/config/userProfiles.d.ts +31 -15
  52. package/dist/config/userProfiles.js +154 -123
  53. package/dist/config/userProfiles.js.map +1 -1
  54. package/dist/hdhr/channelMap.d.ts +1 -1
  55. package/dist/hdhr/channelMap.js +4 -7
  56. package/dist/hdhr/channelMap.js.map +1 -1
  57. package/dist/hdhr/discover.js +7 -15
  58. package/dist/hdhr/discover.js.map +1 -1
  59. package/dist/hdhr/index.js +6 -9
  60. package/dist/hdhr/index.js.map +1 -1
  61. package/dist/native/index.d.ts +2 -2
  62. package/dist/native/index.js +17 -27
  63. package/dist/native/index.js.map +1 -1
  64. package/dist/native/intercept.js +86 -89
  65. package/dist/native/intercept.js.map +1 -1
  66. package/dist/native/probe.js +2 -2
  67. package/dist/native/probe.js.map +1 -1
  68. package/dist/native/proxy.d.ts +2 -2
  69. package/dist/native/proxy.js +4 -4
  70. package/dist/native/proxy.js.map +1 -1
  71. package/dist/routes/assets.js +29 -51
  72. package/dist/routes/assets.js.map +1 -1
  73. package/dist/routes/auth.js +3 -3
  74. package/dist/routes/auth.js.map +1 -1
  75. package/dist/routes/channels.js +5 -11
  76. package/dist/routes/channels.js.map +1 -1
  77. package/dist/routes/components.d.ts +0 -8
  78. package/dist/routes/components.js +0 -19
  79. package/dist/routes/components.js.map +1 -1
  80. package/dist/routes/config/channels/index.d.ts +1 -1
  81. package/dist/routes/config/channels/index.js +1 -1
  82. package/dist/routes/config/channels/index.js.map +1 -1
  83. package/dist/routes/config/channels/routes.js +748 -420
  84. package/dist/routes/config/channels/routes.js.map +1 -1
  85. package/dist/routes/config/channels/table.d.ts +21 -6
  86. package/dist/routes/config/channels/table.js +456 -193
  87. package/dist/routes/config/channels/table.js.map +1 -1
  88. package/dist/routes/config/index.d.ts +2 -2
  89. package/dist/routes/config/index.js +3 -3
  90. package/dist/routes/config/index.js.map +1 -1
  91. package/dist/routes/config/{providers.js → services.js} +47 -42
  92. package/dist/routes/config/services.js.map +1 -0
  93. package/dist/routes/config/settings.js +88 -49
  94. package/dist/routes/config/settings.js.map +1 -1
  95. package/dist/routes/debug.js +5 -7
  96. package/dist/routes/debug.js.map +1 -1
  97. package/dist/routes/health.js +1 -1
  98. package/dist/routes/health.js.map +1 -1
  99. package/dist/routes/icons.d.ts +16 -0
  100. package/dist/routes/icons.js +44 -0
  101. package/dist/routes/icons.js.map +1 -0
  102. package/dist/routes/index.js +2 -2
  103. package/dist/routes/index.js.map +1 -1
  104. package/dist/routes/logs.js +4 -4
  105. package/dist/routes/logs.js.map +1 -1
  106. package/dist/routes/playlist.d.ts +6 -4
  107. package/dist/routes/playlist.js +98 -45
  108. package/dist/routes/playlist.js.map +1 -1
  109. package/dist/routes/root/content.js +94 -55
  110. package/dist/routes/root/content.js.map +1 -1
  111. package/dist/routes/root/scripts/channels.js +620 -478
  112. package/dist/routes/root/scripts/channels.js.map +1 -1
  113. package/dist/routes/root/scripts/config.js +884 -964
  114. package/dist/routes/root/scripts/config.js.map +1 -1
  115. package/dist/routes/root/scripts/shared.js +468 -188
  116. package/dist/routes/root/scripts/shared.js.map +1 -1
  117. package/dist/routes/root/scripts/status.js +252 -207
  118. package/dist/routes/root/scripts/status.js.map +1 -1
  119. package/dist/routes/root/styles.js +57 -8
  120. package/dist/routes/root/styles.js.map +1 -1
  121. package/dist/routes/services.d.ts +6 -0
  122. package/dist/routes/{providers.js → services.js} +43 -43
  123. package/dist/routes/services.js.map +1 -0
  124. package/dist/routes/ui.d.ts +1 -1
  125. package/dist/routes/ui.js +3 -1
  126. package/dist/routes/ui.js.map +1 -1
  127. package/dist/service/commands.js +8 -43
  128. package/dist/service/commands.js.map +1 -1
  129. package/dist/service/generators.d.ts +1 -1
  130. package/dist/service/generators.js +143 -41
  131. package/dist/service/generators.js.map +1 -1
  132. package/dist/streaming/codec.d.ts +20 -0
  133. package/dist/streaming/codec.js +68 -0
  134. package/dist/streaming/codec.js.map +1 -0
  135. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  136. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  137. package/dist/streaming/hls.d.ts +4 -4
  138. package/dist/streaming/hls.js +35 -29
  139. package/dist/streaming/hls.js.map +1 -1
  140. package/dist/streaming/hlsResume.js +3 -3
  141. package/dist/streaming/hlsResume.js.map +1 -1
  142. package/dist/streaming/monitor.d.ts +2 -2
  143. package/dist/streaming/monitor.js +19 -13
  144. package/dist/streaming/monitor.js.map +1 -1
  145. package/dist/streaming/preroll.d.ts +15 -14
  146. package/dist/streaming/preroll.js +32 -12
  147. package/dist/streaming/preroll.js.map +1 -1
  148. package/dist/streaming/registry.d.ts +2 -2
  149. package/dist/streaming/registry.js +4 -8
  150. package/dist/streaming/registry.js.map +1 -1
  151. package/dist/streaming/setup.d.ts +4 -4
  152. package/dist/streaming/setup.js +28 -34
  153. package/dist/streaming/setup.js.map +1 -1
  154. package/dist/streaming/showInfo.js +8 -10
  155. package/dist/streaming/showInfo.js.map +1 -1
  156. package/dist/streaming/statusEmitter.d.ts +4 -3
  157. package/dist/streaming/statusEmitter.js +3 -3
  158. package/dist/streaming/statusEmitter.js.map +1 -1
  159. package/dist/types/channels.d.ts +18 -11
  160. package/dist/types/config.d.ts +3 -2
  161. package/dist/types/index.d.ts +4 -3
  162. package/dist/types/index.js +1 -0
  163. package/dist/types/index.js.map +1 -1
  164. package/dist/types/profiles.d.ts +9 -9
  165. package/dist/types/selection.d.ts +1 -1
  166. package/dist/types/shared.d.ts +1 -1
  167. package/dist/types/streaming.d.ts +8 -16
  168. package/dist/types/streaming.js +5 -1
  169. package/dist/types/streaming.js.map +1 -1
  170. package/dist/upgrade/commands.js +1 -16
  171. package/dist/upgrade/commands.js.map +1 -1
  172. package/dist/utils/chromeFetch.js +1 -1
  173. package/dist/utils/cliOutput.d.ts +10 -0
  174. package/dist/utils/cliOutput.js +17 -0
  175. package/dist/utils/cliOutput.js.map +1 -0
  176. package/dist/utils/debugFilter.js +3 -3
  177. package/dist/utils/debugFilter.js.map +1 -1
  178. package/dist/utils/fileLogger.js +4 -3
  179. package/dist/utils/fileLogger.js.map +1 -1
  180. package/dist/utils/format.d.ts +21 -4
  181. package/dist/utils/format.js +45 -7
  182. package/dist/utils/format.js.map +1 -1
  183. package/dist/utils/index.d.ts +1 -0
  184. package/dist/utils/index.js +1 -0
  185. package/dist/utils/index.js.map +1 -1
  186. package/dist/utils/logger.js +17 -27
  187. package/dist/utils/logger.js.map +1 -1
  188. package/dist/utils/morganStream.js +2 -2
  189. package/dist/utils/morganStream.js.map +1 -1
  190. package/dist/utils/platform.js +3 -2
  191. package/dist/utils/platform.js.map +1 -1
  192. package/dist/utils/retry.js +2 -4
  193. package/dist/utils/retry.js.map +1 -1
  194. package/dist/utils/streamContext.d.ts +7 -0
  195. package/dist/utils/streamContext.js +10 -1
  196. package/dist/utils/streamContext.js.map +1 -1
  197. package/dist/utils/version.js +4 -4
  198. package/dist/utils/version.js.map +1 -1
  199. package/package.json +5 -7
  200. package/dist/config/providerPacks.d.ts +0 -46
  201. package/dist/config/providerPacks.js.map +0 -1
  202. package/dist/config/providers.d.ts +0 -175
  203. package/dist/config/providers.js +0 -678
  204. package/dist/config/providers.js.map +0 -1
  205. package/dist/routes/config/providers.js.map +0 -1
  206. package/dist/routes/providers.d.ts +0 -6
  207. package/dist/routes/providers.js.map +0 -1
  208. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -1,15 +1,48 @@
1
- import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
2
- import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState } from "./table.js";
3
- import { VALID_SORT_FIELDS, compareChannelSort, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
4
- import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.js";
5
- import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
1
+ import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString, stringifySorted } from "../../../utils/index.js";
2
+ import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../../../channels/index.js";
3
+ import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState, generateTagFilterContent, generateTagManagerBody } from "./table.js";
4
+ import { VALID_SORT_FIELDS, compareChannelSort, 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";
6
6
  import { CONFIG } from "../../../config/index.js";
7
- import { PREDEFINED_CHANNELS } from "../../../channels/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.
@@ -77,11 +112,10 @@ export function setupChannelRoutes(app) {
77
112
  }
78
113
  res.setHeader("Content-Type", "application/json");
79
114
  res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
80
- res.send(JSON.stringify(resolved, null, 2) + "\n");
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,140 +272,140 @@ 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;
249
- // Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic.
250
- function buildUserChannel(entry, channelName, channelUrl, selector) {
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 };
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
285
+ // canonicalKey is provided, the channel is a service variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
286
+ // at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
287
+ function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
251
288
  const channel = {
289
+ ...(canonicalKey ? { canonicalKey } : { name: channelName }),
252
290
  channelSelector: selector || undefined,
253
- name: channelName,
254
291
  url: channelUrl
255
292
  };
256
- if (entry.stationId) {
293
+ if (!canonicalKey && entry.stationId) {
257
294
  channel.stationId = sanitizeString(entry.stationId);
258
295
  }
259
296
  return channel;
260
297
  }
261
- for (const entry of channels) {
262
- const action = entry.action ?? "add";
263
- const name = sanitizeString(entry.name?.trim() ?? "");
264
- const providerSlug = entry.providerSlug?.trim() ?? "";
265
- // Handle enable and switch. Enable re-enables a disabled predefined channel, then falls through to the switch logic to set the provider selection.
266
- // Switch changes the provider selection for an existing channel to point to the browsed provider's variant, creating the variant if needed.
267
- if ((action === "switch") || (action === "enable")) {
268
- const canonicalKey = entry.canonicalKey?.trim() ?? "";
269
- if (!canonicalKey) {
270
- errors.push("Entry for '" + name + "' missing canonical key.");
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);
271
326
  continue;
272
327
  }
273
- // Re-enable the channel if it was disabled. Must complete before setting the provider selection since the config write makes the channel
274
- // visible in the lineup.
275
- if (action === "enable") {
276
- // eslint-disable-next-line no-await-in-loop -- Sequential: config state must be updated before setting provider selection.
277
- await enablePredefinedChannels([canonicalKey]);
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;
278
356
  }
279
- const variantKey = canonicalKey + "-" + providerSlug;
280
- // If no variant exists for this provider, create one as a user channel.
281
- if (!allKeys.has(variantKey)) {
282
- existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""));
283
- allKeys.add(variantKey);
284
- channelsChanged = true;
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;
285
364
  }
286
- setProviderSelection(canonicalKey, variantKey);
287
- selectionsChanged = true;
288
- switched++;
289
- affectedKeys.add(canonicalKey);
290
- continue;
291
- }
292
- // Handle provider removal. Reverts the channel away from this provider using a three-tier fallback: (1) clear the selection and let
293
- // resolveProviderKey find the next enabled variant or canonical, (2) if the resolved provider is still this provider (no alternative exists),
294
- // disable the predefined channel or delete the user channel.
295
- if (action === "remove") {
296
- const canonicalKey = entry.canonicalKey?.trim() ?? "";
297
- if (!canonicalKey) {
298
- errors.push("Remove entry for '" + name + "' missing canonical key.");
365
+ if (!url) {
366
+ errors.push("Channel '" + name + "' missing URL.");
299
367
  continue;
300
368
  }
301
- // Clear the provider selection. resolveProviderKey will now return the canonical or the first enabled alternative variant.
302
- setProviderSelection(canonicalKey, canonicalKey);
303
- selectionsChanged = true;
304
- // Check if the resolved provider is still this provider. If so, there is no alternative — disable or delete the channel.
305
- const resolvedKey = resolveProviderKey(canonicalKey);
306
- const resolvedTag = getProviderTagForChannel(resolvedKey);
307
- if (resolvedTag === providerSlug) {
308
- // No alternative provider exists. Disable predefined channels or delete user channels.
309
- if (isPredefinedChannel(canonicalKey)) {
310
- disablePredefined.add(canonicalKey);
311
- }
312
- else {
313
- Reflect.deleteProperty(existingChannels, canonicalKey);
314
- channelsChanged = true;
315
- }
369
+ const urlError = validateChannelUrl(url);
370
+ if (urlError) {
371
+ errors.push("Channel '" + name + "': " + urlError);
372
+ continue;
316
373
  }
317
- removed++;
318
- affectedKeys.add(canonicalKey);
319
- continue;
320
- }
321
- // Handle add. Creates a new user channel for channels not yet in the lineup. The browse modal sends 'add' only for genuinely new channels (no
322
- // existing canonical). Channels that match existing canonicals appear as 'switch' state in the modal instead.
323
- const url = sanitizeString(entry.url?.trim() ?? "");
324
- const channelSelector = sanitizeString(entry.channelSelector?.trim() ?? "");
325
- if (!name) {
326
- errors.push("Channel entry missing name.");
327
- continue;
328
- }
329
- if (!url) {
330
- errors.push("Channel '" + name + "' missing URL.");
331
- continue;
332
- }
333
- const urlError = validateChannelUrl(url);
334
- if (urlError) {
335
- errors.push("Channel '" + name + "': " + urlError);
336
- continue;
337
- }
338
- const baseKey = generateChannelKey(name);
339
- if (!baseKey) {
340
- errors.push("Could not generate key for channel '" + name + "'.");
341
- continue;
342
- }
343
- const canonicalExists = allKeys.has(baseKey);
344
- const key = (canonicalExists && providerSlug) ? baseKey + "-" + providerSlug : baseKey;
345
- // Skip channels that already exist in the lineup.
346
- if (allKeys.has(key)) {
347
- continue;
348
- }
349
- const newChannel = buildUserChannel(entry, name, url, channelSelector);
350
- // When this is a provider variant (key differs from baseKey), store the canonical relationship explicitly so that buildProviderGroups can
351
- // associate the user channel with its canonical via the canonicalKey field.
352
- if (key !== baseKey) {
353
- newChannel.canonicalKey = baseKey;
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);
354
393
  }
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);
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);
365
402
  }
366
- else if (selectionsChanged) {
367
- await saveProviderSelections();
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();
368
407
  }
369
- // Disable predefined channels that had no alternative provider after removal.
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,41 +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. For predefined channels, use null to signal removal in the delta. For user channels, use undefined.
575
- for (const entry of listing) {
576
- const existing = result.channels[entry.key] ?? {};
577
- const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
578
- existing.channelNumber = clearValue;
579
- result.channels[entry.key] = existing;
580
- affectedKeys.push(entry.key);
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
+ }
581
614
  }
582
- }
583
- else {
584
- // Assign sequential numbers starting from the requested start value.
585
- for (let i = 0; i < listing.length; i++) {
586
- const entry = listing[i];
587
- const num = start + i;
588
- if (num > 99999) {
589
- break;
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);
590
627
  }
591
- const existing = result.channels[entry.key] ?? {};
592
- existing.channelNumber = num;
593
- result.channels[entry.key] = existing;
594
- affectedKeys.push(entry.key);
595
628
  }
596
- }
597
- await saveUserChannels(result.channels);
629
+ });
598
630
  const action = clearMode ? "Cleared" : "Numbered";
599
631
  LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
600
632
  const profiles = getProfiles();
@@ -604,8 +636,7 @@ export function setupChannelRoutes(app) {
604
636
  res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
605
637
  }
606
638
  catch (error) {
607
- LOG.error("Failed to auto-number channels: %s.", formatError(error));
608
- res.status(500).json({ error: "Failed to auto-number channels: " + formatError(error), success: false });
639
+ sendErrorResponse(res, error, "auto-number channels");
609
640
  }
610
641
  });
611
642
  // POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
@@ -614,32 +645,29 @@ export function setupChannelRoutes(app) {
614
645
  try {
615
646
  const body = req.body;
616
647
  const enable = body.enable === true;
617
- // Get visible channels (enabled + available by provider filter).
618
- const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByProvider);
619
- // Load user channels for modification.
620
- const result = await loadUserChannels();
621
- if (result.parseError) {
622
- res.status(400).json({ error: "Cannot update HDHR settings: channels file contains invalid JSON.", success: false });
623
- return;
624
- }
648
+ // Get visible channels (enabled + available by service filter).
649
+ const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByService);
625
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.
626
652
  for (const entry of listing) {
627
653
  const current = entry.channel.hdhrEnabled !== false;
628
654
  // Skip channels that already match the target state.
629
655
  if (current === enable) {
630
656
  continue;
631
657
  }
632
- const existing = result.channels[entry.key] ?? {};
633
- const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
634
- existing.hdhrEnabled = enable ? clearValue : false;
635
- result.channels[entry.key] = existing;
636
658
  affectedKeys.push(entry.key);
637
659
  }
638
660
  if (affectedKeys.length === 0) {
639
661
  res.json({ affected: 0, message: "No changes needed.", success: true });
640
662
  return;
641
663
  }
642
- 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
+ });
643
671
  const action = enable ? "included in" : "excluded from";
644
672
  LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
645
673
  const profiles = getProfiles();
@@ -647,66 +675,110 @@ export function setupChannelRoutes(app) {
647
675
  res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
648
676
  }
649
677
  catch (error) {
650
- LOG.error("Failed to bulk-toggle HDHR: %s.", formatError(error));
651
- res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
678
+ sendErrorResponse(res, error, "toggle HDHR settings");
652
679
  }
653
680
  });
654
- // POST /config/provider-filter - Update the provider filter (enabled provider tags).
655
- app.post("/config/provider-filter", async (req, res) => {
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.
682
+ // transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
683
+ // modal body.
684
+ app.post("/config/channels/bulk-tags", async (req, res) => {
656
685
  try {
657
686
  const body = req.body;
658
- const tags = body.enabledProviders;
687
+ const action = body.action;
688
+ const tag = typeof body.tag === "string" ? body.tag.trim() : "";
689
+ if ((action !== "add") && (action !== "remove")) {
690
+ res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
691
+ return;
692
+ }
693
+ if (!tag) {
694
+ res.status(400).json({ error: "Tag is required.", success: false });
695
+ return;
696
+ }
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())) {
700
+ res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
701
+ return;
702
+ }
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));
704
+ if (error) {
705
+ res.status(400).json({ error, success: false });
706
+ return;
707
+ }
708
+ if (affectedKeys.length === 0) {
709
+ res.json({ affected: 0, message: "No changes needed.", success: true });
710
+ return;
711
+ }
712
+ const verb = (action === "add") ? "added to" : "removed from";
713
+ LOG.info("Bulk tag %s: %s on %d channels.", action, tag, affectedKeys.length);
714
+ const profiles = getProfiles();
715
+ const message = "Tag '" + tag + "' " + verb + " " + String(affectedKeys.length) + " channel(s).";
716
+ res.json({
717
+ affected: affectedKeys.length,
718
+ filterContent: generateTagFilterContent(),
719
+ message,
720
+ modalBody: generateTagManagerBody(),
721
+ patch: buildChannelTablePatch(affectedKeys, profiles),
722
+ success: true
723
+ });
724
+ }
725
+ catch (error) {
726
+ sendErrorResponse(res, error, "update tags");
727
+ }
728
+ });
729
+ // POST /config/service-filter - Update the service filter (enabled service tags).
730
+ app.post("/config/service-filter", async (req, res) => {
731
+ try {
732
+ const body = req.body;
733
+ const tags = body.enabledServices;
659
734
  // Validate tags is an array.
660
735
  if (!Array.isArray(tags)) {
661
- 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 });
662
737
  return;
663
738
  }
664
- // 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
665
740
  // removed via the UI without blocking the request.
666
- const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
667
- const currentTags = new Set(getEnabledProviders());
741
+ const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
742
+ const currentTags = new Set(getEnabledServices());
668
743
  for (const tag of tags) {
669
744
  if (!knownTags.has(tag) && !currentTags.has(tag)) {
670
- res.status(400).json({ error: "Unknown provider tag: " + tag, success: false });
745
+ res.status(400).json({ error: "Unknown service tag: " + tag, success: false });
671
746
  return;
672
747
  }
673
748
  }
674
749
  // Update module-level state.
675
- setEnabledProviders(tags);
750
+ setEnabledServices(tags);
676
751
  // Update runtime CONFIG.
677
- CONFIG.channels.enabledProviders = [...tags];
752
+ CONFIG.channels.enabledServices = [...tags];
678
753
  // Save to config file.
679
- const configResult = await loadUserConfig();
680
- const userConfig = configResult.config;
681
- userConfig.channels ??= {};
682
- userConfig.channels.enabledProviders = tags;
683
- await saveUserConfig(filterDefaults(userConfig));
684
- 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");
685
759
  // Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
686
760
  const { counts, scopeCounts } = buildChannelTableState();
687
- res.json({ enabledProviders: tags, patch: { counts, rows: [], scopeCounts }, success: true });
761
+ res.json({ enabledServices: tags, patch: { counts, rows: [], scopeCounts }, success: true });
688
762
  }
689
763
  catch (error) {
690
- LOG.error("Failed to update provider filter: %s.", formatError(error));
691
- res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
764
+ sendErrorResponse(res, error, "update service filter");
692
765
  }
693
766
  });
694
- // 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.
695
768
  app.post("/config/channels/setup-completed", async (_req, res) => {
696
769
  try {
697
770
  CONFIG.channels.setupCompleted = true;
698
- const configResult = await loadUserConfig();
699
- configResult.config.channels ??= {};
700
- configResult.config.channels.setupCompleted = true;
701
- await saveUserConfig(configResult.config);
771
+ await mutateConfig((config) => {
772
+ config.channels ??= {};
773
+ config.channels.setupCompleted = true;
774
+ });
702
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
703
- // 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.
704
777
  const { counts, scopeCounts } = buildChannelTableState();
705
778
  res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
706
779
  }
707
780
  catch (error) {
708
- LOG.error("Failed to save setup completed state: %s.", formatError(error));
709
- res.status(500).json({ error: "Failed to save setup state: " + formatError(error), success: false });
781
+ sendErrorResponse(res, error, "save setup state");
710
782
  }
711
783
  });
712
784
  // POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
@@ -744,28 +816,26 @@ export function setupChannelRoutes(app) {
744
816
  CONFIG.channels.channelSortDirection = body.sortDirection;
745
817
  }
746
818
  // Persist to config file.
747
- const configResult = await loadUserConfig();
748
- const userConfig = configResult.config;
749
- userConfig.channels ??= {};
750
- userConfig.channels.channelSortDirection = CONFIG.channels.channelSortDirection;
751
- userConfig.channels.channelSortField = CONFIG.channels.channelSortField;
752
- userConfig.channels.visibleColumns = CONFIG.channels.visibleColumns;
753
- 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
+ });
754
825
  res.json({ success: true });
755
826
  }
756
827
  catch (error) {
757
- LOG.error("Failed to update display preferences: %s.", formatError(error));
758
- res.status(500).json({ error: "Failed to update display preferences: " + formatError(error), success: false });
828
+ sendErrorResponse(res, error, "update display preferences");
759
829
  }
760
830
  });
761
- // POST /config/provider-bulk-assign - Set all channels to a specific provider.
762
- 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) => {
763
833
  try {
764
834
  const body = req.body;
765
- const providerTag = body.provider?.trim();
766
- // Validate provider tag.
767
- if (!providerTag) {
768
- 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 });
769
839
  return;
770
840
  }
771
841
  let affected = 0;
@@ -774,17 +844,17 @@ export function setupChannelRoutes(app) {
774
844
  // Iterate all channels and set those with a matching variant.
775
845
  const listing = getChannelListing();
776
846
  for (const entry of listing) {
777
- const group = getProviderGroup(entry.key);
847
+ const group = getServiceGroup(entry.key);
778
848
  if (!group || (group.variants.length <= 1)) {
779
849
  continue;
780
850
  }
781
- // Find a variant matching the requested provider tag.
782
- 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));
783
853
  if (matchingVariant) {
784
854
  // Snapshot the current selection before overwriting so the client can offer undo.
785
- const currentVariant = getProviderSelection(entry.key);
855
+ const currentVariant = getServiceSelection(entry.key);
786
856
  previousSelections[entry.key] = currentVariant ?? null;
787
- setProviderSelection(entry.key, matchingVariant.key);
857
+ setServiceSelection(entry.key, matchingVariant.key);
788
858
  affected++;
789
859
  // Collect the resolved profile name for client-side UI update.
790
860
  const resolvedChannel = getResolvedChannel(matchingVariant.key);
@@ -792,17 +862,16 @@ export function setupChannelRoutes(app) {
792
862
  }
793
863
  }
794
864
  // Save to disk.
795
- await saveProviderSelections();
796
- 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);
797
867
  res.json({ affected, previousSelections, selections, success: true, total: listing.length });
798
868
  }
799
869
  catch (error) {
800
- LOG.error("Failed to bulk assign provider: %s.", formatError(error));
801
- res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
870
+ sendErrorResponse(res, error, "bulk assign service");
802
871
  }
803
872
  });
804
- // POST /config/provider-bulk-restore - Restore previous provider selections (undo bulk assign).
805
- 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) => {
806
875
  try {
807
876
  const body = req.body;
808
877
  const previousSelections = body.selections;
@@ -813,21 +882,21 @@ export function setupChannelRoutes(app) {
813
882
  let restored = 0;
814
883
  const selections = {};
815
884
  for (const [key, variantKey] of Object.entries(previousSelections)) {
816
- const group = getProviderGroup(key);
885
+ const group = getServiceGroup(key);
817
886
  if (!group) {
818
887
  continue;
819
888
  }
820
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.
821
890
  if (variantKey === null) {
822
- setProviderSelection(key, key);
891
+ setServiceSelection(key, key);
823
892
  }
824
893
  else {
825
- // 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.
826
895
  const isValid = group.variants.some((v) => (v.key === variantKey));
827
896
  if (!isValid) {
828
897
  continue;
829
898
  }
830
- setProviderSelection(key, variantKey);
899
+ setServiceSelection(key, variantKey);
831
900
  }
832
901
  restored++;
833
902
  // Build the same selection response format as bulk assign for client-side UI updates.
@@ -836,13 +905,12 @@ export function setupChannelRoutes(app) {
836
905
  selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
837
906
  }
838
907
  // Save to disk.
839
- await saveProviderSelections();
908
+ await saveServiceSelections();
840
909
  LOG.info("Bulk restore: %d channel(s) reverted.", restored);
841
910
  res.json({ restored, selections, success: true });
842
911
  }
843
912
  catch (error) {
844
- LOG.error("Failed to bulk restore providers: %s.", formatError(error));
845
- res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
913
+ sendErrorResponse(res, error, "bulk restore services");
846
914
  }
847
915
  });
848
916
  // POST /config/channels - Handle channel add, edit, delete, and revert operations. Returns JSON response.
@@ -866,16 +934,12 @@ export function setupChannelRoutes(app) {
866
934
  res.status(400).json({ message: "Cannot revert '" + key + "': no override exists.", success: false });
867
935
  return;
868
936
  }
869
- // Remove the override.
870
- const result = await loadUserChannels();
871
- if (result.parseError) {
872
- res.status(400).json({ message: "Cannot revert channel: channels file contains invalid JSON.", success: false });
873
- return;
874
- }
875
- // Check if the override contains M3U-relevant fields before removing it.
876
- const revertHint = playlistHintForStored(result.channels[key]);
877
- Reflect.deleteProperty(result.channels, key);
878
- 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
+ });
879
943
  LOG.info("Channel '%s' reverted to predefined defaults.", key);
880
944
  res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
881
945
  patch: buildChannelTablePatch([key], profiles), success: true });
@@ -890,7 +954,7 @@ export function setupChannelRoutes(app) {
890
954
  }
891
955
  const field = body.field;
892
956
  const value = sanitizeString(body.value ?? "");
893
- if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId")) {
957
+ if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId") && (field !== "tags")) {
894
958
  res.status(400).json({ message: "Invalid inline-edit field.", success: false });
895
959
  return;
896
960
  }
@@ -902,29 +966,31 @@ export function setupChannelRoutes(app) {
902
966
  return;
903
967
  }
904
968
  }
905
- // Load and update the channel. For predefined channels, store a delta override with null to clear fields. For user channels, use undefined to delete.
906
- const result = await loadUserChannels();
907
- if (result.parseError) {
908
- res.status(400).json({ message: "Cannot edit: channels file contains invalid JSON.", success: false });
909
- return;
910
- }
911
- // Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet.
912
- const stored = result.channels[key] ?? {};
913
- const clearValue = isPredefinedChannel(key) ? null : undefined;
914
- if (field === "channelNumber") {
915
- stored.channelNumber = value ? parseInt(value, 10) : clearValue;
916
- }
917
- else if (field === "hdhrEnabled") {
918
- // hdhrEnabled: "true" means enabled (clear the override), "false" means disabled. For predefined channels, null clears the delta. For user channels,
919
- // delete the field entirely to revert to the default (included).
920
- stored.hdhrEnabled = (value === "false") ? false : clearValue;
921
- }
922
- else {
923
- stored.stationId = value || clearValue;
924
- }
925
- result.channels[key] = stored;
926
- await saveUserChannels(result.channels);
927
- const fieldLabels = { channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID" };
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
+ });
991
+ const fieldLabels = {
992
+ channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
993
+ };
928
994
  const fieldLabel = fieldLabels[field] ?? field;
929
995
  const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
930
996
  LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
@@ -943,13 +1009,9 @@ export function setupChannelRoutes(app) {
943
1009
  return;
944
1010
  }
945
1011
  // Delete the channel.
946
- const result = await loadUserChannels();
947
- if (result.parseError) {
948
- res.status(400).json({ message: "Cannot delete channel: channels file contains invalid JSON.", success: false });
949
- return;
950
- }
951
- Reflect.deleteProperty(result.channels, key);
952
- await saveUserChannels(result.channels);
1012
+ await mutateChannels((channels) => {
1013
+ Reflect.deleteProperty(channels, key);
1014
+ });
953
1015
  LOG.info("User channel '%s' deleted.", key);
954
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
955
1017
  // the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
@@ -973,10 +1035,17 @@ export function setupChannelRoutes(app) {
973
1035
  const name = sanitizeString(body.name ?? "");
974
1036
  const url = sanitizeString(body.url ?? "");
975
1037
  const profile = sanitizeString(body.profile ?? "");
1038
+ const guideTitle = sanitizeString(body.guideTitle ?? "");
1039
+ const logoUrl = sanitizeString(body.logoUrl ?? "");
976
1040
  const stationId = sanitizeString(body.stationId ?? "");
977
1041
  const channelSelector = sanitizeString(body.channelSelector ?? "");
978
1042
  const channelNumberStr = sanitizeString(body.channelNumber ?? "");
979
1043
  const hdhrEnabled = body.hdhrEnabled !== "false";
1044
+ // Parse tags from comma-separated input. Trim, filter empty, deduplicate, and sort case-insensitively.
1045
+ const tagsRaw = sanitizeString(body.tags ?? "");
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
+ [];
980
1049
  // Validate channel number if provided.
981
1050
  const channelNumberError = validateChannelNumber(channelNumberStr, key);
982
1051
  if (channelNumberError) {
@@ -1009,18 +1078,6 @@ export function setupChannelRoutes(app) {
1009
1078
  res.status(400).json({ errors: formErrors, success: false });
1010
1079
  return;
1011
1080
  }
1012
- // Load existing user channels.
1013
- const result = await loadUserChannels();
1014
- if (result.parseError) {
1015
- // If channels file is corrupt, start fresh on add (which will create a valid file).
1016
- if (action === "add") {
1017
- result.channels = {};
1018
- }
1019
- else {
1020
- res.status(400).json({ message: "Cannot edit channel: channels file contains invalid JSON.", success: false });
1021
- return;
1022
- }
1023
- }
1024
1081
  let playlistChanged = false;
1025
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.
1026
1083
  const predefinedBase = getPredefinedChannel(key);
@@ -1030,7 +1087,9 @@ export function setupChannelRoutes(app) {
1030
1087
  const formValues = {
1031
1088
  channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
1032
1089
  channelSelector,
1090
+ guideTitle,
1033
1091
  hdhrEnabled,
1092
+ logoUrl,
1034
1093
  name,
1035
1094
  profile,
1036
1095
  stationId,
@@ -1045,17 +1104,22 @@ export function setupChannelRoutes(app) {
1045
1104
  }
1046
1105
  return (field === "channelNumber") ? val : val ?? "";
1047
1106
  };
1048
- // First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
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
1049
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
1050
- // values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
1051
- const resolvedKey = resolveProviderKey(key);
1109
+ // values match the displayChannel exactly, the user saved without modification - no override should be created, and any existing override is preserved.
1110
+ // Tags are compared separately (array comparison) after the scalar fields.
1111
+ const resolvedKey = resolveServiceKey(key);
1052
1112
  const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
1053
- if (Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field))) {
1113
+ const displayTags = getChannelEffectiveTags(displayChannel);
1114
+ const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
1115
+ const tagsUnchanged = JSON.stringify(tags) === JSON.stringify(displayTags);
1116
+ if (scalarUnchanged && tagsUnchanged) {
1054
1117
  res.json({ key, message: "No changes to save.", success: true });
1055
1118
  return;
1056
1119
  }
1057
1120
  // Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
1058
- // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
1121
+ // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear). Tags are handled
1122
+ // separately from the scalar loop since they require array comparison.
1059
1123
  const delta = {};
1060
1124
  let hasChanges = false;
1061
1125
  for (const field of Object.keys(formValues)) {
@@ -1065,15 +1129,26 @@ export function setupChannelRoutes(app) {
1065
1129
  hasChanges = true;
1066
1130
  }
1067
1131
  }
1068
- // Helper to check if form values match a given channel's properties.
1069
- const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field));
1132
+ // Tags delta: compare the submitted tags against the predefined base's vocabulary-filtered tags. Using effective tags (not raw) ensures that editing an
1133
+ // unrelated field while a predefined tag is deleted from the vocabulary doesn't spuriously bake the deletion into the channel's stored delta.
1134
+ const predefinedEffectiveTags = getChannelEffectiveTags(predefinedBase);
1135
+ if (JSON.stringify(tags) !== JSON.stringify(predefinedEffectiveTags)) {
1136
+ delta.tags = (tags.length > 0) ? tags : null;
1137
+ hasChanges = true;
1138
+ }
1139
+ // Helper to check if form values (scalars + tags) match a given channel's properties. Tags use vocabulary-filtered comparison so variant matching works
1140
+ // correctly when predefined tags are deleted from the vocabulary.
1141
+ const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
1142
+ (JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
1070
1143
  if (!hasChanges) {
1071
- // 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
1072
1145
  // implicit revert by removing the override and returning the predefined row HTML.
1073
1146
  if (isUserChannel(key)) {
1074
- const implicitRevertHint = playlistHintForStored(result.channels[key]);
1075
- Reflect.deleteProperty(result.channels, key);
1076
- await saveUserChannels(result.channels);
1147
+ let implicitRevertHint = "";
1148
+ await mutateChannels((channels) => {
1149
+ implicitRevertHint = playlistHintForStored(channels[key]);
1150
+ Reflect.deleteProperty(channels, key);
1151
+ });
1077
1152
  LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
1078
1153
  res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
1079
1154
  patch: buildChannelTablePatch([key], profiles), success: true });
@@ -1082,15 +1157,15 @@ export function setupChannelRoutes(app) {
1082
1157
  res.json({ key, message: "No changes to save.", success: true });
1083
1158
  return;
1084
1159
  }
1085
- // 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
1086
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
1087
- // 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,
1088
1163
  // not a new customization. We resolve each variant against pure PREDEFINED data (not the user-overridden channelsRef) to avoid contamination from the
1089
1164
  // current override.
1090
- const providerGroup = getProviderGroup(key);
1165
+ const serviceGroup = getServiceGroup(key);
1091
1166
  let matchedVariantKey;
1092
- if (providerGroup && isUserChannel(key)) {
1093
- for (const variant of providerGroup.variants) {
1167
+ if (serviceGroup && isUserChannel(key)) {
1168
+ for (const variant of serviceGroup.variants) {
1094
1169
  // Skip the canonical entry (already handled by the !hasChanges check above) and :predefined entries (synthetic entries for override UI).
1095
1170
  if ((variant.key === key) || variant.key.includes(":")) {
1096
1171
  continue;
@@ -1104,29 +1179,38 @@ export function setupChannelRoutes(app) {
1104
1179
  }
1105
1180
  }
1106
1181
  if (matchedVariantKey) {
1107
- // Form values match a known variant — revert the override and switch back to that variant.
1108
- const variantRevertHint = playlistHintForStored(result.channels[key]);
1109
- Reflect.deleteProperty(result.channels, key);
1110
- setProviderSelection(key, matchedVariantKey);
1111
- 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);
1112
1189
  LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
1113
1190
  res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
1114
1191
  patch: buildChannelTablePatch([key], profiles), success: true });
1115
1192
  return;
1116
1193
  }
1117
- // No variant match — store the delta and switch the provider selection to the canonical key (the custom override). This ensures the provider dropdown shows
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
1118
1195
  // "Custom" after saving, which is the expected behavior when a user customizes a predefined channel.
1119
- setProviderSelection(key, key);
1120
- result.channels[key] = delta;
1196
+ setServiceSelection(key, key);
1197
+ await mutateChannels((channels) => {
1198
+ channels[key] = delta;
1199
+ });
1121
1200
  playlistChanged = M3U_FIELDS.some((f) => f in delta);
1122
1201
  }
1123
1202
  else {
1124
- // User-defined channel or add: build a full channel object. For edits, snapshot the old channel to detect M3U-relevant changes.
1125
- const oldChannel = ((action === "edit") && (key in result.channels)) ? result.channels[key] : undefined;
1203
+ // User-defined channel or add: build a full channel object.
1126
1204
  const channel = {
1127
1205
  name,
1128
1206
  url
1129
1207
  };
1208
+ if (guideTitle) {
1209
+ channel.guideTitle = guideTitle;
1210
+ }
1211
+ if (logoUrl) {
1212
+ channel.logoUrl = logoUrl;
1213
+ }
1130
1214
  if (profile) {
1131
1215
  channel.profile = profile;
1132
1216
  }
@@ -1139,17 +1223,27 @@ export function setupChannelRoutes(app) {
1139
1223
  if (channelNumberStr) {
1140
1224
  channel.channelNumber = parseInt(channelNumberStr, 10);
1141
1225
  }
1226
+ if (tags.length > 0) {
1227
+ channel.tags = tags;
1228
+ }
1142
1229
  // Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
1143
1230
  if (!hdhrEnabled) {
1144
1231
  channel.hdhrEnabled = false;
1145
1232
  }
1146
- result.channels[key] = channel;
1147
- // For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
1148
- playlistChanged = (action === "add") ||
1149
- ((oldChannel !== undefined) &&
1150
- 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
+ });
1151
1246
  }
1152
- await saveUserChannels(result.channels);
1153
1247
  // Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
1154
1248
  if (stationId) {
1155
1249
  updateChannelLogo(name || key, stationId);
@@ -1159,18 +1253,252 @@ export function setupChannelRoutes(app) {
1159
1253
  // Generate HTML for the channel row so the client can update the DOM without a full page reload.
1160
1254
  // Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
1161
1255
  const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
1162
- // 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().
1163
1259
  res.json({
1164
1260
  isNew: action === "add",
1165
1261
  key,
1166
1262
  message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
1167
1263
  patch: buildChannelTablePatch([key], profiles),
1264
+ serviceWarning,
1265
+ success: true
1266
+ });
1267
+ }
1268
+ catch (error) {
1269
+ sendErrorResponse(res, error, "save channel");
1270
+ }
1271
+ });
1272
+ // Tag Management Endpoints.
1273
+ // GET /config/tags - Returns the tag vocabulary and registry state. The active vocabulary is the computed merge of predefined tags (minus deleted) plus user
1274
+ // tags. The registry contains the raw user state for the tag management UI.
1275
+ app.get("/config/tags", (_req, res) => {
1276
+ res.json({
1277
+ active: getActiveTagVocabulary(),
1278
+ predefined: [...PREDEFINED_TAGS],
1279
+ registry: getTagRegistry(),
1280
+ success: true
1281
+ });
1282
+ });
1283
+ // POST /config/tags - Create a new user tag. Validates the tag name (lowercase alphanumeric + hyphens, max 30 chars) and checks for duplicates against the
1284
+ // active vocabulary and deleted predefined tags (which should be restored, not re-created).
1285
+ app.post("/config/tags", async (req, res) => {
1286
+ try {
1287
+ const body = req.body;
1288
+ const tag = typeof body.tag === "string" ? body.tag.trim() : "";
1289
+ if (tag.length === 0) {
1290
+ res.status(400).json({ error: "Tag name is required.", success: false });
1291
+ return;
1292
+ }
1293
+ if (tag.length > 30) {
1294
+ res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1295
+ return;
1296
+ }
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 });
1299
+ return;
1300
+ }
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())) {
1304
+ res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
1305
+ return;
1306
+ }
1307
+ // Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
1308
+ const registry = getTagRegistry();
1309
+ if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
1310
+ res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
1311
+ return;
1312
+ }
1313
+ // Add the tag to the user registry and persist. No channels are affected (new tag has no assignments yet).
1314
+ registry.tags.push(tag);
1315
+ setTagRegistry(registry);
1316
+ await saveTagRegistry();
1317
+ LOG.info("Created tag: %s.", tag);
1318
+ res.json({
1319
+ active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1320
+ registry: getTagRegistry(), success: true
1321
+ });
1322
+ }
1323
+ catch (error) {
1324
+ sendErrorResponse(res, error, "create tag");
1325
+ }
1326
+ });
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
1328
+ // user-created tags, the tag is removed from the user registry. In both cases, the tag is stripped from every channel that has it — predefined channels via delta
1329
+ // override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
1330
+ app.delete("/config/tags/:tag", async (req, res) => {
1331
+ try {
1332
+ const tag = req.params.tag?.trim() ?? "";
1333
+ if (tag.length === 0) {
1334
+ res.status(400).json({ error: "Tag name is required.", success: false });
1335
+ return;
1336
+ }
1337
+ const registry = getTagRegistry();
1338
+ const isPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, tag));
1339
+ const isUserTag = registry.tags.some((t) => tagsMatch(t, tag));
1340
+ if (!isPredefined && !isUserTag) {
1341
+ res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
1342
+ return;
1343
+ }
1344
+ if (isPredefined) {
1345
+ // Find the canonical predefined form for storage.
1346
+ const canonicalTag = PREDEFINED_TAGS.find((t) => tagsMatch(t, tag)) ?? tag;
1347
+ // Already deleted — no-op, return current state without unnecessary I/O.
1348
+ if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
1349
+ res.json({
1350
+ active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1351
+ registry: getTagRegistry(), success: true
1352
+ });
1353
+ return;
1354
+ }
1355
+ registry.deletedTags.push(canonicalTag);
1356
+ }
1357
+ else {
1358
+ // Remove from user tags.
1359
+ registry.tags = registry.tags.filter((t) => !tagsMatch(t, tag));
1360
+ }
1361
+ setTagRegistry(registry);
1362
+ // Cascade: strip the deleted tag from all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
1363
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.some((t) => tagsMatch(t, tag)) === true, (tags) => tags.filter((t) => !tagsMatch(t, tag)));
1364
+ if (error) {
1365
+ res.status(400).json({ error, success: false });
1366
+ return;
1367
+ }
1368
+ LOG.info("Deleted tag '%s' from vocabulary and %d channel assignments.", tag, affectedKeys.length);
1369
+ const profiles = getProfiles();
1370
+ res.json({
1371
+ active: getActiveTagVocabulary(),
1372
+ filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1373
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1374
+ registry: getTagRegistry(),
1375
+ success: true
1376
+ });
1377
+ }
1378
+ catch (error) {
1379
+ sendErrorResponse(res, error, "delete tag");
1380
+ }
1381
+ });
1382
+ // POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
1383
+ // cascade-restores the tag on predefined channels whose definition includes it. This reverses the cascade delete — predefined channels go back to their default
1384
+ // tag assignments. User channels are not affected (their tag assignments were permanently removed during cascade delete with no source of truth to restore from).
1385
+ app.post("/config/tags/restore", async (req, res) => {
1386
+ try {
1387
+ const body = req.body;
1388
+ const tag = typeof body.tag === "string" ? body.tag.trim() : "";
1389
+ if (tag.length === 0) {
1390
+ res.status(400).json({ error: "Tag name is required.", success: false });
1391
+ return;
1392
+ }
1393
+ const registry = getTagRegistry();
1394
+ if (!registry.deletedTags.some((t) => tagsMatch(t, tag))) {
1395
+ res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
1396
+ return;
1397
+ }
1398
+ registry.deletedTags = registry.deletedTags.filter((t) => !tagsMatch(t, tag));
1399
+ setTagRegistry(registry);
1400
+ // Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
1401
+ // delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
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;
1405
+ const { affectedKeys, error } = await transformChannelTags((entry) => {
1406
+ const predefined = getPredefinedChannel(entry.key);
1407
+ return (predefined?.tags?.some((t) => tagsMatch(t, tag)) === true) &&
1408
+ (entry.channel.tags?.some((t) => tagsMatch(t, tag)) !== true);
1409
+ }, (tags) => [...tags, canonicalTag]);
1410
+ if (error) {
1411
+ res.status(400).json({ error, success: false });
1412
+ return;
1413
+ }
1414
+ LOG.info("Restored predefined tag '%s' on %d channels.", tag, affectedKeys.length);
1415
+ const profiles = getProfiles();
1416
+ res.json({
1417
+ active: getActiveTagVocabulary(),
1418
+ filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1419
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1420
+ registry: getTagRegistry(),
1421
+ success: true
1422
+ });
1423
+ }
1424
+ catch (error) {
1425
+ sendErrorResponse(res, error, "restore tag");
1426
+ }
1427
+ });
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
1429
+ // deletedTags and the new name is added to user tags. For user tags, the old name is replaced with the new name. All channels with the old tag get the
1430
+ // new tag substituted in their stored data (predefined channels via delta, user channels via direct modification).
1431
+ app.post("/config/tags/rename", async (req, res) => {
1432
+ try {
1433
+ const body = req.body;
1434
+ const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim() : "";
1435
+ const newTag = typeof body.newTag === "string" ? body.newTag.trim() : "";
1436
+ if (!oldTag || !newTag) {
1437
+ res.status(400).json({ error: "Both old and new tag names are required.", success: false });
1438
+ return;
1439
+ }
1440
+ // Exact comparison — case-only renames (e.g., "sports" → "Sports") are valid since tags are freeform with preserved casing.
1441
+ if (oldTag === newTag) {
1442
+ res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
1443
+ return;
1444
+ }
1445
+ if (!(/^[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?$/).test(newTag)) {
1446
+ res.status(400).json({
1447
+ error: "Tag name must start and end with a letter or number, and contain only letters, numbers, spaces, and hyphens.", success: false
1448
+ });
1449
+ return;
1450
+ }
1451
+ if (newTag.length > 30) {
1452
+ res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1453
+ return;
1454
+ }
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())) {
1458
+ res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
1459
+ return;
1460
+ }
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)) {
1464
+ res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
1465
+ return;
1466
+ }
1467
+ // Update the tag registry: replace old with new in the appropriate list.
1468
+ const registry = getTagRegistry();
1469
+ const oldIsPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, oldTag));
1470
+ if (oldIsPredefined) {
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);
1475
+ }
1476
+ registry.tags.push(newTag);
1477
+ }
1478
+ else {
1479
+ // User tag: replace in the user tags list.
1480
+ registry.tags = registry.tags.map((t) => tagsMatch(t, oldTag) ? newTag : t);
1481
+ }
1482
+ setTagRegistry(registry);
1483
+ // Cascade: substitute the old tag with the new tag across all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
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));
1485
+ if (error) {
1486
+ res.status(400).json({ error, success: false });
1487
+ return;
1488
+ }
1489
+ LOG.info("Renamed tag '%s' to '%s' across %d channels.", oldTag, newTag, affectedKeys.length);
1490
+ const profiles = getProfiles();
1491
+ res.json({
1492
+ active: getActiveTagVocabulary(),
1493
+ filterContent: generateTagFilterContent(),
1494
+ modalBody: generateTagManagerBody(),
1495
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1496
+ registry: getTagRegistry(),
1168
1497
  success: true
1169
1498
  });
1170
1499
  }
1171
1500
  catch (error) {
1172
- LOG.error("Failed to save channel: %s.", formatError(error));
1173
- res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
1501
+ sendErrorResponse(res, error, "rename tag");
1174
1502
  }
1175
1503
  });
1176
1504
  }