prismcast 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/dist/app.js +9 -8
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/cdp.js +1 -1
  4. package/dist/browser/cdp.js.map +1 -1
  5. package/dist/browser/channelSelection.d.ts +10 -34
  6. package/dist/browser/channelSelection.js +19 -96
  7. package/dist/browser/channelSelection.js.map +1 -1
  8. package/dist/browser/display.d.ts +19 -0
  9. package/dist/browser/display.js +16 -11
  10. package/dist/browser/display.js.map +1 -1
  11. package/dist/browser/index.d.ts +9 -46
  12. package/dist/browser/index.js +136 -191
  13. package/dist/browser/index.js.map +1 -1
  14. package/dist/browser/login.d.ts +65 -0
  15. package/dist/browser/login.js +196 -0
  16. package/dist/browser/login.js.map +1 -0
  17. package/dist/browser/precaching.js +12 -0
  18. package/dist/browser/precaching.js.map +1 -1
  19. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  20. package/dist/browser/tuning/comcastPolymer.js +655 -0
  21. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  22. package/dist/browser/tuning/cox.d.ts +2 -0
  23. package/dist/browser/tuning/cox.js +14 -0
  24. package/dist/browser/tuning/cox.js.map +1 -0
  25. package/dist/browser/tuning/directv.js +1 -1
  26. package/dist/browser/tuning/directv.js.map +1 -1
  27. package/dist/browser/tuning/fox.js +2 -2
  28. package/dist/browser/tuning/fox.js.map +1 -1
  29. package/dist/browser/tuning/hbo.js +1 -1
  30. package/dist/browser/tuning/hbo.js.map +1 -1
  31. package/dist/browser/tuning/hulu.js +1 -1
  32. package/dist/browser/tuning/hulu.js.map +1 -1
  33. package/dist/browser/tuning/shared.d.ts +46 -0
  34. package/dist/browser/tuning/shared.js +100 -0
  35. package/dist/browser/tuning/shared.js.map +1 -0
  36. package/dist/browser/tuning/sling.js +1 -1
  37. package/dist/browser/tuning/sling.js.map +1 -1
  38. package/dist/browser/tuning/spectrum.js +1 -1
  39. package/dist/browser/tuning/spectrum.js.map +1 -1
  40. package/dist/browser/tuning/thumbnailRow.js +1 -1
  41. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  42. package/dist/browser/tuning/tileClick.js +1 -1
  43. package/dist/browser/tuning/tileClick.js.map +1 -1
  44. package/dist/browser/tuning/xfinity.js +10 -603
  45. package/dist/browser/tuning/xfinity.js.map +1 -1
  46. package/dist/browser/tuning/youtubeTv.js +1 -1
  47. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  48. package/dist/browser/video.d.ts +18 -0
  49. package/dist/browser/video.js +69 -107
  50. package/dist/browser/video.js.map +1 -1
  51. package/dist/channels/index.d.ts +4 -2
  52. package/dist/channels/index.js +2261 -800
  53. package/dist/channels/index.js.map +1 -1
  54. package/dist/config/health.js +30 -17
  55. package/dist/config/health.js.map +1 -1
  56. package/dist/config/index.js +37 -63
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/profiles.js +10 -10
  59. package/dist/config/profiles.js.map +1 -1
  60. package/dist/config/providerPacks.js +4 -1
  61. package/dist/config/providerPacks.js.map +1 -1
  62. package/dist/config/providers.d.ts +12 -5
  63. package/dist/config/providers.js +133 -104
  64. package/dist/config/providers.js.map +1 -1
  65. package/dist/config/sites.js +15 -9
  66. package/dist/config/sites.js.map +1 -1
  67. package/dist/config/userChannels.d.ts +108 -7
  68. package/dist/config/userChannels.js +374 -44
  69. package/dist/config/userChannels.js.map +1 -1
  70. package/dist/config/userConfig.d.ts +3 -1
  71. package/dist/config/userConfig.js +31 -19
  72. package/dist/config/userConfig.js.map +1 -1
  73. package/dist/config/userProfiles.js +27 -39
  74. package/dist/config/userProfiles.js.map +1 -1
  75. package/dist/hdhr/channelMap.d.ts +1 -1
  76. package/dist/hdhr/channelMap.js +4 -4
  77. package/dist/hdhr/channelMap.js.map +1 -1
  78. package/dist/hdhr/discover.js +7 -15
  79. package/dist/hdhr/discover.js.map +1 -1
  80. package/dist/index.js +9 -15
  81. package/dist/index.js.map +1 -1
  82. package/dist/native/index.d.ts +3 -0
  83. package/dist/native/index.js +16 -25
  84. package/dist/native/index.js.map +1 -1
  85. package/dist/native/probe.d.ts +4 -3
  86. package/dist/native/probe.js +36 -12
  87. package/dist/native/probe.js.map +1 -1
  88. package/dist/native/proxy.d.ts +2 -0
  89. package/dist/native/proxy.js +369 -326
  90. package/dist/native/proxy.js.map +1 -1
  91. package/dist/routes/assets.js +26 -48
  92. package/dist/routes/assets.js.map +1 -1
  93. package/dist/routes/channels.js +5 -11
  94. package/dist/routes/channels.js.map +1 -1
  95. package/dist/routes/components.d.ts +43 -8
  96. package/dist/routes/components.js +122 -19
  97. package/dist/routes/components.js.map +1 -1
  98. package/dist/routes/config/channels/index.d.ts +2 -2
  99. package/dist/routes/config/channels/index.js +1 -1
  100. package/dist/routes/config/channels/index.js.map +1 -1
  101. package/dist/routes/config/channels/routes.js +749 -71
  102. package/dist/routes/config/channels/routes.js.map +1 -1
  103. package/dist/routes/config/channels/table.d.ts +93 -2
  104. package/dist/routes/config/channels/table.js +594 -104
  105. package/dist/routes/config/channels/table.js.map +1 -1
  106. package/dist/routes/config/index.d.ts +1 -1
  107. package/dist/routes/config/index.js +1 -1
  108. package/dist/routes/config/index.js.map +1 -1
  109. package/dist/routes/config/providers.d.ts +6 -6
  110. package/dist/routes/config/providers.js +76 -102
  111. package/dist/routes/config/providers.js.map +1 -1
  112. package/dist/routes/config/settings.js +57 -36
  113. package/dist/routes/config/settings.js.map +1 -1
  114. package/dist/routes/icons.d.ts +16 -0
  115. package/dist/routes/icons.js +44 -0
  116. package/dist/routes/icons.js.map +1 -0
  117. package/dist/routes/index.d.ts +0 -16
  118. package/dist/routes/index.js +0 -17
  119. package/dist/routes/index.js.map +1 -1
  120. package/dist/routes/playlist.d.ts +6 -4
  121. package/dist/routes/playlist.js +68 -27
  122. package/dist/routes/playlist.js.map +1 -1
  123. package/dist/routes/providers.js +106 -3
  124. package/dist/routes/providers.js.map +1 -1
  125. package/dist/routes/root/content.js +96 -40
  126. package/dist/routes/root/content.js.map +1 -1
  127. package/dist/routes/root/index.js +2 -1
  128. package/dist/routes/root/index.js.map +1 -1
  129. package/dist/routes/root/scripts/channels.js +955 -310
  130. package/dist/routes/root/scripts/channels.js.map +1 -1
  131. package/dist/routes/root/scripts/config.js +695 -569
  132. package/dist/routes/root/scripts/config.js.map +1 -1
  133. package/dist/routes/root/scripts/index.d.ts +1 -0
  134. package/dist/routes/root/scripts/index.js +1 -0
  135. package/dist/routes/root/scripts/index.js.map +1 -1
  136. package/dist/routes/root/scripts/shared.d.ts +6 -0
  137. package/dist/routes/root/scripts/shared.js +437 -0
  138. package/dist/routes/root/scripts/shared.js.map +1 -0
  139. package/dist/routes/root/scripts/status.js +33 -24
  140. package/dist/routes/root/scripts/status.js.map +1 -1
  141. package/dist/routes/root/styles.js +157 -11
  142. package/dist/routes/root/styles.js.map +1 -1
  143. package/dist/routes/ui.d.ts +1 -1
  144. package/dist/routes/ui.js +1 -1
  145. package/dist/routes/ui.js.map +1 -1
  146. package/dist/service/commands.js +4 -35
  147. package/dist/service/commands.js.map +1 -1
  148. package/dist/streaming/codec.d.ts +20 -0
  149. package/dist/streaming/codec.js +68 -0
  150. package/dist/streaming/codec.js.map +1 -0
  151. package/dist/streaming/fmp4Segmenter.d.ts +2 -0
  152. package/dist/streaming/fmp4Segmenter.js +7 -4
  153. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  154. package/dist/streaming/hls.js +274 -188
  155. package/dist/streaming/hls.js.map +1 -1
  156. package/dist/streaming/hlsResume.d.ts +9 -3
  157. package/dist/streaming/hlsResume.js +15 -8
  158. package/dist/streaming/hlsResume.js.map +1 -1
  159. package/dist/streaming/hlsSegments.d.ts +1 -2
  160. package/dist/streaming/hlsSegments.js +48 -46
  161. package/dist/streaming/hlsSegments.js.map +1 -1
  162. package/dist/streaming/lifecycle.js +8 -12
  163. package/dist/streaming/lifecycle.js.map +1 -1
  164. package/dist/streaming/monitor.js +625 -585
  165. package/dist/streaming/monitor.js.map +1 -1
  166. package/dist/streaming/mpegts.js +1 -1
  167. package/dist/streaming/mpegts.js.map +1 -1
  168. package/dist/streaming/preroll.d.ts +41 -20
  169. package/dist/streaming/preroll.js +157 -76
  170. package/dist/streaming/preroll.js.map +1 -1
  171. package/dist/streaming/recovery.d.ts +0 -6
  172. package/dist/streaming/recovery.js +0 -8
  173. package/dist/streaming/recovery.js.map +1 -1
  174. package/dist/streaming/registry.d.ts +36 -9
  175. package/dist/streaming/registry.js +6 -9
  176. package/dist/streaming/registry.js.map +1 -1
  177. package/dist/streaming/setup.d.ts +3 -3
  178. package/dist/streaming/setup.js +38 -22
  179. package/dist/streaming/setup.js.map +1 -1
  180. package/dist/streaming/showInfo.d.ts +7 -6
  181. package/dist/streaming/showInfo.js +156 -36
  182. package/dist/streaming/showInfo.js.map +1 -1
  183. package/dist/streaming/statusEmitter.d.ts +14 -2
  184. package/dist/streaming/statusEmitter.js +22 -4
  185. package/dist/streaming/statusEmitter.js.map +1 -1
  186. package/dist/types/channels.d.ts +34 -0
  187. package/dist/types/config.d.ts +3 -2
  188. package/dist/types/index.d.ts +3 -2
  189. package/dist/types/index.js +1 -0
  190. package/dist/types/index.js.map +1 -1
  191. package/dist/types/profiles.d.ts +8 -3
  192. package/dist/types/selection.d.ts +1 -0
  193. package/dist/types/shared.d.ts +1 -1
  194. package/dist/types/streaming.d.ts +7 -15
  195. package/dist/types/streaming.js +5 -1
  196. package/dist/types/streaming.js.map +1 -1
  197. package/dist/upgrade/commands.js +1 -16
  198. package/dist/upgrade/commands.js.map +1 -1
  199. package/dist/utils/cliOutput.d.ts +10 -0
  200. package/dist/utils/cliOutput.js +17 -0
  201. package/dist/utils/cliOutput.js.map +1 -0
  202. package/dist/utils/debugFilter.d.ts +0 -5
  203. package/dist/utils/debugFilter.js +5 -11
  204. package/dist/utils/debugFilter.js.map +1 -1
  205. package/dist/utils/delay.d.ts +12 -0
  206. package/dist/utils/delay.js +20 -0
  207. package/dist/utils/delay.js.map +1 -1
  208. package/dist/utils/evaluate.js +6 -11
  209. package/dist/utils/evaluate.js.map +1 -1
  210. package/dist/utils/ffmpeg.d.ts +13 -5
  211. package/dist/utils/ffmpeg.js +32 -11
  212. package/dist/utils/ffmpeg.js.map +1 -1
  213. package/dist/utils/format.d.ts +20 -3
  214. package/dist/utils/format.js +32 -6
  215. package/dist/utils/format.js.map +1 -1
  216. package/dist/utils/index.d.ts +2 -0
  217. package/dist/utils/index.js +2 -0
  218. package/dist/utils/index.js.map +1 -1
  219. package/dist/utils/logEmitter.js +1 -1
  220. package/dist/utils/logEmitter.js.map +1 -1
  221. package/dist/utils/retry.d.ts +15 -9
  222. package/dist/utils/retry.js +8 -26
  223. package/dist/utils/retry.js.map +1 -1
  224. package/dist/utils/timing.d.ts +5 -0
  225. package/dist/utils/timing.js +13 -0
  226. package/dist/utils/timing.js.map +1 -0
  227. package/dist/utils/version.js +2 -2
  228. package/dist/utils/version.js.map +1 -1
  229. package/package.json +5 -5
@@ -1,8 +1,13 @@
1
- import { LOG, containsNonPrintable, sanitizeString } from "../utils/index.js";
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 { LOG, containsNonPrintable, sanitizeString, stringifySorted } from "../utils/index.js";
2
7
  import { buildProviderGroups, getAllProviderTags, getProviderSelections, getResolvedChannel, isChannelAvailableByProvider, isProviderVariant, resolveProviderKey, setEnabledProviders, setProviderSelections } from "./providers.js";
3
8
  import { getChannelsFilePath, getDataDir } from "./paths.js";
9
+ import { loadUserConfig, saveUserConfig } from "./userConfig.js";
4
10
  import { CONFIG } from "./index.js";
5
- import { PREDEFINED_CHANNELS } from "../channels/index.js";
6
11
  import fs from "node:fs";
7
12
  const { promises: fsPromises } = fs;
8
13
  /* The channels file path is resolved via the centralized paths module (config/paths.ts). The data directory is initialized at startup before channel loading.
@@ -21,6 +26,8 @@ export function getUserChannelsFilePath() {
21
26
  let loadedUserChannels = {};
22
27
  let userChannelsParseError = false;
23
28
  let userChannelsParseErrorMessage;
29
+ // Module-level tag registry state. Tracks user-created tags and user-deleted predefined tags. The runtime vocabulary is computed by getActiveTagVocabulary().
30
+ let loadedTagRegistry = { deletedTags: [], tags: [] };
24
31
  /**
25
32
  * Returns whether the user channels file had a parse error.
26
33
  * @returns True if the channels file exists but contains invalid JSON.
@@ -37,16 +44,17 @@ export function getChannelsParseErrorMessage() {
37
44
  }
38
45
  /**
39
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.
40
- * The file can contain a special `providerSelections` key with user's provider preferences, which is extracted separately from channels.
41
- * @returns The loaded channels with parse status and provider selections.
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.
42
49
  */
43
50
  export async function loadUserChannels() {
44
51
  try {
45
52
  const content = await fsPromises.readFile(getChannelsFilePath(), "utf-8");
46
53
  try {
47
54
  const parsed = JSON.parse(content);
48
- // Extract providerSelections if present — it's not a channel, it's metadata.
55
+ // Extract metadata keys (providerSelections, tagRegistry) — these are not channels, they're organizational state stored alongside channel data.
49
56
  const providerSelections = {};
57
+ const tagRegistry = { deletedTags: [], tags: [] };
50
58
  const channels = {};
51
59
  for (const [key, value] of Object.entries(parsed)) {
52
60
  if (key === "providerSelections") {
@@ -59,66 +67,114 @@ export async function loadUserChannels() {
59
67
  }
60
68
  }
61
69
  }
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
+ }
81
+ }
62
82
  else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
63
83
  // It's a channel definition or delta override.
64
84
  channels[key] = value;
65
85
  }
66
86
  }
67
- return { channels, parseError: false, providerSelections };
87
+ return { channels, parseError: false, providerSelections, tagRegistry };
68
88
  }
69
89
  catch (parseError) {
70
90
  const message = (parseError instanceof Error) ? parseError.message : String(parseError);
71
91
  LOG.warn("Invalid JSON in channels file %s: %s. Using predefined channels only.", getChannelsFilePath(), message);
72
- return { channels: {}, parseError: true, parseErrorMessage: message, providerSelections: {} };
92
+ return { channels: {}, parseError: true, parseErrorMessage: message, providerSelections: {}, tagRegistry: { deletedTags: [], tags: [] } };
73
93
  }
74
94
  }
75
95
  catch (error) {
76
96
  // File doesn't exist - this is normal, use predefined channels only.
77
97
  if (error.code === "ENOENT") {
78
- return { channels: {}, parseError: false, providerSelections: {} };
98
+ return { channels: {}, parseError: false, providerSelections: {}, tagRegistry: { deletedTags: [], tags: [] } };
79
99
  }
80
100
  // Other read errors - log and use predefined channels.
81
101
  LOG.warn("Failed to read channels file %s: %s. Using predefined channels only.", getChannelsFilePath(), (error instanceof Error) ? error.message : String(error));
82
- return { channels: {}, parseError: false, providerSelections: {} };
102
+ return { channels: {}, parseError: false, providerSelections: {}, tagRegistry: { deletedTags: [], tags: [] } };
83
103
  }
84
104
  }
85
105
  /**
86
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
87
- * restart. Creates the data directory if it doesn't exist. Provider selections are also saved if any exist. Empty deltas (no overridden fields) for predefined
88
- * channel keys are stripped before saving to avoid storing no-op entries.
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.
89
111
  * @param channels - The channels to save (full definitions or delta overrides).
90
112
  * @throws If the file cannot be written.
91
113
  */
92
114
  export async function saveUserChannels(channels) {
93
115
  // Ensure data directory exists.
94
116
  await fsPromises.mkdir(getDataDir(), { recursive: true });
95
- // Strip empty deltas for predefined keys — an empty delta means no changes were made.
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.
96
120
  const filtered = {};
97
121
  for (const [key, stored] of Object.entries(channels)) {
98
- if ((key in PREDEFINED_CHANNELS) && (Object.keys(stored).length === 0)) {
99
- continue;
122
+ if (key in PREDEFINED_CHANNELS) {
123
+ const predefined = PREDEFINED_CHANNELS[key];
124
+ const cleaned = {};
125
+ let hasFields = false;
126
+ for (const [field, value] of Object.entries(stored)) {
127
+ // Skip non-delta fields (like canonicalKey) that aren't in the allowlist.
128
+ if (!DELTA_ALLOWED_FIELDS.has(field)) {
129
+ continue;
130
+ }
131
+ // Skip undefined values — they have no effect.
132
+ if (value === undefined) {
133
+ continue;
134
+ }
135
+ // Skip null values when the predefined doesn't have the field — clearing a nonexistent field is a no-op.
136
+ if ((value === null) && !(field in predefined)) {
137
+ continue;
138
+ }
139
+ // Skip values that match the predefined exactly — redundant copies. Array fields (like tags) use JSON.stringify for comparison since reference equality
140
+ // always fails for arrays. Tags arrays are always sorted and lowercase, making JSON.stringify deterministic.
141
+ if (value !== null) {
142
+ const predefinedValue = predefined[field];
143
+ if (Array.isArray(value) ? (JSON.stringify(value) === JSON.stringify(predefinedValue)) : (predefinedValue === value)) {
144
+ continue;
145
+ }
146
+ }
147
+ cleaned[field] = value;
148
+ hasFields = true;
149
+ }
150
+ // Preserve non-delta fields (like canonicalKey) that were on the stored entry.
151
+ for (const [field, value] of Object.entries(stored)) {
152
+ if (!DELTA_ALLOWED_FIELDS.has(field) && (value !== undefined)) {
153
+ cleaned[field] = value;
154
+ hasFields = true;
155
+ }
156
+ }
157
+ if (hasFields) {
158
+ filtered[key] = cleaned;
159
+ }
160
+ }
161
+ 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.
164
+ filtered[key] = Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
100
165
  }
101
- filtered[key] = stored;
102
- }
103
- // Sort channels by key for consistent output.
104
- const sortedChannels = {};
105
- const sortedKeys = Object.keys(filtered).sort();
106
- for (const key of sortedKeys) {
107
- sortedChannels[key] = filtered[key];
108
166
  }
109
- // Include provider selections if any exist.
167
+ // Include metadata keys (provider selections, tag registry) if they have content.
110
168
  const selections = getProviderSelections();
169
+ const output = { ...filtered };
111
170
  if (Object.keys(selections).length > 0) {
112
- // Sort provider selections for consistent output.
113
- const sortedSelections = {};
114
- const selectionKeys = Object.keys(selections).sort();
115
- for (const key of selectionKeys) {
116
- sortedSelections[key] = selections[key];
117
- }
118
- sortedChannels.providerSelections = sortedSelections;
171
+ output.providerSelections = selections;
119
172
  }
120
- // Write channels with pretty formatting for readability.
121
- const content = JSON.stringify(sortedChannels, null, 2);
173
+ if ((loadedTagRegistry.tags.length > 0) || (loadedTagRegistry.deletedTags.length > 0)) {
174
+ output.tagRegistry = loadedTagRegistry;
175
+ }
176
+ // Write channels with pretty formatting and sorted keys for consistent, diff-friendly output.
177
+ const content = stringifySorted(output);
122
178
  await fsPromises.writeFile(getChannelsFilePath(), content + "\n", "utf-8");
123
179
  // Update in-memory cache so changes take effect immediately for new stream requests.
124
180
  loadedUserChannels = { ...filtered };
@@ -170,14 +226,102 @@ export async function resetUserChannels() {
170
226
  */
171
227
  export async function initializeUserChannels() {
172
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.
231
+ let channelsMigrated = false;
232
+ for (const [canonicalKey, selectedVariant] of Object.entries(result.providerSelections)) {
233
+ if (selectedVariant.endsWith("-foxcom")) {
234
+ result.providerSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
235
+ channelsMigrated = true;
236
+ }
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);
242
+ channelsMigrated = true;
243
+ }
244
+ }
245
+ // Load provider selections before saving so that saveUserChannels (which persists both channels and selections) captures the migrated values.
246
+ setProviderSelections(result.providerSelections);
247
+ if (channelsMigrated) {
248
+ await saveUserChannels(result.channels);
249
+ LOG.info("Migrated Fox provider references from foxcom to foxone.");
250
+ }
173
251
  loadedUserChannels = result.channels;
252
+ loadedTagRegistry = result.tagRegistry;
174
253
  userChannelsParseError = result.parseError;
175
254
  userChannelsParseErrorMessage = result.parseErrorMessage;
176
- // Load provider selections from the file.
177
- setProviderSelections(result.providerSelections);
178
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
179
256
  // silently after logging a warning. Validation must happen after buildProviderGroups() because getAllProviderTags() depends on the groups being built.
180
- const configuredProviders = CONFIG.channels.enabledProviders;
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
270
+ // flag is not set in the config file and evidence of prior configuration exists, infer true and persist.
271
+ if (!CONFIG.channels.setupCompleted) {
272
+ const hasProviders = configuredProviders.length > 0;
273
+ const hasUserChannels = Object.keys(loadedUserChannels).length > 0;
274
+ if (hasProviders || hasUserChannels) {
275
+ CONFIG.channels.setupCompleted = true;
276
+ const configResult = await loadUserConfig();
277
+ configResult.config.channels ??= {};
278
+ configResult.config.channels.setupCompleted = true;
279
+ await saveUserConfig(configResult.config);
280
+ }
281
+ }
282
+ // One-time migration: stamp canonicalKey on existing user channel variant entries that were created before explicit variant relationships were introduced.
283
+ // These entries have hyphenated keys where the prefix is a known predefined canonical (e.g., "espn-hulu" where "espn" exists). The heuristic runs once —
284
+ // new entries get canonicalKey written at creation time by the browse modal.
285
+ let canonicalKeyMigrated = false;
286
+ for (const [key, channel] of Object.entries(result.channels)) {
287
+ // Skip entries that already have canonicalKey.
288
+ if (channel.canonicalKey) {
289
+ continue;
290
+ }
291
+ const hyphenIndex = key.indexOf("-");
292
+ if (hyphenIndex === -1) {
293
+ continue;
294
+ }
295
+ 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.
297
+ if (prefix in PREDEFINED_CHANNELS) {
298
+ channel.canonicalKey = prefix;
299
+ canonicalKeyMigrated = true;
300
+ }
301
+ }
302
+ if (canonicalKeyMigrated) {
303
+ await saveUserChannels(result.channels);
304
+ LOG.info("Migrated user channel variant entries with explicit canonical key declarations.");
305
+ }
306
+ // One-time migration: strip identity fields from user channel variant entries. Identity fields (name, stationId, tags, etc.) are resolved from the canonical
307
+ // 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.
309
+ let variantFieldsMigrated = false;
310
+ for (const channel of Object.values(result.channels)) {
311
+ if (!channel.canonicalKey) {
312
+ continue;
313
+ }
314
+ for (const field of CHANNEL_IDENTITY_FIELDS) {
315
+ if (field in channel) {
316
+ Reflect.deleteProperty(channel, field);
317
+ variantFieldsMigrated = true;
318
+ }
319
+ }
320
+ }
321
+ if (variantFieldsMigrated) {
322
+ await saveUserChannels(result.channels);
323
+ LOG.info("Stripped redundant identity fields from user channel variant entries.");
324
+ }
181
325
  // Build the merged channels map and then build provider groups.
182
326
  const mergedChannels = getMergedChannelMap();
183
327
  buildProviderGroups(mergedChannels);
@@ -215,7 +359,11 @@ export async function initializeUserChannels() {
215
359
  }
216
360
  // Fields that users are allowed to override via delta. This allowlist prevents hand-edited channels.json from overriding fields like provider that are
217
361
  // intentionally not user-editable. Matches the fields in the ChannelDelta interface.
218
- const DELTA_ALLOWED_FIELDS = new Set(["channelNumber", "channelSelector", "name", "profile", "stationId", "tvgShift", "url"]);
362
+ // 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
364
+ // 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]);
219
367
  /**
220
368
  * Resolves a stored channel entry (full definition or delta) into a fully resolved Channel. For user-defined channels with no predefined equivalent, the stored
221
369
  * 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
@@ -232,7 +380,9 @@ export function resolveStoredChannel(key, stored) {
232
380
  if (!predefined) {
233
381
  return stored;
234
382
  }
235
- // Start with a copy of the predefined definition, then overlay allowlisted non-null delta fields.
383
+ // Start with a copy of the predefined definition, then overlay allowlisted non-null delta fields. The spread creates a shallow copy — reference-type fields
384
+ // (like tags) share the same array instance as PREDEFINED_CHANNELS. The defensive copy below ensures the returned Channel is fully independent so callers can
385
+ // safely modify it without corrupting the predefined source of truth.
236
386
  const resolved = { ...predefined };
237
387
  for (const [field, value] of Object.entries(stored)) {
238
388
  if (!DELTA_ALLOWED_FIELDS.has(field)) {
@@ -247,6 +397,9 @@ export function resolveStoredChannel(key, stored) {
247
397
  resolved[field] = value;
248
398
  }
249
399
  }
400
+ // Defensive copy of reference-type fields to break shared references with PREDEFINED_CHANNELS. The delta overlay above may have replaced tags entirely (if
401
+ // 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.
402
+ resolved.tags &&= resolved.tags.slice();
250
403
  return resolved;
251
404
  }
252
405
  /**
@@ -326,8 +479,8 @@ export function getChannelListing() {
326
479
  return listing;
327
480
  }
328
481
  /**
329
- * Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded unless they
330
- * have a user override. Built on top of getChannelListing() to ensure a single merging code path.
482
+ * Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded. Built on
483
+ * top of getChannelListing() to ensure a single merging code path.
331
484
  * @returns The merged channel map with disabled predefined channels filtered out.
332
485
  */
333
486
  export function getAllChannels() {
@@ -346,6 +499,85 @@ export function getAllChannels() {
346
499
  export function getUserChannels() {
347
500
  return { ...loadedUserChannels };
348
501
  }
502
+ // Tag Registry.
503
+ /**
504
+ * Returns the current tag registry state.
505
+ * @returns A copy of the tag registry with user-created tags and deleted predefined tags.
506
+ */
507
+ export function getTagRegistry() {
508
+ return { deletedTags: [...loadedTagRegistry.deletedTags], tags: [...loadedTagRegistry.tags] };
509
+ }
510
+ /**
511
+ * Updates the tag registry state in memory. Call saveUserChannels() after to persist the change.
512
+ * @param registry - The new tag registry state.
513
+ */
514
+ export function setTagRegistry(registry) {
515
+ loadedTagRegistry = { deletedTags: registry.deletedTags.sort(), tags: registry.tags.sort() };
516
+ }
517
+ /**
518
+ * Returns the active tag vocabulary: predefined tags minus user-deleted tags, plus user-created tags, sorted alphabetically. This is the single source of truth
519
+ * 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
520
+ * parameter, even if they exist on channel definitions (vocabulary-as-lens model).
521
+ * @returns Sorted array of active tag strings.
522
+ */
523
+ 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();
529
+ }
530
+ /**
531
+ * 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
532
+ * the active vocabulary are filtered out, ensuring only assignable and queryable tags are visible in the UI and playlist responses.
533
+ * @param channel - The channel to get effective tags for.
534
+ * @returns Sorted array of effective tag strings, or empty array if the channel has no tags or none are in the active vocabulary.
535
+ */
536
+ export function getChannelEffectiveTags(channel) {
537
+ if (!channel.tags || (channel.tags.length === 0)) {
538
+ return [];
539
+ }
540
+ const vocabulary = new Set(getActiveTagVocabulary());
541
+ return channel.tags.filter((tag) => vocabulary.has(tag));
542
+ }
543
+ /**
544
+ * 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
+ * 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
547
+ * delta computation automatically — callers do not need to reason about deltas vs. full definitions.
548
+ * @param filter - Predicate selecting which listing entries to transform. Receives each ChannelListingEntry from getChannelListing().
549
+ * @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
550
+ * not guaranteed). Must return the desired tags array (may be empty to clear all tags). The returned array is sorted before storage.
551
+ * @returns Object with the affected channel keys and success status. On parse error, returns an error message and empty affected keys.
552
+ */
553
+ 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
+ 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);
575
+ }
576
+ if (affectedKeys.length > 0) {
577
+ await saveUserChannels(result.channels);
578
+ }
579
+ return { affectedKeys };
580
+ }
349
581
  /**
350
582
  * Returns the predefined channel definition for a key.
351
583
  * @param key - The channel key to look up.
@@ -362,6 +594,78 @@ export function getPredefinedChannel(key) {
362
594
  export function isPredefinedChannel(key) {
363
595
  return key in PREDEFINED_CHANNELS;
364
596
  }
597
+ /**
598
+ * Returns the East canonical key for a Pacific channel. Pacific canonicals are auto-generated by generatePacificDefinitions with a "p" suffix (e.g., "disneyp"
599
+ * from "disney"). When a Pacific canonical's logo or other brand-level metadata is unavailable, the East counterpart provides a fallback. Returns undefined
600
+ * when the key is not a Pacific canonical (doesn't end with "p" or the base key isn't predefined).
601
+ * @param key - The channel key to check.
602
+ * @returns The East canonical key if this is a Pacific channel, undefined otherwise.
603
+ */
604
+ export function getEastCanonicalKey(key) {
605
+ if (!key.endsWith("p")) {
606
+ return undefined;
607
+ }
608
+ const eastKey = key.slice(0, -1);
609
+ return isPredefinedChannel(eastKey) ? eastKey : undefined;
610
+ }
611
+ // Logo cache. Logo URLs are fetched from the Channels DVR API and cached by Gracenote station ID. The cache is populated in two tiers by showInfo.ts: first from
612
+ // the DVR's /devices endpoint (covers all channels in the M3U playlist), then via TMS station name search for any remaining channels. The cache refreshes every
613
+ // 24 hours and updates on individual channel add/edit operations.
614
+ // Logo cache keyed by Gracenote station ID. Populated by showInfo.ts via setChannelLogos() and setChannelLogo().
615
+ const logoCache = new Map();
616
+ /**
617
+ * Bulk-populates the logo cache from a stationId-to-URL map. Called by showInfo.ts after fetching logo data from the DVR. Existing entries are overwritten with
618
+ * fresh data to pick up any logo URL changes (e.g., network rebrands).
619
+ * @param logos - Map of Gracenote station ID to logo URL.
620
+ */
621
+ export function setChannelLogos(logos) {
622
+ for (const [stationId, url] of logos) {
623
+ logoCache.set(stationId, url);
624
+ }
625
+ }
626
+ /**
627
+ * Sets a single logo cache entry. Called by showInfo.ts after a TMS station name search for a single channel (tier 2 population or channel add/edit).
628
+ * @param stationId - The Gracenote station ID.
629
+ * @param url - The logo URL.
630
+ */
631
+ export function setChannelLogo(stationId, url) {
632
+ logoCache.set(stationId, url);
633
+ }
634
+ /**
635
+ * Clears the logo cache. Called on shutdown to release memory.
636
+ */
637
+ export function clearChannelLogos() {
638
+ logoCache.clear();
639
+ }
640
+ /**
641
+ * Resolves a channel key to its effective Gracenote station ID. Pacific channels (e.g., "bravop") resolve to their east counterpart's station ID since
642
+ * they share the same brand logo. User channel overrides that change the stationId are reflected. Returns undefined if the channel has no stationId.
643
+ * @param channelKey - The channel key to resolve (e.g., "amc", "bravop").
644
+ * @returns The Gracenote station ID, or undefined.
645
+ */
646
+ export function getChannelStationId(channelKey) {
647
+ // Pacific channels are regional feeds of the same brand as their east counterparts, so we resolve to the east variant's station ID for logo purposes.
648
+ const effectiveKey = getEastCanonicalKey(channelKey) ?? channelKey;
649
+ // User channels take precedence - resolveStoredChannel merges deltas with the predefined base, so overrides that change the stationId are reflected here.
650
+ return (effectiveKey in loadedUserChannels) ?
651
+ resolveStoredChannel(effectiveKey, loadedUserChannels[effectiveKey]).stationId :
652
+ // Runtime check needed - TypeScript thinks Record indexing always returns a value, but the key may not exist.
653
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
654
+ PREDEFINED_CHANNELS[effectiveKey]?.stationId;
655
+ }
656
+ /**
657
+ * Returns the logo URL for a channel from the cache. Pacific channels (e.g., "bravop") are resolved to their east counterpart automatically. The cache is
658
+ * populated by showInfo.ts from the Channels DVR API - this function only reads from it.
659
+ * @param channelKey - The channel key to look up (e.g., "amc", "bravop"). Pacific keys resolve to their east variant internally.
660
+ * @returns The logo URL if cached, undefined otherwise.
661
+ */
662
+ export function getChannelLogo(channelKey) {
663
+ const stationId = getChannelStationId(channelKey);
664
+ if (!stationId) {
665
+ return undefined;
666
+ }
667
+ return logoCache.get(stationId);
668
+ }
365
669
  /**
366
670
  * Checks if a channel key exists in the user channels.
367
671
  * @param key - The channel key to check.
@@ -374,19 +678,15 @@ export function isUserChannel(key) {
374
678
  * re-enable. This is useful for users who don't want certain predefined channels cluttering their channel list.
375
679
  */
376
680
  /**
377
- * Checks if a predefined channel is disabled.
681
+ * Checks if a predefined channel is disabled. The disabled state is determined solely by the disabledPredefined list in config — the user's explicit visibility
682
+ * 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.
378
683
  * @param key - The channel key to check.
379
684
  * @returns True if the channel is predefined and disabled.
380
685
  */
381
686
  export function isPredefinedChannelDisabled(key) {
382
- // Only predefined channels can be disabled via this mechanism.
383
687
  if (!isPredefinedChannel(key)) {
384
688
  return false;
385
689
  }
386
- // If a user channel overrides this predefined channel, the predefined channel's disabled state is irrelevant.
387
- if (isUserChannel(key)) {
388
- return false;
389
- }
390
690
  return CONFIG.channels.disabledPredefined.includes(key);
391
691
  }
392
692
  /**
@@ -512,6 +812,28 @@ export function validateChannelKey(key, isNew) {
512
812
  }
513
813
  return undefined;
514
814
  }
815
+ /**
816
+ * Validates a channel number for range and uniqueness. Returns an error message if invalid, undefined if valid. Empty string means no channel number (valid).
817
+ * This is the single source of truth for channel number validation, shared by the full edit handler and the inline-edit handler.
818
+ * @param value - The channel number as a string (from form input or inline edit). Empty means "no number."
819
+ * @param excludeKey - The channel key being edited, excluded from the duplicate check.
820
+ * @returns Error message if invalid, undefined if valid.
821
+ */
822
+ export function validateChannelNumber(value, excludeKey) {
823
+ if (!value) {
824
+ return undefined;
825
+ }
826
+ const num = parseInt(value, 10);
827
+ if (Number.isNaN(num) || (num < 1) || (num > 99999)) {
828
+ return "Channel number must be between 1 and 99999.";
829
+ }
830
+ for (const entry of getChannelListing()) {
831
+ if ((entry.channel.channelNumber === num) && (entry.key !== excludeKey)) {
832
+ return "Channel number " + String(num) + " is already used by '" + entry.key + "'.";
833
+ }
834
+ }
835
+ return undefined;
836
+ }
515
837
  /**
516
838
  * Validates a channel URL.
517
839
  * @param url - The URL to validate.
@@ -686,4 +1008,12 @@ export async function saveProviderSelections() {
686
1008
  // Simply save the user channels — the saveUserChannels function includes provider selections automatically.
687
1009
  await saveUserChannels(loadedUserChannels);
688
1010
  }
1011
+ /**
1012
+ * Saves the current tag registry to the channels file. This triggers a full file save including all user channels.
1013
+ * @throws If the file cannot be written.
1014
+ */
1015
+ export async function saveTagRegistry() {
1016
+ // Simply save the user channels — the saveUserChannels function includes the tag registry automatically.
1017
+ await saveUserChannels(loadedUserChannels);
1018
+ }
689
1019
  //# sourceMappingURL=userChannels.js.map