prismcast 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +6 -6
  2. package/dist/app.js +15 -5
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +4 -6
  5. package/dist/browser/cdp.js +13 -17
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.js +3 -3
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/precaching.d.ts +1 -1
  10. package/dist/browser/precaching.js +23 -23
  11. package/dist/browser/precaching.js.map +1 -1
  12. package/dist/browser/tuning/directv.js +4 -7
  13. package/dist/browser/tuning/directv.js.map +1 -1
  14. package/dist/browser/tuning/fox.js +1 -1
  15. package/dist/browser/tuning/fox.js.map +1 -1
  16. package/dist/browser/tuning/hbo.js +1 -1
  17. package/dist/browser/tuning/hbo.js.map +1 -1
  18. package/dist/browser/tuning/hulu.js +3 -3
  19. package/dist/browser/tuning/hulu.js.map +1 -1
  20. package/dist/browser/tuning/sling.js +2 -2
  21. package/dist/browser/tuning/sling.js.map +1 -1
  22. package/dist/channels/index.d.ts +1 -1
  23. package/dist/channels/index.js +416 -416
  24. package/dist/channels/index.js.map +1 -1
  25. package/dist/config/health.d.ts +3 -3
  26. package/dist/config/health.js +3 -3
  27. package/dist/config/index.js +2 -2
  28. package/dist/config/index.js.map +1 -1
  29. package/dist/config/persistence.d.ts +59 -0
  30. package/dist/config/persistence.js +127 -0
  31. package/dist/config/persistence.js.map +1 -0
  32. package/dist/config/servicePacks.d.ts +46 -0
  33. package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
  34. package/dist/config/servicePacks.js.map +1 -0
  35. package/dist/config/services.d.ts +202 -0
  36. package/dist/config/{providers.js → services.js} +277 -210
  37. package/dist/config/services.js.map +1 -0
  38. package/dist/config/sites.js +61 -61
  39. package/dist/config/sites.js.map +1 -1
  40. package/dist/config/userChannels.d.ts +36 -27
  41. package/dist/config/userChannels.js +300 -212
  42. package/dist/config/userChannels.js.map +1 -1
  43. package/dist/config/userConfig.d.ts +11 -9
  44. package/dist/config/userConfig.js +53 -60
  45. package/dist/config/userConfig.js.map +1 -1
  46. package/dist/config/userProfiles.d.ts +31 -15
  47. package/dist/config/userProfiles.js +139 -96
  48. package/dist/config/userProfiles.js.map +1 -1
  49. package/dist/hdhr/channelMap.d.ts +1 -1
  50. package/dist/hdhr/channelMap.js +4 -7
  51. package/dist/hdhr/channelMap.js.map +1 -1
  52. package/dist/hdhr/index.js +6 -9
  53. package/dist/hdhr/index.js.map +1 -1
  54. package/dist/native/index.js +3 -3
  55. package/dist/native/index.js.map +1 -1
  56. package/dist/native/intercept.js +86 -89
  57. package/dist/native/intercept.js.map +1 -1
  58. package/dist/native/probe.js +2 -2
  59. package/dist/native/probe.js.map +1 -1
  60. package/dist/native/proxy.js +4 -4
  61. package/dist/native/proxy.js.map +1 -1
  62. package/dist/routes/assets.js +3 -3
  63. package/dist/routes/assets.js.map +1 -1
  64. package/dist/routes/auth.js +3 -3
  65. package/dist/routes/auth.js.map +1 -1
  66. package/dist/routes/config/channels/index.d.ts +1 -1
  67. package/dist/routes/config/channels/index.js +1 -1
  68. package/dist/routes/config/channels/index.js.map +1 -1
  69. package/dist/routes/config/channels/routes.js +488 -460
  70. package/dist/routes/config/channels/routes.js.map +1 -1
  71. package/dist/routes/config/channels/table.d.ts +4 -4
  72. package/dist/routes/config/channels/table.js +206 -147
  73. package/dist/routes/config/channels/table.js.map +1 -1
  74. package/dist/routes/config/index.d.ts +2 -2
  75. package/dist/routes/config/index.js +3 -3
  76. package/dist/routes/config/index.js.map +1 -1
  77. package/dist/routes/config/{providers.js → services.js} +44 -39
  78. package/dist/routes/config/services.js.map +1 -0
  79. package/dist/routes/config/settings.js +47 -29
  80. package/dist/routes/config/settings.js.map +1 -1
  81. package/dist/routes/debug.js +5 -7
  82. package/dist/routes/debug.js.map +1 -1
  83. package/dist/routes/health.js +1 -1
  84. package/dist/routes/health.js.map +1 -1
  85. package/dist/routes/index.js +2 -2
  86. package/dist/routes/index.js.map +1 -1
  87. package/dist/routes/logs.js +4 -4
  88. package/dist/routes/logs.js.map +1 -1
  89. package/dist/routes/playlist.d.ts +4 -4
  90. package/dist/routes/playlist.js +56 -44
  91. package/dist/routes/playlist.js.map +1 -1
  92. package/dist/routes/root/content.js +58 -58
  93. package/dist/routes/root/content.js.map +1 -1
  94. package/dist/routes/root/scripts/channels.js +529 -572
  95. package/dist/routes/root/scripts/channels.js.map +1 -1
  96. package/dist/routes/root/scripts/config.js +838 -992
  97. package/dist/routes/root/scripts/config.js.map +1 -1
  98. package/dist/routes/root/scripts/shared.js +431 -197
  99. package/dist/routes/root/scripts/shared.js.map +1 -1
  100. package/dist/routes/root/scripts/status.js +247 -196
  101. package/dist/routes/root/scripts/status.js.map +1 -1
  102. package/dist/routes/root/styles.js +12 -4
  103. package/dist/routes/root/styles.js.map +1 -1
  104. package/dist/routes/services.d.ts +6 -0
  105. package/dist/routes/{providers.js → services.js} +43 -43
  106. package/dist/routes/services.js.map +1 -0
  107. package/dist/routes/ui.js +2 -0
  108. package/dist/routes/ui.js.map +1 -1
  109. package/dist/service/commands.js +5 -8
  110. package/dist/service/commands.js.map +1 -1
  111. package/dist/service/generators.d.ts +1 -1
  112. package/dist/service/generators.js +143 -41
  113. package/dist/service/generators.js.map +1 -1
  114. package/dist/streaming/hls.d.ts +4 -4
  115. package/dist/streaming/hls.js +24 -23
  116. package/dist/streaming/hls.js.map +1 -1
  117. package/dist/streaming/hlsResume.js +1 -1
  118. package/dist/streaming/hlsResume.js.map +1 -1
  119. package/dist/streaming/monitor.d.ts +2 -2
  120. package/dist/streaming/monitor.js +19 -13
  121. package/dist/streaming/monitor.js.map +1 -1
  122. package/dist/streaming/preroll.js +1 -1
  123. package/dist/streaming/preroll.js.map +1 -1
  124. package/dist/streaming/registry.js +4 -8
  125. package/dist/streaming/registry.js.map +1 -1
  126. package/dist/streaming/setup.d.ts +2 -2
  127. package/dist/streaming/setup.js +23 -22
  128. package/dist/streaming/setup.js.map +1 -1
  129. package/dist/streaming/showInfo.js +8 -10
  130. package/dist/streaming/showInfo.js.map +1 -1
  131. package/dist/streaming/statusEmitter.d.ts +4 -3
  132. package/dist/streaming/statusEmitter.js +3 -3
  133. package/dist/streaming/statusEmitter.js.map +1 -1
  134. package/dist/types/channels.d.ts +15 -11
  135. package/dist/types/config.d.ts +2 -2
  136. package/dist/types/index.d.ts +2 -2
  137. package/dist/types/profiles.d.ts +9 -9
  138. package/dist/types/selection.d.ts +1 -1
  139. package/dist/types/shared.d.ts +1 -1
  140. package/dist/types/streaming.d.ts +1 -1
  141. package/dist/utils/chromeFetch.js +1 -1
  142. package/dist/utils/debugFilter.js +3 -3
  143. package/dist/utils/debugFilter.js.map +1 -1
  144. package/dist/utils/fileLogger.js +4 -3
  145. package/dist/utils/fileLogger.js.map +1 -1
  146. package/dist/utils/format.d.ts +7 -1
  147. package/dist/utils/format.js +21 -1
  148. package/dist/utils/format.js.map +1 -1
  149. package/dist/utils/logger.js +17 -27
  150. package/dist/utils/logger.js.map +1 -1
  151. package/dist/utils/morganStream.js +2 -2
  152. package/dist/utils/morganStream.js.map +1 -1
  153. package/dist/utils/platform.js +3 -2
  154. package/dist/utils/platform.js.map +1 -1
  155. package/dist/utils/streamContext.d.ts +7 -0
  156. package/dist/utils/streamContext.js +10 -1
  157. package/dist/utils/streamContext.js.map +1 -1
  158. package/dist/utils/version.js +4 -4
  159. package/dist/utils/version.js.map +1 -1
  160. package/package.json +5 -7
  161. package/dist/config/providerPacks.d.ts +0 -46
  162. package/dist/config/providerPacks.js.map +0 -1
  163. package/dist/config/providers.d.ts +0 -175
  164. package/dist/config/providers.js.map +0 -1
  165. package/dist/routes/config/providers.js.map +0 -1
  166. package/dist/routes/providers.d.ts +0 -6
  167. package/dist/routes/providers.js.map +0 -1
  168. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -3,12 +3,13 @@
3
3
  * userChannels.ts: User channel file management for PrismCast.
4
4
  */
5
5
  import { CHANNEL_IDENTITY_FIELDS, PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../channels/index.js";
6
- import { LOG, containsNonPrintable, sanitizeString, stringifySorted } from "../utils/index.js";
7
- import { buildProviderGroups, getAllProviderTags, getProviderSelections, getResolvedChannel, isChannelAvailableByProvider, isProviderVariant, resolveProviderKey, setEnabledProviders, setProviderSelections } from "./providers.js";
8
- import { getChannelsFilePath, getDataDir } from "./paths.js";
9
- import { loadUserConfig, saveUserConfig } from "./userConfig.js";
6
+ import { FileStoreParseError, createFileStore } from "./persistence.js";
7
+ import { LOG, containsNonPrintable, sanitizeString } from "../utils/index.js";
8
+ import { buildServiceGroups, getAllServiceTags, getResolvedChannel, getServiceSelections, isChannelAvailableByService, isServiceVariant, resolveServiceKey, setEnabledServices, setServiceSelections } from "./services.js";
10
9
  import { CONFIG } from "./index.js";
11
10
  import fs from "node:fs";
11
+ import { getChannelsFilePath } from "./paths.js";
12
+ import { mutateConfig } from "./userConfig.js";
12
13
  const { promises: fsPromises } = fs;
13
14
  /* The channels file path is resolved via the centralized paths module (config/paths.ts). The data directory is initialized at startup before channel loading.
14
15
  */
@@ -43,80 +44,63 @@ export function getChannelsParseErrorMessage() {
43
44
  return userChannelsParseErrorMessage;
44
45
  }
45
46
  /**
46
- * Loads user channels from the channels file. Returns an empty map if the file doesn't exist, and sets parseError if the file exists but contains invalid JSON.
47
- * The file can contain metadata keys (`providerSelections`, `tagRegistry`) which are extracted separately from channel data.
48
- * @returns The loaded channels with parse status, provider selections, and tag registry.
47
+ * Parses raw channels.json content into the compound data type. Extracts channel entries, service selections, and tag registry from the top-level JSON object.
48
+ * Also applies the legacy "provider" to "service" field migration.
49
+ * @param raw - The raw JSON string from the file.
50
+ * @returns The parsed compound data.
49
51
  */
50
- export async function loadUserChannels() {
51
- try {
52
- const content = await fsPromises.readFile(getChannelsFilePath(), "utf-8");
53
- try {
54
- const parsed = JSON.parse(content);
55
- // Extract metadata keys (providerSelections, tagRegistry) — these are not channels, they're organizational state stored alongside channel data.
56
- const providerSelections = {};
57
- const tagRegistry = { deletedTags: [], tags: [] };
58
- const channels = {};
59
- for (const [key, value] of Object.entries(parsed)) {
60
- if (key === "providerSelections") {
61
- // Copy provider selections if it's an object.
62
- if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
63
- for (const [selKey, selValue] of Object.entries(value)) {
64
- if (typeof selValue === "string") {
65
- providerSelections[selKey] = selValue;
66
- }
67
- }
52
+ function parseChannelsFile(raw) {
53
+ const parsed = JSON.parse(raw);
54
+ const channels = {};
55
+ const serviceSelections = {};
56
+ const tagRegistry = { deletedTags: [], tags: [] };
57
+ for (const [key, value] of Object.entries(parsed)) {
58
+ if ((key === "serviceSelections") || (key === "providerSelections")) {
59
+ // Copy service selections if it's an object. Accepts the legacy "providerSelections" key for backward compatibility.
60
+ if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
61
+ for (const [selKey, selValue] of Object.entries(value)) {
62
+ if (typeof selValue === "string") {
63
+ serviceSelections[selKey] = selValue;
68
64
  }
69
65
  }
70
- else if (key === "tagRegistry") {
71
- // Extract tag registry with defensive validation. Each field must be a string array — non-string elements are silently dropped.
72
- if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
73
- const raw = value;
74
- if (Array.isArray(raw.tags)) {
75
- tagRegistry.tags = raw.tags.filter((t) => typeof t === "string").sort();
76
- }
77
- if (Array.isArray(raw.deletedTags)) {
78
- tagRegistry.deletedTags = raw.deletedTags.filter((t) => typeof t === "string").sort();
79
- }
80
- }
66
+ }
67
+ }
68
+ else if (key === "tagRegistry") {
69
+ // Extract tag registry with defensive validation. String array fields (tags, deletedTags) drop non-string elements silently.
70
+ if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
71
+ const rawRegistry = value;
72
+ if (Array.isArray(rawRegistry.tags)) {
73
+ tagRegistry.tags = rawRegistry.tags.filter((t) => typeof t === "string").sort();
81
74
  }
82
- else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
83
- // It's a channel definition or delta override.
84
- channels[key] = value;
75
+ if (Array.isArray(rawRegistry.deletedTags)) {
76
+ tagRegistry.deletedTags = rawRegistry.deletedTags.filter((t) => typeof t === "string").sort();
85
77
  }
86
78
  }
87
- return { channels, parseError: false, providerSelections, tagRegistry };
88
79
  }
89
- catch (parseError) {
90
- const message = (parseError instanceof Error) ? parseError.message : String(parseError);
91
- LOG.warn("Invalid JSON in channels file %s: %s. Using predefined channels only.", getChannelsFilePath(), message);
92
- return { channels: {}, parseError: true, parseErrorMessage: message, providerSelections: {}, tagRegistry: { deletedTags: [], tags: [] } };
80
+ else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
81
+ // It's a channel definition or delta override.
82
+ channels[key] = value;
93
83
  }
94
84
  }
95
- catch (error) {
96
- // File doesn't exist - this is normal, use predefined channels only.
97
- if (error.code === "ENOENT") {
98
- return { channels: {}, parseError: false, providerSelections: {}, tagRegistry: { deletedTags: [], tags: [] } };
85
+ // Silent migration: rename legacy "provider" field to "service" on channel entries. The old field was used as a display name override for the service selection
86
+ // dropdown. New installs always write "service". This migration ensures existing channels.json files are upgraded transparently.
87
+ for (const channel of Object.values(channels)) {
88
+ const legacy = channel;
89
+ if (("provider" in legacy) && !("service" in legacy)) {
90
+ legacy.service = legacy.provider;
99
91
  }
100
- // Other read errors - log and use predefined channels.
101
- LOG.warn("Failed to read channels file %s: %s. Using predefined channels only.", getChannelsFilePath(), (error instanceof Error) ? error.message : String(error));
102
- return { channels: {}, parseError: false, providerSelections: {}, tagRegistry: { deletedTags: [], tags: [] } };
92
+ Reflect.deleteProperty(legacy, "provider");
103
93
  }
94
+ return { channels, serviceSelections, tagRegistry };
104
95
  }
105
96
  /**
106
- * Saves user channels to the channels file and updates the in-memory cache. Changes take effect immediately for new stream requests without requiring a server
107
- * restart. Creates the data directory if it doesn't exist. Metadata keys (provider selections, tag registry) are also saved if they have content. No-op deltas
108
- * for predefined channel keys are normalized before saving: fields that match the predefined value or null-clear a field the predefined doesn't have are
109
- * stripped. If the delta becomes empty after normalization, the entry is removed entirely. This ensures that any code path that writes deltas (inline edit,
110
- * auto-number, browse modal, full edit) produces clean channels.json output without each handler needing to optimize its own delta.
111
- * @param channels - The channels to save (full definitions or delta overrides).
112
- * @throws If the file cannot be written.
97
+ * Normalizes predefined channel deltas by stripping no-op fields and strips null fields from user channels. A delta field is a no-op if: (1) its value is null
98
+ * and the predefined doesn't have the field, (2) its value is undefined, or (3) its value matches the predefined's value exactly. After stripping, deltas with
99
+ * no remaining fields are removed entirely.
100
+ * @param channels - The raw channel entries to normalize.
101
+ * @returns The normalized channel map.
113
102
  */
114
- export async function saveUserChannels(channels) {
115
- // Ensure data directory exists.
116
- await fsPromises.mkdir(getDataDir(), { recursive: true });
117
- // Normalize predefined channel deltas by stripping no-op fields. A delta field is a no-op if: (1) its value is null and the predefined doesn't have the
118
- // field (clearing a nonexistent field), (2) its value is undefined, or (3) its value matches the predefined's value exactly (redundant copy). After stripping,
119
- // deltas with no remaining fields are removed entirely.
103
+ function normalizeChannelDeltas(channels) {
120
104
  const filtered = {};
121
105
  for (const [key, stored] of Object.entries(channels)) {
122
106
  if (key in PREDEFINED_CHANNELS) {
@@ -128,15 +112,15 @@ export async function saveUserChannels(channels) {
128
112
  if (!DELTA_ALLOWED_FIELDS.has(field)) {
129
113
  continue;
130
114
  }
131
- // Skip undefined values — they have no effect.
115
+ // Skip undefined values - they have no effect.
132
116
  if (value === undefined) {
133
117
  continue;
134
118
  }
135
- // Skip null values when the predefined doesn't have the field — clearing a nonexistent field is a no-op.
119
+ // Skip null values when the predefined doesn't have the field - clearing a nonexistent field is a no-op.
136
120
  if ((value === null) && !(field in predefined)) {
137
121
  continue;
138
122
  }
139
- // Skip values that match the predefined exactly — redundant copies. Array fields (like tags) use JSON.stringify for comparison since reference equality
123
+ // Skip values that match the predefined exactly - redundant copies. Array fields (like tags) use JSON.stringify for comparison since reference equality
140
124
  // always fails for arrays. Tags arrays are always sorted and lowercase, making JSON.stringify deterministic.
141
125
  if (value !== null) {
142
126
  const predefinedValue = predefined[field];
@@ -159,46 +143,90 @@ export async function saveUserChannels(channels) {
159
143
  }
160
144
  }
161
145
  else {
162
- // User channels: strip null fields. Null is a delta convention for predefined channels ("clear this field") and has no meaning on full Channel definitions —
163
- // the Channel type uses T | undefined, never T | null. This allows callers to uniformly use null for "empty/clear" without needing to know the storage convention.
146
+ // User channels: strip null fields. Null is a delta convention for predefined channels ("clear this field") and has no meaning on full Channel
147
+ // definitions...the Channel type uses T | undefined, never T | null. This allows callers to uniformly use null for "empty/clear" without needing to
148
+ // know the storage convention.
164
149
  filtered[key] = Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
165
150
  }
166
151
  }
167
- // Include metadata keys (provider selections, tag registry) if they have content.
168
- const selections = getProviderSelections();
169
- const output = { ...filtered };
152
+ return filtered;
153
+ }
154
+ /**
155
+ * Prepares channels data for writing to disk. Injects metadata from module state (serviceSelections, tagRegistry) into the serializable output. The metadata
156
+ * is always pulled from module state rather than from the file data, because route handlers may have modified metadata in memory since the last file read.
157
+ * Delta normalization is handled by mutateChannels() before the data reaches this hook.
158
+ * @param data - The compound channels data with already-normalized channels.
159
+ * @returns The serializable output with channels and current metadata.
160
+ */
161
+ function prepareChannelsForWrite(data) {
162
+ // Build the serializable output with metadata from module state.
163
+ const output = { ...data.channels };
164
+ const selections = getServiceSelections();
170
165
  if (Object.keys(selections).length > 0) {
171
- output.providerSelections = selections;
166
+ output.serviceSelections = selections;
172
167
  }
173
168
  if ((loadedTagRegistry.tags.length > 0) || (loadedTagRegistry.deletedTags.length > 0)) {
174
169
  output.tagRegistry = loadedTagRegistry;
175
170
  }
176
- // Write channels with pretty formatting and sorted keys for consistent, diff-friendly output.
177
- const content = stringifySorted(output);
178
- await fsPromises.writeFile(getChannelsFilePath(), content + "\n", "utf-8");
179
- // Update in-memory cache so changes take effect immediately for new stream requests.
180
- loadedUserChannels = { ...filtered };
181
- // Refresh provider groups so channelsRef reflects the new channel data. This ensures getResolvedChannel() returns correct data after modifications.
182
- buildProviderGroups(getMergedChannelMap());
183
- // Clear any previous parse error since we're writing valid data.
171
+ return output;
172
+ }
173
+ // Transactional store instance for channels.json.
174
+ const channelsStore = createFileStore({
175
+ beforeWrite: prepareChannelsForWrite,
176
+ defaultValue: () => ({ channels: {}, serviceSelections: {}, tagRegistry: { deletedTags: [], tags: [] } }),
177
+ label: "channels",
178
+ parse: parseChannelsFile,
179
+ path: getChannelsFilePath
180
+ });
181
+ /**
182
+ * Reads the current channels from disk without acquiring the serialization lock. Returns the parsed channels with metadata and parse status. Use this for
183
+ * read-only access and startup initialization. For modifications, use mutateChannels() instead.
184
+ * @returns The loaded channels with parse status, service selections, and tag registry.
185
+ */
186
+ export async function readChannels() {
187
+ const result = await channelsStore.read();
188
+ return {
189
+ channels: result.data.channels,
190
+ parseError: result.parseError,
191
+ parseErrorMessage: result.parseErrorMessage,
192
+ serviceSelections: result.data.serviceSelections,
193
+ tagRegistry: result.data.tagRegistry
194
+ };
195
+ }
196
+ /**
197
+ * Serialized read-modify-write operation on channels.json. The mutation function receives the current StoredChannelMap and modifies it in place. The store
198
+ * handles atomicity, serialization, corruption guard, backup, and metadata injection. Delta normalization is applied after the caller's mutation and before the
199
+ * file write so that the normalized result is available for the post-write cache update.
200
+ * @param fn - Mutation function. Receives current channels. Modify in place; return value is ignored.
201
+ * @throws FileStoreParseError if channels.json contains invalid JSON.
202
+ */
203
+ export async function mutateChannels(fn) {
204
+ // Normalized channels captured from inside the mutation callback for post-write cache update. Normalization runs inside the callback (under the
205
+ // serialization lock) so the same normalized data is written to disk and assigned to the cache.
206
+ let normalizedChannels = {};
207
+ await channelsStore.mutate((data) => {
208
+ fn(data.channels);
209
+ // Normalize deltas before the beforeWrite hook serializes the data. This ensures the in-memory cache and the on-disk representation are identical.
210
+ // The beforeWrite hook (prepareChannelsForWrite) handles metadata injection only.
211
+ data.channels = normalizeChannelDeltas(data.channels);
212
+ normalizedChannels = data.channels;
213
+ });
214
+ // Side effects after successful write. These only run if the store mutation (including atomic file write) completed without error.
215
+ loadedUserChannels = { ...normalizedChannels };
216
+ buildServiceGroups(getMergedChannelMap());
184
217
  userChannelsParseError = false;
185
218
  userChannelsParseErrorMessage = undefined;
186
219
  }
187
220
  /**
188
221
  * Deletes a user channel by key.
189
222
  * @param key - The channel key to delete.
190
- * @throws If the file cannot be read or written.
223
+ * @throws FileStoreParseError if the channels file contains invalid JSON.
224
+ * @throws If the file cannot be written.
191
225
  */
192
226
  export async function deleteUserChannel(key) {
193
- const result = await loadUserChannels();
194
- // If parse error, we can't modify - just log a warning.
195
- if (result.parseError) {
196
- throw new Error("Cannot delete channel: channels file contains invalid JSON.");
197
- }
198
- // Remove the channel.
199
- Reflect.deleteProperty(result.channels, key);
200
- // Save the modified channels.
201
- await saveUserChannels(result.channels);
227
+ await mutateChannels((channels) => {
228
+ Reflect.deleteProperty(channels, key);
229
+ });
202
230
  LOG.info("User channel '%s' deleted.", key);
203
231
  }
204
232
  /**
@@ -222,61 +250,72 @@ export async function resetUserChannels() {
222
250
  /* User channels are loaded at server startup and stored in module-level state. This avoids repeated file reads during request handling.
223
251
  */
224
252
  /**
225
- * Initializes user channels by loading them from the file. This should be called once at server startup. Also builds provider groups and loads provider selections.
253
+ * Initializes user channels by loading them from the file. This should be called once at server startup. Also builds service groups and loads service selections.
226
254
  */
227
255
  export async function initializeUserChannels() {
228
- const result = await loadUserChannels();
229
- // Silent migration: rename "foxcom" provider references to "foxone." Migrates provider selections (channels.json) and user channel variant keys. The
230
- // provider filter (config.json) is handled separately below since it's already loaded into CONFIG at this point.
256
+ const result = await readChannels();
257
+ // Populate module-level state from the loaded file. Migrations below may call mutateChannels(), which updates loadedUserChannels via its side effects
258
+ // with normalized data. Setting the initial state here ensures the cache is populated even when no migrations run.
259
+ loadedUserChannels = result.channels;
260
+ loadedTagRegistry = result.tagRegistry;
261
+ userChannelsParseError = result.parseError;
262
+ userChannelsParseErrorMessage = result.parseErrorMessage;
263
+ // Silent migrations: rename stale service keys to their current equivalents. Migrates service selections (channels.json) and user channel variant keys.
264
+ // The service filter (config.json) is handled separately below since it's already loaded into CONFIG at this point.
231
265
  let channelsMigrated = false;
232
- for (const [canonicalKey, selectedVariant] of Object.entries(result.providerSelections)) {
266
+ for (const [canonicalKey, selectedVariant] of Object.entries(result.serviceSelections)) {
267
+ // foxcom → foxone: original Fox service slug renamed.
233
268
  if (selectedVariant.endsWith("-foxcom")) {
234
- result.providerSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
269
+ result.serviceSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
235
270
  channelsMigrated = true;
236
271
  }
237
- }
238
- for (const key of Object.keys(result.channels)) {
239
- if (key.endsWith("-foxcom")) {
240
- result.channels[key.slice(0, -6) + "foxone"] = result.channels[key];
241
- Reflect.deleteProperty(result.channels, key);
272
+ // fox-site → fox-foxone: the "fox" channel's FoxOne variant was briefly keyed as "site" in v1.8.0 instead of "foxone" like every other Fox channel.
273
+ if ((canonicalKey === "fox") && (selectedVariant === "fox-site")) {
274
+ result.serviceSelections[canonicalKey] = "fox-foxone";
242
275
  channelsMigrated = true;
243
276
  }
244
277
  }
245
- // Load provider selections before saving so that saveUserChannels (which persists both channels and selections) captures the migrated values.
246
- setProviderSelections(result.providerSelections);
278
+ // Load service selections before saving so that mutateChannels (which persists both channels and selections via the beforeWrite hook) captures the
279
+ // migrated values from module state.
280
+ setServiceSelections(result.serviceSelections);
247
281
  if (channelsMigrated) {
248
- await saveUserChannels(result.channels);
249
- LOG.info("Migrated Fox provider references from foxcom to foxone.");
282
+ await mutateChannels((channels) => {
283
+ // Apply the foxcom → foxone channel key migration. The mutation reads fresh from disk, so we replay the transform rather than passing the in-memory
284
+ // result. The fox-site selection migration is selection-only (no channel keys to rename).
285
+ for (const key of Object.keys(channels)) {
286
+ if (key.endsWith("-foxcom")) {
287
+ channels[key.slice(0, -6) + "foxone"] = channels[key];
288
+ Reflect.deleteProperty(channels, key);
289
+ }
290
+ }
291
+ });
292
+ LOG.info("Migrated stale Fox service references.");
250
293
  }
251
- loadedUserChannels = result.channels;
252
- loadedTagRegistry = result.tagRegistry;
253
- userChannelsParseError = result.parseError;
254
- userChannelsParseErrorMessage = result.parseErrorMessage;
255
- // Load enabled providers from the configuration, validating that each tag is recognized. Invalid tags (e.g., from hand-edited config.json typos) are stripped
256
- // silently after logging a warning. Validation must happen after buildProviderGroups() because getAllProviderTags() depends on the groups being built.
257
- let configuredProviders = CONFIG.channels.enabledProviders;
258
- // Silent migration: rename "foxcom" to "foxone" in the provider filter if present. Persisted to config.json immediately so the stale value doesn't remain.
259
- if (configuredProviders.includes("foxcom")) {
260
- configuredProviders = configuredProviders.map((tag) => (tag === "foxcom") ? "foxone" : tag);
261
- CONFIG.channels.enabledProviders = configuredProviders;
262
- const configResult = await loadUserConfig();
263
- if (configResult.config.channels?.enabledProviders) {
264
- configResult.config.channels.enabledProviders = configuredProviders;
265
- await saveUserConfig(configResult.config);
266
- }
267
- LOG.info("Migrated provider filter from foxcom to foxone.");
268
- }
269
- // Upgrade inference for setupCompleted: existing users who already have providers or channels configured should not see the first-run setup wizard. If the
294
+ // Load enabled services from the configuration, validating that each tag is recognized. Invalid tags (e.g., from hand-edited config.json typos) are stripped
295
+ // silently after logging a warning. Validation must happen after buildServiceGroups() because getAllServiceTags() depends on the groups being built.
296
+ let configuredServices = CONFIG.channels.enabledServices;
297
+ // Silent migration: rename "foxcom" to "foxone" in the service filter if present. Persisted to config.json immediately so the stale value doesn't remain.
298
+ if (configuredServices.includes("foxcom")) {
299
+ configuredServices = configuredServices.map((tag) => (tag === "foxcom") ? "foxone" : tag);
300
+ CONFIG.channels.enabledServices = configuredServices;
301
+ await mutateConfig((config) => {
302
+ if (config.channels?.enabledServices) {
303
+ config.channels.enabledServices = configuredServices;
304
+ }
305
+ });
306
+ LOG.info("Migrated service filter from foxcom to foxone.");
307
+ }
308
+ // Upgrade inference for setupCompleted: existing users who already have services or channels configured should not see the first-run setup wizard. If the
270
309
  // flag is not set in the config file and evidence of prior configuration exists, infer true and persist.
271
310
  if (!CONFIG.channels.setupCompleted) {
272
- const hasProviders = configuredProviders.length > 0;
311
+ const hasServices = configuredServices.length > 0;
273
312
  const hasUserChannels = Object.keys(loadedUserChannels).length > 0;
274
- if (hasProviders || hasUserChannels) {
313
+ if (hasServices || hasUserChannels) {
275
314
  CONFIG.channels.setupCompleted = true;
276
- const configResult = await loadUserConfig();
277
- configResult.config.channels ??= {};
278
- configResult.config.channels.setupCompleted = true;
279
- await saveUserConfig(configResult.config);
315
+ await mutateConfig((config) => {
316
+ config.channels ??= {};
317
+ config.channels.setupCompleted = true;
318
+ });
280
319
  }
281
320
  }
282
321
  // One-time migration: stamp canonicalKey on existing user channel variant entries that were created before explicit variant relationships were introduced.
@@ -293,19 +332,33 @@ export async function initializeUserChannels() {
293
332
  continue;
294
333
  }
295
334
  const prefix = key.substring(0, hyphenIndex);
296
- // Only stamp canonicalKey if the prefix exists as a predefined channel. This matches the old buildProviderGroups heuristic exactly.
335
+ // Only stamp canonicalKey if the prefix exists as a predefined channel. This matches the old buildServiceGroups heuristic exactly.
297
336
  if (prefix in PREDEFINED_CHANNELS) {
298
337
  channel.canonicalKey = prefix;
299
338
  canonicalKeyMigrated = true;
300
339
  }
301
340
  }
302
341
  if (canonicalKeyMigrated) {
303
- await saveUserChannels(result.channels);
342
+ await mutateChannels((channels) => {
343
+ for (const [key, channel] of Object.entries(channels)) {
344
+ if (channel.canonicalKey) {
345
+ continue;
346
+ }
347
+ const hyphenIndex = key.indexOf("-");
348
+ if (hyphenIndex === -1) {
349
+ continue;
350
+ }
351
+ const prefix = key.substring(0, hyphenIndex);
352
+ if (prefix in PREDEFINED_CHANNELS) {
353
+ channel.canonicalKey = prefix;
354
+ }
355
+ }
356
+ });
304
357
  LOG.info("Migrated user channel variant entries with explicit canonical key declarations.");
305
358
  }
306
359
  // One-time migration: strip identity fields from user channel variant entries. Identity fields (name, stationId, tags, etc.) are resolved from the canonical
307
360
  // at runtime via applyVariantInheritance, so storing them on variants is redundant. Older versions wrote these fields on variant creation. This migration
308
- // cleans them up so channels.json only contains provider-specific fields on variant entries.
361
+ // cleans them up so channels.json only contains service-specific fields on variant entries.
309
362
  let variantFieldsMigrated = false;
310
363
  for (const channel of Object.values(result.channels)) {
311
364
  if (!channel.canonicalKey) {
@@ -319,25 +372,41 @@ export async function initializeUserChannels() {
319
372
  }
320
373
  }
321
374
  if (variantFieldsMigrated) {
322
- await saveUserChannels(result.channels);
375
+ await mutateChannels((channels) => {
376
+ for (const channel of Object.values(channels)) {
377
+ if (!channel.canonicalKey) {
378
+ continue;
379
+ }
380
+ for (const field of CHANNEL_IDENTITY_FIELDS) {
381
+ if (field in channel) {
382
+ Reflect.deleteProperty(channel, field);
383
+ }
384
+ }
385
+ }
386
+ });
323
387
  LOG.info("Stripped redundant identity fields from user channel variant entries.");
324
388
  }
325
- // Build the merged channels map and then build provider groups.
389
+ // Build the merged channels map and then build service groups.
326
390
  const mergedChannels = getMergedChannelMap();
327
- buildProviderGroups(mergedChannels);
328
- // Now that provider groups are built, validate the configured provider tags. Strip any unrecognized tags and warn.
329
- if (configuredProviders.length > 0) {
330
- const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
331
- const validTags = configuredProviders.filter((tag) => knownTags.has(tag));
332
- const invalidTags = configuredProviders.filter((tag) => !knownTags.has(tag));
391
+ // buildServiceGroups validates stored service selections against the rebuilt variant structure and reverts any that are stale. If any were cleaned, persist
392
+ // once so the cleanup survives restarts. At runtime (via mutateChannels), the in-memory cleanup is sufficient and persists naturally on the next write.
393
+ const staleSelections = buildServiceGroups(mergedChannels);
394
+ if (staleSelections.length > 0) {
395
+ await saveServiceSelections();
396
+ }
397
+ // Now that service groups are built, validate the configured service tags. Strip any unrecognized tags and warn.
398
+ if (configuredServices.length > 0) {
399
+ const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
400
+ const validTags = configuredServices.filter((tag) => knownTags.has(tag));
401
+ const invalidTags = configuredServices.filter((tag) => !knownTags.has(tag));
333
402
  if (invalidTags.length > 0) {
334
- LOG.warn("Ignoring unrecognized provider tags in configuration: %s.", invalidTags.join(", "));
403
+ LOG.warn("Ignoring unrecognized service tags in configuration: %s.", invalidTags.join(", "));
335
404
  }
336
- setEnabledProviders(validTags);
337
- CONFIG.channels.enabledProviders = validTags;
405
+ setEnabledServices(validTags);
406
+ CONFIG.channels.enabledServices = validTags;
338
407
  }
339
408
  else {
340
- setEnabledProviders(configuredProviders);
409
+ setEnabledServices(configuredServices);
341
410
  }
342
411
  // Check for non-printable characters in loaded channel string values. These warnings are informational — loaded data is not modified.
343
412
  for (const [channelKey, stored] of Object.entries(loadedUserChannels)) {
@@ -357,13 +426,11 @@ export async function initializeUserChannels() {
357
426
  LOG.info("Loaded %d channels.", totalCount);
358
427
  }
359
428
  }
360
- // Fields that users are allowed to override via delta. This allowlist prevents hand-edited channels.json from overriding fields like provider that are
361
- // intentionally not user-editable. Matches the fields in the ChannelDelta interface.
362
429
  // User-editable fields for predefined channel delta overrides. Derived from CHANNEL_IDENTITY_FIELDS (identity fields like name, stationId, tags) plus the
363
- // provider-specific fields exposed in the edit form (channelSelector, profile, url). This derivation ensures that adding a new identity field to
430
+ // service-specific fields exposed in the edit form (channelSelector, profile, url). This derivation ensures that adding a new identity field to
364
431
  // CHANNEL_IDENTITY_FIELDS automatically includes it in the delta allowlist.
365
- const PROVIDER_SPECIFIC_EDITABLE_FIELDS = ["channelSelector", "profile", "url"];
366
- const DELTA_ALLOWED_FIELDS = new Set([...CHANNEL_IDENTITY_FIELDS, ...PROVIDER_SPECIFIC_EDITABLE_FIELDS]);
432
+ const SERVICE_SPECIFIC_EDITABLE_FIELDS = ["channelSelector", "profile", "url"];
433
+ const DELTA_ALLOWED_FIELDS = new Set([...CHANNEL_IDENTITY_FIELDS, ...SERVICE_SPECIFIC_EDITABLE_FIELDS]);
367
434
  /**
368
435
  * Resolves a stored channel entry (full definition or delta) into a fully resolved Channel. For user-defined channels with no predefined equivalent, the stored
369
436
  * entry is returned as-is (it must be a full Channel). For overrides of predefined channels, the predefined definition is used as a base and only allowlisted
@@ -403,7 +470,7 @@ export function resolveStoredChannel(key, stored) {
403
470
  return resolved;
404
471
  }
405
472
  /**
406
- * Returns the merged channel map (predefined + user) without filtering by enabled status or provider variants. Used internally for building provider groups.
473
+ * Returns the merged channel map (predefined + user) without filtering by enabled status or service variants. Used internally for building service groups.
407
474
  * Resolves any delta overrides into full Channel objects so the result contains only complete definitions.
408
475
  * @returns The complete merged channel map.
409
476
  */
@@ -429,12 +496,12 @@ function getMergedChannelMap() {
429
496
  * The enabled field reflects whether the channel is available for streaming. Predefined-only channels can be disabled via configuration; user and override
430
497
  * channels are always enabled.
431
498
  *
432
- * Provider variants (non-canonical keys in provider groups) are filtered out from this listing — they are accessed via the provider selection mechanism instead.
499
+ * Service variants (non-canonical keys in service groups) are filtered out from this listing — they are accessed via the service selection mechanism instead.
433
500
  *
434
- * Override entries produce a new resolved Channel object (via resolveStoredChannel()), which is a different reference from PREDEFINED_CHANNELS[key]. The provider
435
- * system (providers.ts) relies on this reference difference to detect user overrides via isUserOverride(). Predefined-only entries preserve the original reference.
501
+ * Override entries produce a new resolved Channel object (via resolveStoredChannel()), which is a different reference from PREDEFINED_CHANNELS[key]. The service
502
+ * system (services.ts) relies on this reference difference to detect user overrides via isUserOverride(). Predefined-only entries preserve the original reference.
436
503
  *
437
- * The returned channel field is provider-resolved: when a non-default provider is selected for a channel, the entry's channel reflects the selected variant's URL,
504
+ * The returned channel field is service-resolved: when a non-default service is selected for a channel, the entry's channel reflects the selected variant's URL,
438
505
  * channelSelector, stationId, and channelNumber. The entry's key always remains the canonical key.
439
506
  * @returns Sorted array of channel listing entries.
440
507
  */
@@ -442,8 +509,8 @@ export function getChannelListing() {
442
509
  const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(loadedUserChannels)]);
443
510
  const listing = [];
444
511
  for (const key of allKeys) {
445
- // Skip provider variants — they're accessed via provider selection, not as separate channels.
446
- if (isProviderVariant(key)) {
512
+ // Skip service variants — they're accessed via service selection, not as separate channels.
513
+ if (isServiceVariant(key)) {
447
514
  continue;
448
515
  }
449
516
  const isPredefined = key in PREDEFINED_CHANNELS;
@@ -460,14 +527,14 @@ export function getChannelListing() {
460
527
  source = "predefined";
461
528
  }
462
529
  // For user entries (including overrides), resolve the stored delta/definition into a full Channel. The resolved object is a new reference, which preserves
463
- // the isUserOverride() contract in providers.ts (reference comparison against PREDEFINED_CHANNELS[key]). Predefined-only entries keep the original reference.
530
+ // the isUserOverride() contract in services.ts (reference comparison against PREDEFINED_CHANNELS[key]). Predefined-only entries keep the original reference.
464
531
  const channel = isUser ? resolveStoredChannel(key, loadedUserChannels[key]) : PREDEFINED_CHANNELS[key];
465
- // When a non-default provider is selected, resolve the variant so consumers see the correct URL, channelSelector, stationId, and channelNumber. We skip
532
+ // When a non-default service is selected, resolve the variant so consumers see the correct URL, channelSelector, stationId, and channelNumber. We skip
466
533
  // resolution when the resolved key matches the canonical key — the channel object is already correct and preserving its reference avoids a redundant lookup.
467
- const resolvedKey = resolveProviderKey(key);
534
+ const resolvedKey = resolveServiceKey(key);
468
535
  const resolvedChannel = (resolvedKey !== key) ? getResolvedChannel(resolvedKey) : undefined;
469
536
  listing.push({
470
- availableByProvider: isChannelAvailableByProvider(key),
537
+ availableByService: isChannelAvailableByService(key),
471
538
  channel: resolvedChannel ?? channel,
472
539
  enabled: !isPredefinedChannelDisabled(key),
473
540
  key,
@@ -486,7 +553,7 @@ export function getChannelListing() {
486
553
  export function getAllChannels() {
487
554
  const result = {};
488
555
  for (const entry of getChannelListing()) {
489
- if (entry.enabled && entry.availableByProvider) {
556
+ if (entry.enabled && entry.availableByService) {
490
557
  result[entry.key] = entry.channel;
491
558
  }
492
559
  }
@@ -508,7 +575,7 @@ export function getTagRegistry() {
508
575
  return { deletedTags: [...loadedTagRegistry.deletedTags], tags: [...loadedTagRegistry.tags] };
509
576
  }
510
577
  /**
511
- * Updates the tag registry state in memory. Call saveUserChannels() after to persist the change.
578
+ * Updates the tag registry state in memory. Call saveTagRegistry() after to persist the change.
512
579
  * @param registry - The new tag registry state.
513
580
  */
514
581
  export function setTagRegistry(registry) {
@@ -521,11 +588,17 @@ export function setTagRegistry(registry) {
521
588
  * @returns Sorted array of active tag strings.
522
589
  */
523
590
  export function getActiveTagVocabulary() {
524
- const deleted = new Set(loadedTagRegistry.deletedTags);
525
- const active = PREDEFINED_TAGS.filter((tag) => !deleted.has(tag));
526
- // Merge user tags, deduplicate (in case a user tag matches a non-deleted predefined tag), and sort.
527
- const combined = new Set([...active, ...loadedTagRegistry.tags]);
528
- return [...combined].sort();
591
+ const active = PREDEFINED_TAGS.filter((tag) => !loadedTagRegistry.deletedTags.some((d) => tagsMatch(d, tag)));
592
+ // Merge user tags, deduplicate case-insensitively (in case a user tag matches a non-deleted predefined tag), and sort. When a predefined and user tag
593
+ // collide case-insensitively, the predefined form wins since it appears first in the Map.
594
+ const seen = new Map();
595
+ for (const tag of [...active, ...loadedTagRegistry.tags]) {
596
+ const lower = tag.toLowerCase();
597
+ if (!seen.has(lower)) {
598
+ seen.set(lower, tag);
599
+ }
600
+ }
601
+ return [...seen.values()].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
529
602
  }
530
603
  /**
531
604
  * Returns a channel's effective tags — the intersection of the channel's assigned tags with the active vocabulary. Tags that exist on the channel but are not in
@@ -537,13 +610,23 @@ export function getChannelEffectiveTags(channel) {
537
610
  if (!channel.tags || (channel.tags.length === 0)) {
538
611
  return [];
539
612
  }
540
- const vocabulary = new Set(getActiveTagVocabulary());
541
- return channel.tags.filter((tag) => vocabulary.has(tag));
613
+ const vocabulary = getActiveTagVocabulary();
614
+ return channel.tags.filter((tag) => vocabulary.some((v) => tagsMatch(v, tag)));
615
+ }
616
+ /**
617
+ * Case-insensitive tag comparison. Tags are freeform strings with preserved casing, but all matching throughout the system is case-insensitive. This function
618
+ * is the single source of truth for that policy — all tag identity checks should use it rather than inline toLowerCase() calls.
619
+ * @param a - The first tag.
620
+ * @param b - The second tag.
621
+ * @returns True if the tags match case-insensitively.
622
+ */
623
+ export function tagsMatch(a, b) {
624
+ return a.toLowerCase() === b.toLowerCase();
542
625
  }
543
626
  /**
544
627
  * Applies a tag transformation across channels and persists the result. This is the single source of truth for batch tag mutations — delete, rename, and bulk
545
628
  * toggle all route through this function. The caller provides a filter (which channels to transform) and a transform (how to modify each channel's tags). This
546
- * function handles loading stored channel data, applying the transform, and saving. Delta normalization in saveUserChannels() handles predefined channel
629
+ * function handles loading stored channel data, applying the transform, and saving. Delta normalization in mutateChannels() handles predefined channel
547
630
  * delta computation automatically — callers do not need to reason about deltas vs. full definitions.
548
631
  * @param filter - Predicate selecting which listing entries to transform. Receives each ChannelListingEntry from getChannelListing().
549
632
  * @param transform - Pure function mapping a channel's current resolved tags to its new tags. Receives the channel's current tags array (may be empty, ordering
@@ -551,30 +634,33 @@ export function getChannelEffectiveTags(channel) {
551
634
  * @returns Object with the affected channel keys and success status. On parse error, returns an error message and empty affected keys.
552
635
  */
553
636
  export async function transformChannelTags(filter, transform) {
554
- const result = await loadUserChannels();
555
- if (result.parseError) {
556
- return { affectedKeys: [], error: "Cannot update tags: channels file contains invalid JSON." };
557
- }
558
637
  const affectedKeys = [];
559
- for (const entry of getChannelListing()) {
560
- if (!filter(entry)) {
561
- continue;
562
- }
563
- const currentTags = entry.channel.tags ?? [];
564
- const newTags = transform(currentTags).sort();
565
- // Skip channels where the transform produced no change.
566
- if (JSON.stringify(newTags) === JSON.stringify(currentTags.slice().sort())) {
567
- continue;
568
- }
569
- // Set the new tags on the stored entry. Callers use null uniformly for "clear/empty" — saveUserChannels() handles the storage conventions: delta normalization
570
- // for predefined channels (comparing against raw definitions), null-stripping for user channels (null has no meaning on full Channel definitions).
571
- const existing = result.channels[entry.key] ?? {};
572
- existing.tags = (newTags.length > 0) ? newTags : null;
573
- result.channels[entry.key] = existing;
574
- affectedKeys.push(entry.key);
638
+ try {
639
+ await mutateChannels((channels) => {
640
+ for (const entry of getChannelListing()) {
641
+ if (!filter(entry)) {
642
+ continue;
643
+ }
644
+ const currentTags = entry.channel.tags ?? [];
645
+ const newTags = transform(currentTags).sort();
646
+ // Skip channels where the transform produced no change.
647
+ if (JSON.stringify(newTags) === JSON.stringify(currentTags.toSorted())) {
648
+ continue;
649
+ }
650
+ // Set the new tags on the stored entry. Callers use null uniformly for "clear/empty" - the normalizer in mutateChannels() handles the storage
651
+ // conventions: delta normalization for predefined channels (comparing against raw definitions), null-stripping for user channels.
652
+ const existing = channels[entry.key] ?? {};
653
+ existing.tags = (newTags.length > 0) ? newTags : null;
654
+ channels[entry.key] = existing;
655
+ affectedKeys.push(entry.key);
656
+ }
657
+ });
575
658
  }
576
- if (affectedKeys.length > 0) {
577
- await saveUserChannels(result.channels);
659
+ catch (error) {
660
+ if (error instanceof FileStoreParseError) {
661
+ return { affectedKeys: [], error: "Cannot update tags: channels file contains invalid JSON." };
662
+ }
663
+ throw error;
578
664
  }
579
665
  return { affectedKeys };
580
666
  }
@@ -697,14 +783,14 @@ export function getDisabledPredefinedChannels() {
697
783
  return [...CONFIG.channels.disabledPredefined];
698
784
  }
699
785
  /**
700
- * Returns all predefined channels regardless of disabled state, excluding provider variants. Used by the UI to show all predefined channels including disabled ones.
701
- * Provider variants are internal implementation details of channel delivery and are not channels themselves.
786
+ * Returns all predefined channels regardless of disabled state, excluding service variants. Used by the UI to show all predefined channels including disabled ones.
787
+ * Service variants are internal implementation details of channel delivery and are not channels themselves.
702
788
  * @returns The predefined channel map with canonical entries only.
703
789
  */
704
790
  export function getPredefinedChannels() {
705
791
  const result = {};
706
792
  for (const [key, channel] of Object.entries(PREDEFINED_CHANNELS)) {
707
- if (isProviderVariant(key)) {
793
+ if (isServiceVariant(key)) {
708
794
  continue;
709
795
  }
710
796
  result[key] = channel;
@@ -717,15 +803,15 @@ export function getPredefinedChannels() {
717
803
  /**
718
804
  * Filters predefined channel keys by their relationship to the Pacific timezone naming convention. For "pacific" mode, returns keys that end in "p" and
719
805
  * whose East counterpart (key minus trailing "p") exists. For "east" mode, returns keys that do NOT end in "p" and whose Pacific counterpart (key plus "p")
720
- * exists. Provider variants are excluded — only canonical keys are returned.
806
+ * exists. Service variants are excluded — only canonical keys are returned.
721
807
  * @param side - Which side of the East/Pacific pair to select.
722
808
  * @returns Sorted array of matching canonical predefined channel keys.
723
809
  */
724
810
  function filterPredefinedKeysByTimezone(side) {
725
811
  const keys = [];
726
812
  for (const key of Object.keys(PREDEFINED_CHANNELS)) {
727
- // Skip provider variants — they are internal implementation details, not channels.
728
- if (isProviderVariant(key)) {
813
+ // Skip service variants — they are internal implementation details, not channels.
814
+ if (isServiceVariant(key)) {
729
815
  continue;
730
816
  }
731
817
  if (side === "pacific") {
@@ -761,14 +847,14 @@ export function getEastWithPacificPredefinedKeys() {
761
847
  }
762
848
  /**
763
849
  * Computes enabled/total counts for all three predefined channel scopes (all, east, pacific) against the current disabled set. Both the enabled count and
764
- * the total are filtered by provider availability so that the displayed counts match the visible channel table. When no provider filter is active,
850
+ * the total are filtered by service availability so that the displayed counts match the visible channel table. When no service filter is active,
765
851
  * all channels pass and the counts are unaffected. Used by the server-side HTML renderer and both toggle endpoints to provide consistent counts to the client.
766
852
  * @returns An object with `all`, `east`, and `pacific` keys, each containing `{ enabled, total }`.
767
853
  */
768
854
  export function getPredefinedScopeCounts() {
769
- const allKeys = Object.keys(getPredefinedChannels()).filter((k) => isChannelAvailableByProvider(k));
770
- const eastKeys = getEastWithPacificPredefinedKeys().filter((k) => isChannelAvailableByProvider(k));
771
- const pacificKeys = getPacificPredefinedKeys().filter((k) => isChannelAvailableByProvider(k));
855
+ const allKeys = Object.keys(getPredefinedChannels()).filter((k) => isChannelAvailableByService(k));
856
+ const eastKeys = getEastWithPacificPredefinedKeys().filter((k) => isChannelAvailableByService(k));
857
+ const pacificKeys = getPacificPredefinedKeys().filter((k) => isChannelAvailableByService(k));
772
858
  const disabled = new Set(CONFIG.channels.disabledPredefined);
773
859
  return {
774
860
  all: { enabled: allKeys.filter((k) => !disabled.has(k)).length, total: allKeys.length },
@@ -997,23 +1083,25 @@ export function validateImportedChannels(data, validProfiles) {
997
1083
  }
998
1084
  return { channels, errors, valid: errors.length === 0 };
999
1085
  }
1000
- /* Provider selections are stored in the channels.json file alongside user channels. When a selection changes, we save the entire file (channels + selections)
1086
+ /* Service selections are stored in the channels.json file alongside user channels. When a selection changes, we save the entire file (channels + selections)
1001
1087
  * to persist the change.
1002
1088
  */
1003
1089
  /**
1004
- * Saves the current provider selections to the channels file. This triggers a full file save including all user channels.
1090
+ * Saves the current service selections to the channels file. The no-op mutation triggers a write that picks up current serviceSelections from module state via the
1091
+ * beforeWrite hook.
1005
1092
  * @throws If the file cannot be written.
1006
1093
  */
1007
- export async function saveProviderSelections() {
1008
- // Simply save the user channels — the saveUserChannels function includes provider selections automatically.
1009
- await saveUserChannels(loadedUserChannels);
1094
+ export async function saveServiceSelections() {
1095
+ // No-op mutation: the beforeWrite hook injects current serviceSelections from module state.
1096
+ await mutateChannels(() => { });
1010
1097
  }
1011
1098
  /**
1012
- * Saves the current tag registry to the channels file. This triggers a full file save including all user channels.
1099
+ * Saves the current tag registry to the channels file. The no-op mutation triggers a write that picks up the current tag registry from module state via the
1100
+ * beforeWrite hook.
1013
1101
  * @throws If the file cannot be written.
1014
1102
  */
1015
1103
  export async function saveTagRegistry() {
1016
- // Simply save the user channels — the saveUserChannels function includes the tag registry automatically.
1017
- await saveUserChannels(loadedUserChannels);
1104
+ // No-op mutation: the beforeWrite hook injects the current tag registry from module state.
1105
+ await mutateChannels(() => { });
1018
1106
  }
1019
1107
  //# sourceMappingURL=userChannels.js.map