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,10 +1,15 @@
1
+ /* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * userChannels.ts: User channel file management for PrismCast.
4
+ */
5
+ import { CHANNEL_IDENTITY_FIELDS, PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../channels/index.js";
6
+ import { FileStoreParseError, createFileStore } from "./persistence.js";
1
7
  import { LOG, containsNonPrintable, sanitizeString } from "../utils/index.js";
2
- import { buildProviderGroups, getAllProviderTags, getProviderSelections, getResolvedChannel, isChannelAvailableByProvider, isProviderVariant, resolveProviderKey, setEnabledProviders, setProviderSelections } from "./providers.js";
3
- import { getChannelsFilePath, getDataDir } from "./paths.js";
4
- import { loadUserConfig, saveUserConfig } from "./userConfig.js";
8
+ import { buildServiceGroups, getAllServiceTags, getResolvedChannel, getServiceSelections, isChannelAvailableByService, isServiceVariant, resolveServiceKey, setEnabledServices, setServiceSelections } from "./services.js";
5
9
  import { CONFIG } from "./index.js";
6
- import { PREDEFINED_CHANNELS } from "../channels/index.js";
7
10
  import fs from "node:fs";
11
+ import { getChannelsFilePath } from "./paths.js";
12
+ import { mutateConfig } from "./userConfig.js";
8
13
  const { promises: fsPromises } = fs;
9
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.
10
15
  */
@@ -22,6 +27,8 @@ export function getUserChannelsFilePath() {
22
27
  let loadedUserChannels = {};
23
28
  let userChannelsParseError = false;
24
29
  let userChannelsParseErrorMessage;
30
+ // Module-level tag registry state. Tracks user-created tags and user-deleted predefined tags. The runtime vocabulary is computed by getActiveTagVocabulary().
31
+ let loadedTagRegistry = { deletedTags: [], tags: [] };
25
32
  /**
26
33
  * Returns whether the user channels file had a parse error.
27
34
  * @returns True if the channels file exists but contains invalid JSON.
@@ -37,67 +44,63 @@ export function getChannelsParseErrorMessage() {
37
44
  return userChannelsParseErrorMessage;
38
45
  }
39
46
  /**
40
- * 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.
41
- * The file can contain a special `providerSelections` key with user's provider preferences, which is extracted separately from channels.
42
- * @returns The loaded channels with parse status and provider selections.
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.
43
51
  */
44
- export async function loadUserChannels() {
45
- try {
46
- const content = await fsPromises.readFile(getChannelsFilePath(), "utf-8");
47
- try {
48
- const parsed = JSON.parse(content);
49
- // Extract providerSelections if present — it's not a channel, it's metadata.
50
- const providerSelections = {};
51
- const channels = {};
52
- for (const [key, value] of Object.entries(parsed)) {
53
- if (key === "providerSelections") {
54
- // Copy provider selections if it's an object.
55
- if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
56
- for (const [selKey, selValue] of Object.entries(value)) {
57
- if (typeof selValue === "string") {
58
- providerSelections[selKey] = selValue;
59
- }
60
- }
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;
61
64
  }
62
65
  }
63
- else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
64
- // It's a channel definition or delta override.
65
- channels[key] = value;
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();
74
+ }
75
+ if (Array.isArray(rawRegistry.deletedTags)) {
76
+ tagRegistry.deletedTags = rawRegistry.deletedTags.filter((t) => typeof t === "string").sort();
66
77
  }
67
78
  }
68
- return { channels, parseError: false, providerSelections };
69
79
  }
70
- catch (parseError) {
71
- const message = (parseError instanceof Error) ? parseError.message : String(parseError);
72
- LOG.warn("Invalid JSON in channels file %s: %s. Using predefined channels only.", getChannelsFilePath(), message);
73
- return { channels: {}, parseError: true, parseErrorMessage: message, providerSelections: {} };
80
+ else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
81
+ // It's a channel definition or delta override.
82
+ channels[key] = value;
74
83
  }
75
84
  }
76
- catch (error) {
77
- // File doesn't exist - this is normal, use predefined channels only.
78
- if (error.code === "ENOENT") {
79
- return { channels: {}, parseError: false, providerSelections: {} };
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;
80
91
  }
81
- // Other read errors - log and use predefined channels.
82
- LOG.warn("Failed to read channels file %s: %s. Using predefined channels only.", getChannelsFilePath(), (error instanceof Error) ? error.message : String(error));
83
- return { channels: {}, parseError: false, providerSelections: {} };
92
+ Reflect.deleteProperty(legacy, "provider");
84
93
  }
94
+ return { channels, serviceSelections, tagRegistry };
85
95
  }
86
96
  /**
87
- * 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
88
- * restart. Creates the data directory if it doesn't exist. Provider selections are also saved if any exist. No-op deltas for predefined channel keys are
89
- * normalized before saving: fields that match the predefined value or null-clear a field the predefined doesn't have are stripped. If the delta becomes empty
90
- * after normalization, the entry is removed entirely. This ensures that any code path that writes deltas (inline edit, auto-number, browse modal, full edit)
91
- * produces clean channels.json output without each handler needing to optimize its own delta.
92
- * @param channels - The channels to save (full definitions or delta overrides).
93
- * @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.
94
102
  */
95
- export async function saveUserChannels(channels) {
96
- // Ensure data directory exists.
97
- await fsPromises.mkdir(getDataDir(), { recursive: true });
98
- // 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
99
- // field (clearing a nonexistent field), (2) its value is undefined, or (3) its value matches the predefined's value exactly (redundant copy). After stripping,
100
- // deltas with no remaining fields are removed entirely.
103
+ function normalizeChannelDeltas(channels) {
101
104
  const filtered = {};
102
105
  for (const [key, stored] of Object.entries(channels)) {
103
106
  if (key in PREDEFINED_CHANNELS) {
@@ -109,17 +112,21 @@ export async function saveUserChannels(channels) {
109
112
  if (!DELTA_ALLOWED_FIELDS.has(field)) {
110
113
  continue;
111
114
  }
112
- // Skip undefined values — they have no effect.
115
+ // Skip undefined values - they have no effect.
113
116
  if (value === undefined) {
114
117
  continue;
115
118
  }
116
- // 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.
117
120
  if ((value === null) && !(field in predefined)) {
118
121
  continue;
119
122
  }
120
- // Skip values that match the predefined exactly — redundant copies.
121
- if ((value !== null) && (predefined[field] === value)) {
122
- continue;
123
+ // Skip values that match the predefined exactly - redundant copies. Array fields (like tags) use JSON.stringify for comparison since reference equality
124
+ // always fails for arrays. Tags arrays are always sorted and lowercase, making JSON.stringify deterministic.
125
+ if (value !== null) {
126
+ const predefinedValue = predefined[field];
127
+ if (Array.isArray(value) ? (JSON.stringify(value) === JSON.stringify(predefinedValue)) : (predefinedValue === value)) {
128
+ continue;
129
+ }
123
130
  }
124
131
  cleaned[field] = value;
125
132
  hasFields = true;
@@ -136,52 +143,90 @@ export async function saveUserChannels(channels) {
136
143
  }
137
144
  }
138
145
  else {
139
- filtered[key] = stored;
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.
149
+ filtered[key] = Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
140
150
  }
141
151
  }
142
- // Sort channels by key for consistent output.
143
- const sortedChannels = {};
144
- const sortedKeys = Object.keys(filtered).sort();
145
- for (const key of sortedKeys) {
146
- sortedChannels[key] = filtered[key];
147
- }
148
- // Include provider selections if any exist.
149
- const selections = getProviderSelections();
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();
150
165
  if (Object.keys(selections).length > 0) {
151
- // Sort provider selections for consistent output.
152
- const sortedSelections = {};
153
- const selectionKeys = Object.keys(selections).sort();
154
- for (const key of selectionKeys) {
155
- sortedSelections[key] = selections[key];
156
- }
157
- sortedChannels.providerSelections = sortedSelections;
158
- }
159
- // Write channels with pretty formatting for readability.
160
- const content = JSON.stringify(sortedChannels, null, 2);
161
- await fsPromises.writeFile(getChannelsFilePath(), content + "\n", "utf-8");
162
- // Update in-memory cache so changes take effect immediately for new stream requests.
163
- loadedUserChannels = { ...filtered };
164
- // Refresh provider groups so channelsRef reflects the new channel data. This ensures getResolvedChannel() returns correct data after modifications.
165
- buildProviderGroups(getMergedChannelMap());
166
- // Clear any previous parse error since we're writing valid data.
166
+ output.serviceSelections = selections;
167
+ }
168
+ if ((loadedTagRegistry.tags.length > 0) || (loadedTagRegistry.deletedTags.length > 0)) {
169
+ output.tagRegistry = loadedTagRegistry;
170
+ }
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());
167
217
  userChannelsParseError = false;
168
218
  userChannelsParseErrorMessage = undefined;
169
219
  }
170
220
  /**
171
221
  * Deletes a user channel by key.
172
222
  * @param key - The channel key to delete.
173
- * @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.
174
225
  */
175
226
  export async function deleteUserChannel(key) {
176
- const result = await loadUserChannels();
177
- // If parse error, we can't modify - just log a warning.
178
- if (result.parseError) {
179
- throw new Error("Cannot delete channel: channels file contains invalid JSON.");
180
- }
181
- // Remove the channel.
182
- Reflect.deleteProperty(result.channels, key);
183
- // Save the modified channels.
184
- await saveUserChannels(result.channels);
227
+ await mutateChannels((channels) => {
228
+ Reflect.deleteProperty(channels, key);
229
+ });
185
230
  LOG.info("User channel '%s' deleted.", key);
186
231
  }
187
232
  /**
@@ -205,61 +250,72 @@ export async function resetUserChannels() {
205
250
  /* User channels are loaded at server startup and stored in module-level state. This avoids repeated file reads during request handling.
206
251
  */
207
252
  /**
208
- * 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.
209
254
  */
210
255
  export async function initializeUserChannels() {
211
- const result = await loadUserChannels();
212
- // Silent migration: rename "foxcom" provider references to "foxone." Migrates provider selections (channels.json) and user channel variant keys. The
213
- // 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.
214
265
  let channelsMigrated = false;
215
- 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.
216
268
  if (selectedVariant.endsWith("-foxcom")) {
217
- result.providerSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
269
+ result.serviceSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
218
270
  channelsMigrated = true;
219
271
  }
220
- }
221
- for (const key of Object.keys(result.channels)) {
222
- if (key.endsWith("-foxcom")) {
223
- result.channels[key.slice(0, -6) + "foxone"] = result.channels[key];
224
- 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";
225
275
  channelsMigrated = true;
226
276
  }
227
277
  }
228
- // Load provider selections before saving so that saveUserChannels (which persists both channels and selections) captures the migrated values.
229
- 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);
230
281
  if (channelsMigrated) {
231
- await saveUserChannels(result.channels);
232
- 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.");
233
293
  }
234
- loadedUserChannels = result.channels;
235
- userChannelsParseError = result.parseError;
236
- userChannelsParseErrorMessage = result.parseErrorMessage;
237
- // Load enabled providers from the configuration, validating that each tag is recognized. Invalid tags (e.g., from hand-edited config.json typos) are stripped
238
- // silently after logging a warning. Validation must happen after buildProviderGroups() because getAllProviderTags() depends on the groups being built.
239
- let configuredProviders = CONFIG.channels.enabledProviders;
240
- // Silent migration: rename "foxcom" to "foxone" in the provider filter if present. Persisted to config.json immediately so the stale value doesn't remain.
241
- if (configuredProviders.includes("foxcom")) {
242
- configuredProviders = configuredProviders.map((tag) => (tag === "foxcom") ? "foxone" : tag);
243
- CONFIG.channels.enabledProviders = configuredProviders;
244
- const configResult = await loadUserConfig();
245
- if (configResult.config.channels?.enabledProviders) {
246
- configResult.config.channels.enabledProviders = configuredProviders;
247
- await saveUserConfig(configResult.config);
248
- }
249
- LOG.info("Migrated provider filter from foxcom to foxone.");
250
- }
251
- // 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
252
309
  // flag is not set in the config file and evidence of prior configuration exists, infer true and persist.
253
310
  if (!CONFIG.channels.setupCompleted) {
254
- const hasProviders = configuredProviders.length > 0;
311
+ const hasServices = configuredServices.length > 0;
255
312
  const hasUserChannels = Object.keys(loadedUserChannels).length > 0;
256
- if (hasProviders || hasUserChannels) {
313
+ if (hasServices || hasUserChannels) {
257
314
  CONFIG.channels.setupCompleted = true;
258
- const configResult = await loadUserConfig();
259
- configResult.config.channels ??= {};
260
- configResult.config.channels.setupCompleted = true;
261
- await saveUserConfig(configResult.config);
262
- LOG.info("Inferred Provider Setup as completed from existing configuration.");
315
+ await mutateConfig((config) => {
316
+ config.channels ??= {};
317
+ config.channels.setupCompleted = true;
318
+ });
263
319
  }
264
320
  }
265
321
  // One-time migration: stamp canonicalKey on existing user channel variant entries that were created before explicit variant relationships were introduced.
@@ -276,32 +332,81 @@ export async function initializeUserChannels() {
276
332
  continue;
277
333
  }
278
334
  const prefix = key.substring(0, hyphenIndex);
279
- // 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.
280
336
  if (prefix in PREDEFINED_CHANNELS) {
281
337
  channel.canonicalKey = prefix;
282
338
  canonicalKeyMigrated = true;
283
339
  }
284
340
  }
285
341
  if (canonicalKeyMigrated) {
286
- 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
+ });
287
357
  LOG.info("Migrated user channel variant entries with explicit canonical key declarations.");
288
358
  }
289
- // Build the merged channels map and then build provider groups.
359
+ // One-time migration: strip identity fields from user channel variant entries. Identity fields (name, stationId, tags, etc.) are resolved from the canonical
360
+ // at runtime via applyVariantInheritance, so storing them on variants is redundant. Older versions wrote these fields on variant creation. This migration
361
+ // cleans them up so channels.json only contains service-specific fields on variant entries.
362
+ let variantFieldsMigrated = false;
363
+ for (const channel of Object.values(result.channels)) {
364
+ if (!channel.canonicalKey) {
365
+ continue;
366
+ }
367
+ for (const field of CHANNEL_IDENTITY_FIELDS) {
368
+ if (field in channel) {
369
+ Reflect.deleteProperty(channel, field);
370
+ variantFieldsMigrated = true;
371
+ }
372
+ }
373
+ }
374
+ if (variantFieldsMigrated) {
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
+ });
387
+ LOG.info("Stripped redundant identity fields from user channel variant entries.");
388
+ }
389
+ // Build the merged channels map and then build service groups.
290
390
  const mergedChannels = getMergedChannelMap();
291
- buildProviderGroups(mergedChannels);
292
- // Now that provider groups are built, validate the configured provider tags. Strip any unrecognized tags and warn.
293
- if (configuredProviders.length > 0) {
294
- const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
295
- const validTags = configuredProviders.filter((tag) => knownTags.has(tag));
296
- 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));
297
402
  if (invalidTags.length > 0) {
298
- LOG.warn("Ignoring unrecognized provider tags in configuration: %s.", invalidTags.join(", "));
403
+ LOG.warn("Ignoring unrecognized service tags in configuration: %s.", invalidTags.join(", "));
299
404
  }
300
- setEnabledProviders(validTags);
301
- CONFIG.channels.enabledProviders = validTags;
405
+ setEnabledServices(validTags);
406
+ CONFIG.channels.enabledServices = validTags;
302
407
  }
303
408
  else {
304
- setEnabledProviders(configuredProviders);
409
+ setEnabledServices(configuredServices);
305
410
  }
306
411
  // Check for non-printable characters in loaded channel string values. These warnings are informational — loaded data is not modified.
307
412
  for (const [channelKey, stored] of Object.entries(loadedUserChannels)) {
@@ -321,9 +426,11 @@ export async function initializeUserChannels() {
321
426
  LOG.info("Loaded %d channels.", totalCount);
322
427
  }
323
428
  }
324
- // Fields that users are allowed to override via delta. This allowlist prevents hand-edited channels.json from overriding fields like provider that are
325
- // intentionally not user-editable. Matches the fields in the ChannelDelta interface.
326
- const DELTA_ALLOWED_FIELDS = new Set(["channelNumber", "channelSelector", "hdhrEnabled", "name", "profile", "stationId", "tvgShift", "url"]);
429
+ // User-editable fields for predefined channel delta overrides. Derived from CHANNEL_IDENTITY_FIELDS (identity fields like name, stationId, tags) plus the
430
+ // service-specific fields exposed in the edit form (channelSelector, profile, url). This derivation ensures that adding a new identity field to
431
+ // CHANNEL_IDENTITY_FIELDS automatically includes it in the delta allowlist.
432
+ const SERVICE_SPECIFIC_EDITABLE_FIELDS = ["channelSelector", "profile", "url"];
433
+ const DELTA_ALLOWED_FIELDS = new Set([...CHANNEL_IDENTITY_FIELDS, ...SERVICE_SPECIFIC_EDITABLE_FIELDS]);
327
434
  /**
328
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
329
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
@@ -340,7 +447,9 @@ export function resolveStoredChannel(key, stored) {
340
447
  if (!predefined) {
341
448
  return stored;
342
449
  }
343
- // Start with a copy of the predefined definition, then overlay allowlisted non-null delta fields.
450
+ // Start with a copy of the predefined definition, then overlay allowlisted non-null delta fields. The spread creates a shallow copy — reference-type fields
451
+ // (like tags) share the same array instance as PREDEFINED_CHANNELS. The defensive copy below ensures the returned Channel is fully independent so callers can
452
+ // safely modify it without corrupting the predefined source of truth.
344
453
  const resolved = { ...predefined };
345
454
  for (const [field, value] of Object.entries(stored)) {
346
455
  if (!DELTA_ALLOWED_FIELDS.has(field)) {
@@ -355,10 +464,13 @@ export function resolveStoredChannel(key, stored) {
355
464
  resolved[field] = value;
356
465
  }
357
466
  }
467
+ // Defensive copy of reference-type fields to break shared references with PREDEFINED_CHANNELS. The delta overlay above may have replaced tags entirely (if
468
+ // the delta included a tags array), but when no delta is present for tags, the spread leaves the predefined's array reference on the resolved object.
469
+ resolved.tags &&= resolved.tags.slice();
358
470
  return resolved;
359
471
  }
360
472
  /**
361
- * 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.
362
474
  * Resolves any delta overrides into full Channel objects so the result contains only complete definitions.
363
475
  * @returns The complete merged channel map.
364
476
  */
@@ -384,12 +496,12 @@ function getMergedChannelMap() {
384
496
  * The enabled field reflects whether the channel is available for streaming. Predefined-only channels can be disabled via configuration; user and override
385
497
  * channels are always enabled.
386
498
  *
387
- * 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.
388
500
  *
389
- * Override entries produce a new resolved Channel object (via resolveStoredChannel()), which is a different reference from PREDEFINED_CHANNELS[key]. The provider
390
- * 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.
391
503
  *
392
- * 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,
393
505
  * channelSelector, stationId, and channelNumber. The entry's key always remains the canonical key.
394
506
  * @returns Sorted array of channel listing entries.
395
507
  */
@@ -397,8 +509,8 @@ export function getChannelListing() {
397
509
  const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(loadedUserChannels)]);
398
510
  const listing = [];
399
511
  for (const key of allKeys) {
400
- // Skip provider variants — they're accessed via provider selection, not as separate channels.
401
- if (isProviderVariant(key)) {
512
+ // Skip service variants — they're accessed via service selection, not as separate channels.
513
+ if (isServiceVariant(key)) {
402
514
  continue;
403
515
  }
404
516
  const isPredefined = key in PREDEFINED_CHANNELS;
@@ -415,14 +527,14 @@ export function getChannelListing() {
415
527
  source = "predefined";
416
528
  }
417
529
  // For user entries (including overrides), resolve the stored delta/definition into a full Channel. The resolved object is a new reference, which preserves
418
- // 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.
419
531
  const channel = isUser ? resolveStoredChannel(key, loadedUserChannels[key]) : PREDEFINED_CHANNELS[key];
420
- // 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
421
533
  // resolution when the resolved key matches the canonical key — the channel object is already correct and preserving its reference avoids a redundant lookup.
422
- const resolvedKey = resolveProviderKey(key);
534
+ const resolvedKey = resolveServiceKey(key);
423
535
  const resolvedChannel = (resolvedKey !== key) ? getResolvedChannel(resolvedKey) : undefined;
424
536
  listing.push({
425
- availableByProvider: isChannelAvailableByProvider(key),
537
+ availableByService: isChannelAvailableByService(key),
426
538
  channel: resolvedChannel ?? channel,
427
539
  enabled: !isPredefinedChannelDisabled(key),
428
540
  key,
@@ -434,14 +546,14 @@ export function getChannelListing() {
434
546
  return listing;
435
547
  }
436
548
  /**
437
- * Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded unless they
438
- * have a user override. Built on top of getChannelListing() to ensure a single merging code path.
549
+ * Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded. Built on
550
+ * top of getChannelListing() to ensure a single merging code path.
439
551
  * @returns The merged channel map with disabled predefined channels filtered out.
440
552
  */
441
553
  export function getAllChannels() {
442
554
  const result = {};
443
555
  for (const entry of getChannelListing()) {
444
- if (entry.enabled && entry.availableByProvider) {
556
+ if (entry.enabled && entry.availableByService) {
445
557
  result[entry.key] = entry.channel;
446
558
  }
447
559
  }
@@ -454,6 +566,104 @@ export function getAllChannels() {
454
566
  export function getUserChannels() {
455
567
  return { ...loadedUserChannels };
456
568
  }
569
+ // Tag Registry.
570
+ /**
571
+ * Returns the current tag registry state.
572
+ * @returns A copy of the tag registry with user-created tags and deleted predefined tags.
573
+ */
574
+ export function getTagRegistry() {
575
+ return { deletedTags: [...loadedTagRegistry.deletedTags], tags: [...loadedTagRegistry.tags] };
576
+ }
577
+ /**
578
+ * Updates the tag registry state in memory. Call saveTagRegistry() after to persist the change.
579
+ * @param registry - The new tag registry state.
580
+ */
581
+ export function setTagRegistry(registry) {
582
+ loadedTagRegistry = { deletedTags: registry.deletedTags.sort(), tags: registry.tags.sort() };
583
+ }
584
+ /**
585
+ * Returns the active tag vocabulary: predefined tags minus user-deleted tags, plus user-created tags, sorted alphabetically. This is the single source of truth
586
+ * for which tags are visible, assignable, and queryable throughout the system. Tags not in this list are invisible to the UI and rejected by the ?tag= query
587
+ * parameter, even if they exist on channel definitions (vocabulary-as-lens model).
588
+ * @returns Sorted array of active tag strings.
589
+ */
590
+ export function getActiveTagVocabulary() {
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" }));
602
+ }
603
+ /**
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
605
+ * the active vocabulary are filtered out, ensuring only assignable and queryable tags are visible in the UI and playlist responses.
606
+ * @param channel - The channel to get effective tags for.
607
+ * @returns Sorted array of effective tag strings, or empty array if the channel has no tags or none are in the active vocabulary.
608
+ */
609
+ export function getChannelEffectiveTags(channel) {
610
+ if (!channel.tags || (channel.tags.length === 0)) {
611
+ return [];
612
+ }
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();
625
+ }
626
+ /**
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
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
629
+ * function handles loading stored channel data, applying the transform, and saving. Delta normalization in mutateChannels() handles predefined channel
630
+ * delta computation automatically — callers do not need to reason about deltas vs. full definitions.
631
+ * @param filter - Predicate selecting which listing entries to transform. Receives each ChannelListingEntry from getChannelListing().
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
633
+ * not guaranteed). Must return the desired tags array (may be empty to clear all tags). The returned array is sorted before storage.
634
+ * @returns Object with the affected channel keys and success status. On parse error, returns an error message and empty affected keys.
635
+ */
636
+ export async function transformChannelTags(filter, transform) {
637
+ const affectedKeys = [];
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
+ });
658
+ }
659
+ catch (error) {
660
+ if (error instanceof FileStoreParseError) {
661
+ return { affectedKeys: [], error: "Cannot update tags: channels file contains invalid JSON." };
662
+ }
663
+ throw error;
664
+ }
665
+ return { affectedKeys };
666
+ }
457
667
  /**
458
668
  * Returns the predefined channel definition for a key.
459
669
  * @param key - The channel key to look up.
@@ -554,19 +764,15 @@ export function isUserChannel(key) {
554
764
  * re-enable. This is useful for users who don't want certain predefined channels cluttering their channel list.
555
765
  */
556
766
  /**
557
- * Checks if a predefined channel is disabled.
767
+ * Checks if a predefined channel is disabled. The disabled state is determined solely by the disabledPredefined list in config — the user's explicit visibility
768
+ * intent. Property overrides (HDHR toggle, name change, tag edits) stored as deltas in channels.json are orthogonal and do not affect the enabled/disabled state.
558
769
  * @param key - The channel key to check.
559
770
  * @returns True if the channel is predefined and disabled.
560
771
  */
561
772
  export function isPredefinedChannelDisabled(key) {
562
- // Only predefined channels can be disabled via this mechanism.
563
773
  if (!isPredefinedChannel(key)) {
564
774
  return false;
565
775
  }
566
- // If a user channel overrides this predefined channel, the predefined channel's disabled state is irrelevant.
567
- if (isUserChannel(key)) {
568
- return false;
569
- }
570
776
  return CONFIG.channels.disabledPredefined.includes(key);
571
777
  }
572
778
  /**
@@ -577,14 +783,14 @@ export function getDisabledPredefinedChannels() {
577
783
  return [...CONFIG.channels.disabledPredefined];
578
784
  }
579
785
  /**
580
- * Returns all predefined channels regardless of disabled state, excluding provider variants. Used by the UI to show all predefined channels including disabled ones.
581
- * 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.
582
788
  * @returns The predefined channel map with canonical entries only.
583
789
  */
584
790
  export function getPredefinedChannels() {
585
791
  const result = {};
586
792
  for (const [key, channel] of Object.entries(PREDEFINED_CHANNELS)) {
587
- if (isProviderVariant(key)) {
793
+ if (isServiceVariant(key)) {
588
794
  continue;
589
795
  }
590
796
  result[key] = channel;
@@ -597,15 +803,15 @@ export function getPredefinedChannels() {
597
803
  /**
598
804
  * Filters predefined channel keys by their relationship to the Pacific timezone naming convention. For "pacific" mode, returns keys that end in "p" and
599
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")
600
- * exists. Provider variants are excluded — only canonical keys are returned.
806
+ * exists. Service variants are excluded — only canonical keys are returned.
601
807
  * @param side - Which side of the East/Pacific pair to select.
602
808
  * @returns Sorted array of matching canonical predefined channel keys.
603
809
  */
604
810
  function filterPredefinedKeysByTimezone(side) {
605
811
  const keys = [];
606
812
  for (const key of Object.keys(PREDEFINED_CHANNELS)) {
607
- // Skip provider variants — they are internal implementation details, not channels.
608
- if (isProviderVariant(key)) {
813
+ // Skip service variants — they are internal implementation details, not channels.
814
+ if (isServiceVariant(key)) {
609
815
  continue;
610
816
  }
611
817
  if (side === "pacific") {
@@ -641,14 +847,14 @@ export function getEastWithPacificPredefinedKeys() {
641
847
  }
642
848
  /**
643
849
  * Computes enabled/total counts for all three predefined channel scopes (all, east, pacific) against the current disabled set. Both the enabled count and
644
- * 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,
645
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.
646
852
  * @returns An object with `all`, `east`, and `pacific` keys, each containing `{ enabled, total }`.
647
853
  */
648
854
  export function getPredefinedScopeCounts() {
649
- const allKeys = Object.keys(getPredefinedChannels()).filter((k) => isChannelAvailableByProvider(k));
650
- const eastKeys = getEastWithPacificPredefinedKeys().filter((k) => isChannelAvailableByProvider(k));
651
- 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));
652
858
  const disabled = new Set(CONFIG.channels.disabledPredefined);
653
859
  return {
654
860
  all: { enabled: allKeys.filter((k) => !disabled.has(k)).length, total: allKeys.length },
@@ -877,15 +1083,25 @@ export function validateImportedChannels(data, validProfiles) {
877
1083
  }
878
1084
  return { channels, errors, valid: errors.length === 0 };
879
1085
  }
880
- /* 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)
881
1087
  * to persist the change.
882
1088
  */
883
1089
  /**
884
- * 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.
1092
+ * @throws If the file cannot be written.
1093
+ */
1094
+ export async function saveServiceSelections() {
1095
+ // No-op mutation: the beforeWrite hook injects current serviceSelections from module state.
1096
+ await mutateChannels(() => { });
1097
+ }
1098
+ /**
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.
885
1101
  * @throws If the file cannot be written.
886
1102
  */
887
- export async function saveProviderSelections() {
888
- // Simply save the user channels — the saveUserChannels function includes provider selections automatically.
889
- await saveUserChannels(loadedUserChannels);
1103
+ export async function saveTagRegistry() {
1104
+ // No-op mutation: the beforeWrite hook injects the current tag registry from module state.
1105
+ await mutateChannels(() => { });
890
1106
  }
891
1107
  //# sourceMappingURL=userChannels.js.map