prismcast 1.4.2 → 1.5.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 (178) hide show
  1. package/dist/app.js +18 -1
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/cdp.d.ts +2 -1
  4. package/dist/browser/cdp.js +44 -23
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +45 -1
  7. package/dist/browser/channelSelection.js +164 -27
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/display.d.ts +0 -4
  10. package/dist/browser/display.js +0 -7
  11. package/dist/browser/display.js.map +1 -1
  12. package/dist/browser/index.d.ts +5 -0
  13. package/dist/browser/index.js +14 -0
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/precaching.d.ts +5 -0
  16. package/dist/browser/precaching.js +112 -0
  17. package/dist/browser/precaching.js.map +1 -0
  18. package/dist/browser/tuning/directv.d.ts +2 -0
  19. package/dist/browser/tuning/directv.js +723 -0
  20. package/dist/browser/tuning/directv.js.map +1 -0
  21. package/dist/browser/tuning/fox.d.ts +2 -2
  22. package/dist/browser/tuning/fox.js +141 -22
  23. package/dist/browser/tuning/fox.js.map +1 -1
  24. package/dist/browser/tuning/hbo.d.ts +2 -2
  25. package/dist/browser/tuning/hbo.js +110 -41
  26. package/dist/browser/tuning/hbo.js.map +1 -1
  27. package/dist/browser/tuning/hulu.d.ts +2 -2
  28. package/dist/browser/tuning/hulu.js +442 -168
  29. package/dist/browser/tuning/hulu.js.map +1 -1
  30. package/dist/browser/tuning/sling.d.ts +2 -2
  31. package/dist/browser/tuning/sling.js +200 -56
  32. package/dist/browser/tuning/sling.js.map +1 -1
  33. package/dist/browser/tuning/youtubeTv.d.ts +2 -2
  34. package/dist/browser/tuning/youtubeTv.js +243 -57
  35. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  36. package/dist/browser/video.d.ts +1 -1
  37. package/dist/browser/video.js +4 -2
  38. package/dist/browser/video.js.map +1 -1
  39. package/dist/channels/index.js +280 -34
  40. package/dist/channels/index.js.map +1 -1
  41. package/dist/config/health.d.ts +78 -0
  42. package/dist/config/health.js +202 -0
  43. package/dist/config/health.js.map +1 -0
  44. package/dist/config/paths.d.ts +10 -0
  45. package/dist/config/paths.js +14 -0
  46. package/dist/config/paths.js.map +1 -1
  47. package/dist/config/profiles.d.ts +6 -4
  48. package/dist/config/profiles.js +65 -14
  49. package/dist/config/profiles.js.map +1 -1
  50. package/dist/config/providerPacks.d.ts +46 -0
  51. package/dist/config/providerPacks.js +200 -0
  52. package/dist/config/providerPacks.js.map +1 -0
  53. package/dist/config/providers.d.ts +36 -3
  54. package/dist/config/providers.js +168 -8
  55. package/dist/config/providers.js.map +1 -1
  56. package/dist/config/sites.d.ts +1 -12
  57. package/dist/config/sites.js +38 -5
  58. package/dist/config/sites.js.map +1 -1
  59. package/dist/config/userChannels.d.ts +12 -0
  60. package/dist/config/userChannels.js +81 -14
  61. package/dist/config/userChannels.js.map +1 -1
  62. package/dist/config/userConfig.d.ts +5 -1
  63. package/dist/config/userConfig.js +58 -6
  64. package/dist/config/userConfig.js.map +1 -1
  65. package/dist/config/userProfiles.d.ts +81 -0
  66. package/dist/config/userProfiles.js +424 -0
  67. package/dist/config/userProfiles.js.map +1 -0
  68. package/dist/routes/config/channels/index.d.ts +3 -0
  69. package/dist/routes/config/channels/index.js +3 -0
  70. package/dist/routes/config/channels/index.js.map +1 -0
  71. package/dist/routes/config/channels/routes.d.ts +6 -0
  72. package/dist/routes/config/channels/routes.js +797 -0
  73. package/dist/routes/config/channels/routes.js.map +1 -0
  74. package/dist/routes/config/channels/table.d.ts +46 -0
  75. package/dist/routes/config/channels/table.js +811 -0
  76. package/dist/routes/config/channels/table.js.map +1 -0
  77. package/dist/routes/config/index.d.ts +37 -0
  78. package/dist/routes/config/index.js +81 -0
  79. package/dist/routes/config/index.js.map +1 -0
  80. package/dist/routes/config/providers.d.ts +18 -0
  81. package/dist/routes/config/providers.js +573 -0
  82. package/dist/routes/config/providers.js.map +1 -0
  83. package/dist/routes/config/settings.d.ts +39 -0
  84. package/dist/routes/config/settings.js +820 -0
  85. package/dist/routes/config/settings.js.map +1 -0
  86. package/dist/routes/index.d.ts +3 -2
  87. package/dist/routes/index.js +7 -4
  88. package/dist/routes/index.js.map +1 -1
  89. package/dist/routes/playlist.d.ts +5 -1
  90. package/dist/routes/playlist.js +32 -7
  91. package/dist/routes/playlist.js.map +1 -1
  92. package/dist/routes/providers.d.ts +6 -0
  93. package/dist/routes/providers.js +166 -0
  94. package/dist/routes/providers.js.map +1 -0
  95. package/dist/routes/root/content.d.ts +32 -0
  96. package/dist/routes/root/content.js +857 -0
  97. package/dist/routes/root/content.js.map +1 -0
  98. package/dist/routes/root/index.js +197 -0
  99. package/dist/routes/root/index.js.map +1 -0
  100. package/dist/routes/root/scripts/channels.d.ts +1 -0
  101. package/dist/routes/root/scripts/channels.js +745 -0
  102. package/dist/routes/root/scripts/channels.js.map +1 -0
  103. package/dist/routes/root/scripts/config.d.ts +1 -0
  104. package/dist/routes/root/scripts/config.js +1662 -0
  105. package/dist/routes/root/scripts/config.js.map +1 -0
  106. package/dist/routes/root/scripts/index.d.ts +3 -0
  107. package/dist/routes/root/scripts/index.js +8 -0
  108. package/dist/routes/root/scripts/index.js.map +1 -0
  109. package/dist/routes/root/scripts/status.d.ts +1 -0
  110. package/dist/routes/root/scripts/status.js +436 -0
  111. package/dist/routes/root/scripts/status.js.map +1 -0
  112. package/dist/routes/root/styles.d.ts +5 -0
  113. package/dist/routes/root/styles.js +447 -0
  114. package/dist/routes/root/styles.js.map +1 -0
  115. package/dist/routes/ui.js +8 -3
  116. package/dist/routes/ui.js.map +1 -1
  117. package/dist/service/commands.d.ts +5 -3
  118. package/dist/service/commands.js +117 -33
  119. package/dist/service/commands.js.map +1 -1
  120. package/dist/service/generators.d.ts +27 -0
  121. package/dist/service/generators.js +97 -6
  122. package/dist/service/generators.js.map +1 -1
  123. package/dist/streaming/hls.js +18 -1
  124. package/dist/streaming/hls.js.map +1 -1
  125. package/dist/streaming/lifecycle.js +1 -1
  126. package/dist/streaming/lifecycle.js.map +1 -1
  127. package/dist/streaming/monitor.d.ts +1 -38
  128. package/dist/streaming/monitor.js +2 -289
  129. package/dist/streaming/monitor.js.map +1 -1
  130. package/dist/streaming/recovery.d.ts +143 -0
  131. package/dist/streaming/recovery.js +286 -0
  132. package/dist/streaming/recovery.js.map +1 -0
  133. package/dist/streaming/registry.d.ts +1 -1
  134. package/dist/streaming/setup.d.ts +1 -1
  135. package/dist/streaming/setup.js +3 -1
  136. package/dist/streaming/setup.js.map +1 -1
  137. package/dist/streaming/statusEmitter.d.ts +4 -2
  138. package/dist/streaming/statusEmitter.js +5 -0
  139. package/dist/streaming/statusEmitter.js.map +1 -1
  140. package/dist/types/channels.d.ts +68 -0
  141. package/dist/types/channels.js +2 -0
  142. package/dist/types/channels.js.map +1 -0
  143. package/dist/types/config.d.ts +127 -0
  144. package/dist/types/config.js +2 -0
  145. package/dist/types/config.js.map +1 -0
  146. package/dist/types/index.d.ts +7 -429
  147. package/dist/types/index.js +1 -5
  148. package/dist/types/index.js.map +1 -1
  149. package/dist/types/profiles.d.ts +141 -0
  150. package/dist/types/profiles.js +2 -0
  151. package/dist/types/profiles.js.map +1 -0
  152. package/dist/types/selection.d.ts +101 -0
  153. package/dist/types/selection.js +6 -0
  154. package/dist/types/selection.js.map +1 -0
  155. package/dist/types/shared.d.ts +7 -0
  156. package/dist/types/shared.js +6 -0
  157. package/dist/types/shared.js.map +1 -0
  158. package/dist/types/streaming.d.ts +96 -0
  159. package/dist/types/streaming.js +2 -0
  160. package/dist/types/streaming.js.map +1 -0
  161. package/dist/utils/debugFilter.js +3 -1
  162. package/dist/utils/debugFilter.js.map +1 -1
  163. package/dist/utils/format.d.ts +7 -0
  164. package/dist/utils/format.js +22 -0
  165. package/dist/utils/format.js.map +1 -1
  166. package/dist/utils/index.d.ts +1 -0
  167. package/dist/utils/index.js +1 -0
  168. package/dist/utils/index.js.map +1 -1
  169. package/dist/utils/sanitize.d.ts +14 -0
  170. package/dist/utils/sanitize.js +37 -0
  171. package/dist/utils/sanitize.js.map +1 -0
  172. package/package.json +5 -3
  173. package/dist/routes/config.d.ts +0 -76
  174. package/dist/routes/config.js +0 -2187
  175. package/dist/routes/config.js.map +0 -1
  176. package/dist/routes/root.js +0 -3158
  177. package/dist/routes/root.js.map +0 -1
  178. /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
@@ -1,2187 +0,0 @@
1
- import { CONFIG, getDefaults, validatePositiveInt, validatePositiveNumber } from "../config/index.js";
2
- import { CONFIG_METADATA, filterDefaults, getAdvancedSections, getEnvOverrides, getNestedValue, getSettingsTabSections, getUITabs, isEqualToDefault, loadUserConfig, saveUserConfig, setNestedValue } from "../config/userConfig.js";
3
- import { LOG, escapeHtml, formatError, generateChannelKey, isRunningAsService, parseM3U } from "../utils/index.js";
4
- import { getAllProviderTags, getCanonicalKey, getChannelProviderTags, getEnabledProviders, getProviderDisplayName, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../config/providers.js";
5
- import { getChannelListing, getChannelsParseErrorMessage, getDisabledPredefinedChannels, getPredefinedChannel, getPredefinedChannels, getUserChannels, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../config/userChannels.js";
6
- import { PREDEFINED_CHANNELS } from "../channels/index.js";
7
- import { closeBrowser } from "../browser/index.js";
8
- import { getConfigFilePath } from "../config/paths.js";
9
- import { getPresetOptionsWithDegradation } from "../config/presets.js";
10
- import { getProfiles } from "../config/profiles.js";
11
- import { getStreamCount } from "../streaming/registry.js";
12
- /* The /config endpoint provides a user-friendly web interface for editing PrismCast settings. Users can adjust values, see defaults, and understand what each
13
- * setting does without editing JSON files directly. Changes require a server restart to take effect.
14
- *
15
- * The UI shows:
16
- * - Settings grouped by category (Server, Browser, Streaming, etc.)
17
- * - Current value, default value, and description for each setting
18
- * - Visual indicator for settings overridden by environment variables (disabled with explanation)
19
- * - Reset buttons for individual categories
20
- * - Save & Restart button that applies changes and restarts the server
21
- */
22
- // SVG icon constants for channel action buttons. Each icon is 14x14px with a 16x16 viewBox, stroke-based with currentColor, and uses round line caps/joins.
23
- const ICON_EDIT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
24
- "stroke-linejoin=\"round\"><path d=\"M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z\"/></svg>";
25
- const ICON_LOGIN = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
26
- "stroke-linejoin=\"round\"><path d=\"M6.5 2H3.5a1 1 0 00-1 1v10a1 1 0 001 1h3\"/><path d=\"M10.5 11l3-3-3-3\"/><path d=\"M13.5 8H6.5\"/></svg>";
27
- const ICON_DELETE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
28
- "stroke-linejoin=\"round\"><path d=\"M2 4h12\"/><path d=\"M5 4V2.5a.5.5 0 01.5-.5h5a.5.5 0 01.5.5V4\"/><path d=\"M12.5 4l-.5 9.5a1 1 0 01-1 .5H5a1 1 0 " +
29
- "01-1-.5L3.5 4\"/></svg>";
30
- const ICON_ENABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
31
- "stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>";
32
- const ICON_DISABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
33
- "stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>";
34
- const ICON_REVERT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
35
- "stroke-linejoin=\"round\"><path d=\"M3 8a5 5 0 1 1 1.5 3.5\"/><path d=\"M3 4v4h4\"/></svg>";
36
- const ICON_COPY = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
37
- "stroke-linejoin=\"round\"><rect x=\"5\" y=\"5\" width=\"9\" height=\"9\" rx=\"1\"/><path d=\"M5 11H3a1 1 0 01-1-1V3a1 1 0 011-1h7a1 1 0 011 1v2\"/></svg>";
38
- // Fields that appear in the generated M3U playlist and affect Channels DVR's view of the channel. Used to decide whether the playlist reload hint is shown.
39
- const M3U_FIELDS = ["channelNumber", "name", "stationId"];
40
- const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change.";
41
- /**
42
- * Checks whether a stored channel entry contains any fields that affect the M3U playlist. Used to decide whether to append the playlist reload hint when reverting
43
- * or removing an override.
44
- * @param stored - The stored channel data to check (may be a delta or full definition).
45
- * @returns The PLAYLIST_HINT string if M3U-relevant fields are present, empty string otherwise.
46
- */
47
- function playlistHintForStored(stored) {
48
- return M3U_FIELDS.some((f) => f in stored) ? PLAYLIST_HINT : "";
49
- }
50
- /**
51
- * Schedules a server restart after a brief delay to allow the response to be sent. This is used after configuration changes that require a restart to take effect.
52
- * Returns information about whether the server will auto-restart (depends on whether running as a service). If streams are active and running as a service, the restart
53
- * is deferred until streams end, allowing the client to show a dialog and let the user choose to wait or force restart.
54
- * @param reason - A description of why the server is restarting, used in the log message.
55
- * @returns Information about the restart including the message to display and whether auto-restart will occur.
56
- */
57
- function scheduleServerRestart(reason) {
58
- const willRestart = isRunningAsService();
59
- // When not running as a service, we can't auto-restart. Notify the user that a manual restart is required.
60
- if (!willRestart) {
61
- LOG.info("Configuration saved %s. Manual restart required for changes to take effect.", reason);
62
- return {
63
- activeStreams: 0,
64
- deferred: false,
65
- message: "Configuration saved. Please restart PrismCast for changes to take effect.",
66
- willRestart: false
67
- };
68
- }
69
- // Check for active streams. If streams are active, defer the restart to avoid interrupting recordings or live viewing.
70
- const activeStreams = getStreamCount();
71
- if (activeStreams > 0) {
72
- LOG.info("Configuration saved %s. Restart deferred until %d active stream(s) end.", reason, activeStreams);
73
- return {
74
- activeStreams,
75
- deferred: true,
76
- message: "Configuration saved. " + String(activeStreams) + " stream(s) are active.",
77
- willRestart: true
78
- };
79
- }
80
- // No active streams - restart immediately. Close the browser first to avoid orphan Chrome processes.
81
- setTimeout(() => {
82
- LOG.info("Exiting for service manager restart %s.", reason);
83
- void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
84
- }, 500);
85
- return {
86
- activeStreams: 0,
87
- deferred: false,
88
- message: "Configuration saved. Server is restarting...",
89
- willRestart: true
90
- };
91
- }
92
- /**
93
- * Generates HTML for a text input form field with label and optional hint.
94
- * @param id - The input element ID.
95
- * @param name - The input name attribute.
96
- * @param label - The label text.
97
- * @param value - The current value.
98
- * @param options - Additional options (hint, list, pattern, placeholder, required, type).
99
- * @returns Array of HTML strings for the form row.
100
- */
101
- function generateTextField(id, name, label, value, options = {}) {
102
- const lines = [];
103
- const inputType = options.type ?? "text";
104
- const listAttr = options.list ? " list=\"" + options.list + "\"" : "";
105
- const required = options.required ? " required" : "";
106
- const pattern = options.pattern ? " pattern=\"" + options.pattern + "\"" : "";
107
- const placeholder = options.placeholder ? " placeholder=\"" + escapeHtml(options.placeholder) + "\"" : "";
108
- lines.push("<div class=\"form-row\">");
109
- lines.push("<label for=\"" + id + "\">" + label + "</label>");
110
- lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
111
- placeholder + " value=\"" + escapeHtml(value) + "\">");
112
- lines.push("</div>");
113
- // When a datalist ID is specified, append an empty <datalist> element outside the form-row flex container. The client-side JavaScript populates it dynamically
114
- // based on the URL field value.
115
- if (options.list) {
116
- lines.push("<datalist id=\"" + options.list + "\"></datalist>");
117
- }
118
- if (options.hint) {
119
- lines.push("<div class=\"hint\">" + options.hint + "</div>");
120
- }
121
- return lines;
122
- }
123
- /**
124
- * Groups profiles by their declared category for UI display. Each profile declares its own category (api, keyboard, multiChannel, special) and this helper
125
- * simply filters by that field. The display order (api, keyboard, special, multiChannel) is determined by the caller.
126
- * @param profiles - List of available profiles with category, descriptions, and summaries.
127
- * @returns Object with profiles grouped by category.
128
- */
129
- function categorizeProfiles(profiles) {
130
- return {
131
- api: profiles.filter((p) => (p.category === "api")),
132
- keyboard: profiles.filter((p) => (p.category === "keyboard")),
133
- multiChannel: profiles.filter((p) => (p.category === "multiChannel")),
134
- special: profiles.filter((p) => (p.category === "special"))
135
- };
136
- }
137
- /**
138
- * Generates HTML for the profile dropdown field with descriptions as tooltips and summaries inline.
139
- * @param id - The select element ID.
140
- * @param selectedProfile - The currently selected profile (empty string for autodetect).
141
- * @param profiles - List of available profiles with descriptions and summaries.
142
- * @param showHint - Whether to show the hint text with profile reference link.
143
- * @returns Array of HTML strings for the form row.
144
- */
145
- function generateProfileDropdown(id, selectedProfile, profiles, showHint = true) {
146
- const lines = [];
147
- const groups = categorizeProfiles(profiles);
148
- // Helper to generate option elements for a profile.
149
- const renderOption = (profile) => {
150
- const selected = (profile.name === selectedProfile) ? " selected" : "";
151
- const title = profile.description ? " title=\"" + escapeHtml(profile.description) + "\"" : "";
152
- const displayText = profile.summary ? profile.name + " \u2014 " + profile.summary : profile.name;
153
- return "<option value=\"" + escapeHtml(profile.name) + "\"" + title + selected + ">" + escapeHtml(displayText) + "</option>";
154
- };
155
- lines.push("<div class=\"form-row\">");
156
- lines.push("<label for=\"" + id + "\">Profile</label>");
157
- lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\">");
158
- lines.push("<option value=\"\">Autodetect (Recommended)</option>");
159
- // Fullscreen API profiles (most common).
160
- if (groups.api.length > 0) {
161
- lines.push("<optgroup label=\"Fullscreen API\">");
162
- for (const profile of groups.api) {
163
- lines.push(renderOption(profile));
164
- }
165
- lines.push("</optgroup>");
166
- }
167
- // Keyboard fullscreen profiles.
168
- if (groups.keyboard.length > 0) {
169
- lines.push("<optgroup label=\"Keyboard Fullscreen\">");
170
- for (const profile of groups.keyboard) {
171
- lines.push(renderOption(profile));
172
- }
173
- lines.push("</optgroup>");
174
- }
175
- // Special profiles.
176
- if (groups.special.length > 0) {
177
- lines.push("<optgroup label=\"Special\">");
178
- for (const profile of groups.special) {
179
- lines.push(renderOption(profile));
180
- }
181
- lines.push("</optgroup>");
182
- }
183
- // Multi-channel profiles (at the end).
184
- if (groups.multiChannel.length > 0) {
185
- lines.push("<optgroup label=\"Multi-Channel (needs selector)\">");
186
- for (const profile of groups.multiChannel) {
187
- lines.push(renderOption(profile));
188
- }
189
- lines.push("</optgroup>");
190
- }
191
- lines.push("</select>");
192
- lines.push("</div>");
193
- if (showHint) {
194
- lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
195
- "try experimenting with different profiles. ");
196
- lines.push("<a href=\"#\" onclick=\"toggleProfileReference(); return false;\">View profile reference</a></div>");
197
- }
198
- return lines;
199
- }
200
- /**
201
- * Generates HTML for the profile reference section. This collapsible section provides detailed documentation for all available profiles, grouped by category to
202
- * help users understand which profile to select for their site.
203
- * @param profiles - List of available profiles with descriptions and summaries.
204
- * @returns HTML string for the profile reference section.
205
- */
206
- function generateProfileReference(profiles) {
207
- const lines = [];
208
- const groups = categorizeProfiles(profiles);
209
- lines.push("<div id=\"profile-reference\" class=\"profile-reference\" style=\"display: none;\">");
210
- lines.push("<div class=\"profile-reference-header\">");
211
- lines.push("<h3>Profile Reference</h3>");
212
- lines.push("<button type=\"button\" class=\"profile-reference-close\" aria-label=\"Close\" onclick=\"toggleProfileReference()\">\u2715</button>");
213
- lines.push("</div>");
214
- lines.push("<p class=\"reference-intro\">Profiles configure how PrismCast interacts with different video players. Autodetect uses predefined ");
215
- lines.push("profiles for known sites. If video doesn't play or fullscreen fails, use this reference to experiment with different profiles.</p>");
216
- // Fullscreen API profiles (most common).
217
- if (groups.api.length > 0) {
218
- lines.push("<div class=\"profile-category\">");
219
- lines.push("<h4>Fullscreen API Profiles</h4>");
220
- lines.push("<p class=\"category-desc\">For single-channel sites that require JavaScript's requestFullscreen() API instead of keyboard shortcuts.</p>");
221
- lines.push("<dl class=\"profile-list\">");
222
- for (const profile of groups.api) {
223
- lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
224
- lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
225
- }
226
- lines.push("</dl>");
227
- lines.push("</div>");
228
- }
229
- // Keyboard fullscreen profiles.
230
- if (groups.keyboard.length > 0) {
231
- lines.push("<div class=\"profile-category\">");
232
- lines.push("<h4>Keyboard Fullscreen Profiles</h4>");
233
- lines.push("<p class=\"category-desc\">For single-channel sites that use the 'f' key to toggle fullscreen mode.</p>");
234
- lines.push("<dl class=\"profile-list\">");
235
- for (const profile of groups.keyboard) {
236
- lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
237
- lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
238
- }
239
- lines.push("</dl>");
240
- lines.push("</div>");
241
- }
242
- // Special profiles.
243
- if (groups.special.length > 0) {
244
- lines.push("<div class=\"profile-category\">");
245
- lines.push("<h4>Special Profiles</h4>");
246
- lines.push("<p class=\"category-desc\">For non-standard use cases like static pages without video.</p>");
247
- lines.push("<dl class=\"profile-list\">");
248
- for (const profile of groups.special) {
249
- lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
250
- lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
251
- }
252
- lines.push("</dl>");
253
- lines.push("</div>");
254
- }
255
- // Multi-channel profiles (requires channel selector) - at the end since these are more advanced.
256
- if (groups.multiChannel.length > 0) {
257
- lines.push("<div class=\"profile-category\">");
258
- lines.push("<h4>Multi-Channel Profiles</h4>");
259
- lines.push("<p class=\"category-desc\">For sites that host multiple live channels on a single page. These profiles require a channel selector ");
260
- lines.push("to identify which channel to tune to. Set the Channel Selector field in Advanced Options when using these profiles.</p>");
261
- lines.push("<dl class=\"profile-list\">");
262
- for (const profile of groups.multiChannel) {
263
- lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
264
- lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
265
- }
266
- lines.push("</dl>");
267
- // Per-strategy guidance for finding Channel Selector values. Organized by strategy type since the same strategy can be used across multiple profiles.
268
- lines.push("<h4 class=\"selector-guide-heading\">Finding Your Channel Selector</h4>");
269
- lines.push("<p class=\"category-desc\">Predefined channels already have Channel Selector values set. For custom channels, the value depends on the ");
270
- lines.push("profile's strategy type:</p>");
271
- lines.push("<dl class=\"profile-list\">");
272
- lines.push("<dt>apiMultiVideo, disneyPlus, keyboardDynamicMultiVideo (element selector)</dt>");
273
- lines.push("<dd>These profiles use a <code>matchSelector</code> CSS template to find the channel element. The default pattern matches image URLs: ");
274
- lines.push("right-click the channel's image on the site \u2192 Inspect Element \u2192 find the &lt;img&gt; tag \u2192 copy a unique portion ");
275
- lines.push("of the <code>src</code> URL that identifies the channel (e.g., \"espn\" from a URL containing \"poster_linear_espn_none\"). ");
276
- lines.push("Custom <code>matchSelector</code> patterns can match any attribute (aria-label, data-testid, title, etc.).</dd>");
277
- lines.push("<dt>foxLive (station code)</dt>");
278
- lines.push("<dd>Inspect a channel logo in the guide \u2192 find the <code>&lt;button&gt;</code> inside <code>GuideChannelLogo</code> \u2192 use ");
279
- lines.push("the <code>title</code> attribute value (e.g., BTN, FOXD2C, FS1, FS2, FWX).</dd>");
280
- lines.push("<dt>hboMax (channel name)</dt>");
281
- lines.push("<dd>Inspect a channel tile in the HBO rail \u2192 find the <code>&lt;p aria-hidden=\"true\"&gt;</code> element \u2192 use the text ");
282
- lines.push("content (e.g., HBO, HBO Comedy, HBO Drama, HBO Hits, HBO Movies).</dd>");
283
- lines.push("<dt>huluLive (channel name)</dt>");
284
- lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with ");
285
- lines.push("<code>live-guide-channel-kyber-</code> \u2192 use the portion after that prefix. The name may differ from the logo shown ");
286
- lines.push("(e.g., the full name rather than an abbreviation). For local affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast ");
287
- lines.push("resolves the local station automatically.</dd>");
288
- lines.push("<dt>slingLive (channel name)</dt>");
289
- lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with <code>channel-</code> ");
290
- lines.push("\u2192 use the portion after that prefix. The name may differ from the logo shown (e.g., \"FOX Sports 1\" not \"FS1\"). For local ");
291
- lines.push("affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast resolves the local station automatically.</dd>");
292
- lines.push("<dt>youtubeTV (channel name)</dt>");
293
- lines.push("<dd>Inspect a channel thumbnail in the guide \u2192 find the <code>aria-label</code> attribute on the ");
294
- lines.push("<code>ytu-endpoint</code> element \u2192 use the name after \"watch \" (e.g., <code>aria-label=\"watch CNN\"</code> \u2192 CNN). ");
295
- lines.push("For locals, use the network name (e.g., NBC) \u2014 affiliates like \"NBC 5\" are resolved automatically. PBS resolves to the ");
296
- lines.push("local affiliate in major markets.</dd>");
297
- lines.push("</dl>");
298
- lines.push("</div>");
299
- }
300
- lines.push("</div>");
301
- return lines.join("\n");
302
- }
303
- /**
304
- * Generates HTML for the advanced fields section (station ID, channel selector, and channel number).
305
- * @param idPrefix - Prefix for element IDs ("add" or "edit").
306
- * @param stationIdValue - Current station ID value.
307
- * @param channelSelectorValue - Current channel selector value.
308
- * @param channelNumberValue - Current channel number value.
309
- * @param showHints - Whether to show hint text.
310
- * @returns Array of HTML strings for the advanced fields section.
311
- */
312
- function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue, channelNumberValue, showHints = true) {
313
- const lines = [];
314
- // Advanced fields toggle.
315
- lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
316
- "-advanced').classList.toggle('show'); this.textContent = this.textContent === 'Show Advanced Options' ? " +
317
- "'Hide Advanced Options' : 'Show Advanced Options';\">Show Advanced Options</div>");
318
- lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
319
- // Station ID.
320
- const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
321
- lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { hint: stationIdHint, placeholder: showHints ? "e.g., 12345" : undefined }));
322
- // Channel selector.
323
- const channelSelectorHint = showHints ?
324
- "Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
325
- "For guide-based profiles (Fox, HBO Max, Hulu, Sling, YouTube TV), use the channel name or station code from the guide. " +
326
- "For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
327
- "(typically a portion of the image src URL)." :
328
- undefined;
329
- lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
330
- // Channel number for Channels DVR and Plex integration.
331
- const channelNumberHint = showHints ?
332
- "Optional numeric channel number for guide matching in Channels DVR and Plex." :
333
- undefined;
334
- lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
335
- lines.push("</div>"); // End advanced fields.
336
- return lines;
337
- }
338
- /**
339
- * Generates a JavaScript object literal mapping URL hostnames to known channel selector values from predefined channels. This data is embedded as a `<script>` block
340
- * in the channels panel so the client-side datalist can offer suggestions based on the URL the user enters.
341
- *
342
- * @returns A JavaScript variable declaration string ready to embed in a `<script>` tag.
343
- */
344
- function generateChannelSelectorData() {
345
- const byDomain = {};
346
- for (const channel of Object.values(PREDEFINED_CHANNELS)) {
347
- if (!channel.channelSelector) {
348
- continue;
349
- }
350
- const hostname = new URL(channel.url).hostname;
351
- byDomain[hostname] ??= [];
352
- byDomain[hostname].push({ label: channel.name ?? channel.channelSelector, value: channel.channelSelector });
353
- }
354
- // Sort entries within each domain alphabetically by label for consistent ordering in the datalist dropdown.
355
- for (const entries of Object.values(byDomain)) {
356
- entries.sort((a, b) => a.label.localeCompare(b.label));
357
- }
358
- return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";";
359
- }
360
- /**
361
- * Generates the HTML for a single channel's table rows (display row and edit form row). All channels — predefined, override, and user-defined — get both rows.
362
- * The edit form is pre-populated with the effective (resolved) values so users see what they're changing. When called from generateChannelsPanel() which already
363
- * has the listing entry, pass it via the entry parameter to avoid redundant getChannelListing() calls. POST handlers that generate a single row omit the
364
- * parameter to trigger an internal lookup.
365
- * @param key - The channel key.
366
- * @param profiles - List of available profiles with descriptions for the dropdown.
367
- * @param entry - Optional pre-resolved listing entry. When omitted, looked up from getChannelListing().
368
- * @returns Object with displayRow and editRow HTML strings.
369
- */
370
- export function generateChannelRowHtml(key, profiles, entry) {
371
- // Resolve the effective channel. Use the provided entry if available, otherwise look it up from the listing.
372
- const listing = entry ?? getChannelListing().find((e) => e.key === key);
373
- // If channel doesn't exist in the listing, return empty rows (shouldn't happen in normal use).
374
- if (!listing) {
375
- return { displayRow: "", editRow: "" };
376
- }
377
- const channel = listing.channel;
378
- // Resolve the selected provider's channel data for display purposes (edit form pre-population). This ensures the values shown reflect the currently selected provider.
379
- const resolvedKey = resolveProviderKey(key);
380
- const resolvedChannel = getResolvedChannel(resolvedKey);
381
- const displayChannel = resolvedChannel ?? channel;
382
- const isUser = isUserChannel(key);
383
- const isPredefined = isPredefinedChannel(key);
384
- const isOverride = isPredefined && isUser;
385
- const isDisabled = isPredefinedChannelDisabled(key);
386
- const isAvailableByProvider = isChannelAvailableByProvider(key);
387
- // Check if this channel has multiple providers.
388
- const providerGroup = getProviderGroup(key);
389
- // Build the provider tags data attribute for client-side filtering.
390
- const providerTags = getChannelProviderTags(key).join(",");
391
- // Generate display row. User channels get one CSS class, disabled predefined get another, provider-filtered get a third.
392
- const displayLines = [];
393
- const rowClasses = [];
394
- if (isUser) {
395
- rowClasses.push("user-channel");
396
- }
397
- if (isDisabled) {
398
- rowClasses.push("channel-disabled");
399
- }
400
- if (!isAvailableByProvider) {
401
- rowClasses.push("channel-unavailable");
402
- }
403
- const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
404
- displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) + "\">");
405
- displayLines.push("<td class=\"ch-key\">" + escapeHtml(key) + "</td>");
406
- displayLines.push("<td>" + escapeHtml(channel.name ?? key) + "</td>");
407
- // Provider column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers"
408
- // label alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
409
- displayLines.push("<td>");
410
- const labelHidden = isAvailableByProvider ? " style=\"display:none\"" : "";
411
- const contentHidden = isAvailableByProvider ? "" : " style=\"display:none\"";
412
- displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available providers</em>");
413
- if (hasMultipleProviders(key) && providerGroup) {
414
- // Multi-provider: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the provider selection changes. Filtered-out
415
- // options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls filterChannelRows()
416
- // to remove them from the DOM.
417
- const currentSelection = getProviderSelection(key) ?? key;
418
- displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) + "\" onchange=\"updateProviderSelection(this)\"" +
419
- contentHidden + ">");
420
- for (const variant of providerGroup.variants) {
421
- const selected = (variant.key === currentSelection) ? " selected" : "";
422
- const tag = getProviderTagForChannel(variant.key);
423
- const optionHidden = !isProviderTagEnabled(tag) ? " hidden" : "";
424
- displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(tag) + "\"" + selected + optionHidden + ">" +
425
- escapeHtml(variant.label) + "</option>");
426
- }
427
- displayLines.push("</select>");
428
- }
429
- else {
430
- // Single-provider: wrap the provider name in a span so client-side JS can toggle it with the no-provider label.
431
- displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
432
- escapeHtml(channel.provider ?? getProviderDisplayName(channel.url)) + "</span>");
433
- }
434
- displayLines.push("</td>");
435
- // Actions column with icon buttons. Four positions per row: Edit (always), Login/placeholder (middle), a third action that varies by row type, and Copy URL.
436
- displayLines.push("<td>");
437
- displayLines.push("<div class=\"btn-group\">");
438
- const escapedKey = escapeHtml(key);
439
- // Position 1: Edit (all channels).
440
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
441
- "')\">" + ICON_EDIT + "</button>");
442
- // Position 2: Login for enabled channels, placeholder for disabled predefined.
443
- if (!isDisabled) {
444
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-login\" title=\"Login\" aria-label=\"Login\" onclick=\"startChannelLogin('" +
445
- escapedKey + "')\">" + ICON_LOGIN + "</button>");
446
- }
447
- else {
448
- displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
449
- }
450
- // Position 3: varies by row type.
451
- if (isOverride) {
452
- // Override: revert to predefined defaults.
453
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-revert\" title=\"Revert to defaults\" aria-label=\"Revert to defaults\"" +
454
- " onclick=\"revertChannel('" + escapedKey + "')\">" + ICON_REVERT + "</button>");
455
- }
456
- else if (isUser && !isPredefined) {
457
- // User-defined (not an override): delete.
458
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
459
- escapedKey + "')\">" + ICON_DELETE + "</button>");
460
- }
461
- else if (isPredefined) {
462
- // Predefined (no override): enable/disable toggle.
463
- if (isDisabled) {
464
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
465
- escapedKey + "', true)\">" + ICON_ENABLE + "</button>");
466
- }
467
- else {
468
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-disable\" title=\"Disable\" aria-label=\"Disable\" onclick=\"togglePredefinedChannel('" +
469
- escapedKey + "', false)\">" + ICON_DISABLE + "</button>");
470
- }
471
- }
472
- // Position 4: Copy URL dropdown (all channels).
473
- displayLines.push("<div class=\"dropdown copy-dropdown\">");
474
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-copy\" title=\"Copy stream URL\" aria-label=\"Copy stream URL\" " +
475
- "onclick=\"toggleDropdown(this)\">" + ICON_COPY + "</button>");
476
- displayLines.push("<div class=\"dropdown-menu copy-url-menu\">");
477
- displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('hls', '" + escapedKey + "')\">Copy HLS URL</div>");
478
- displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('mpegts', '" + escapedKey + "')\">Copy MPEG-TS URL</div>");
479
- displayLines.push("</div>");
480
- displayLines.push("</div>");
481
- displayLines.push("</div>");
482
- displayLines.push("</td>");
483
- displayLines.push("</tr>");
484
- const displayRow = displayLines.join("\n");
485
- // Generate edit form row for all channels. Pre-populate with the currently selected provider's values so the user sees what they're actually streaming, not the
486
- // canonical definition which may differ when a provider variant is selected.
487
- const editLines = [];
488
- editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
489
- editLines.push("<td colspan=\"4\">");
490
- editLines.push("<div class=\"channel-form\" style=\"margin: 0;\">");
491
- editLines.push("<h3>Edit Channel: " + escapedKey + "</h3>");
492
- editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
493
- editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
494
- editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
495
- // Channel name.
496
- editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", displayChannel.name ?? key, {
497
- hint: "Friendly name shown in the playlist and UI.",
498
- required: true
499
- }));
500
- // Channel URL.
501
- editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", displayChannel.url, {
502
- hint: "The URL of the streaming page to capture.",
503
- required: true,
504
- type: "url"
505
- }));
506
- // Profile dropdown.
507
- editLines.push(...generateProfileDropdown("edit-profile-" + key, displayChannel.profile ?? "", profiles));
508
- // Advanced fields.
509
- editLines.push(...generateAdvancedFields("edit-" + key, displayChannel.stationId ?? "", displayChannel.channelSelector ?? "", displayChannel.channelNumber ? String(displayChannel.channelNumber) : ""));
510
- // Form buttons.
511
- editLines.push("<div class=\"form-buttons\">");
512
- editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
513
- editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapedKey + "')\">Cancel</button>");
514
- editLines.push("</div>");
515
- editLines.push("</form>");
516
- editLines.push("</div>");
517
- editLines.push("</td>");
518
- editLines.push("</tr>");
519
- const editRow = editLines.join("\n");
520
- return { displayRow, editRow };
521
- }
522
- /**
523
- * Formats a value for display, converting numbers to human-readable strings where appropriate.
524
- * @param value - The value to format.
525
- * @returns Formatted string for display.
526
- */
527
- function formatValueForDisplay(value, settingType) {
528
- if ((value === null) || (value === undefined)) {
529
- return "";
530
- }
531
- if (typeof value === "number") {
532
- // Format large numbers with commas for readability, except for port numbers where commas would be confusing.
533
- if ((value >= 1000) && (settingType !== "port")) {
534
- return value.toLocaleString();
535
- }
536
- return String(value);
537
- }
538
- if (typeof value === "string") {
539
- return value;
540
- }
541
- // Config values are always primitives (string, number, boolean). Numbers and strings are handled above.
542
- return String(value);
543
- }
544
- /**
545
- * Converts a stored value to a display value using the setting's displayDivisor.
546
- * @param value - The stored value.
547
- * @param setting - The setting metadata.
548
- * @returns The display value.
549
- */
550
- function toDisplayValue(value, setting) {
551
- if ((value === null) || (value === undefined)) {
552
- return null;
553
- }
554
- if ((typeof value === "number") && setting.displayDivisor) {
555
- const displayValue = value / setting.displayDivisor;
556
- // Determine precision: explicit displayPrecision, or 2 for floats, or 1 for integers with displayDivisor (to handle values like 1500ms → 1.5s).
557
- const precision = setting.displayPrecision ?? ((setting.type === "float") ? 2 : 1);
558
- return Number(displayValue.toFixed(precision));
559
- }
560
- // Boolean values pass through as strings for display.
561
- if (typeof value === "boolean") {
562
- return String(value);
563
- }
564
- return value;
565
- }
566
- /**
567
- * Gets the effective unit to display for a setting.
568
- * @param setting - The setting metadata.
569
- * @returns The unit string to display.
570
- */
571
- function getDisplayUnit(setting) {
572
- return setting.displayUnit ?? setting.unit;
573
- }
574
- /**
575
- * Mapping of units that require pluralization to their singular and plural forms. Abbreviations like "ms", "kbps", "fps" do not need pluralization and are not
576
- * included here. Uses Partial<Record> to indicate that not all string keys have values.
577
- */
578
- const UNIT_PLURALIZATION = {
579
- minutes: { plural: "minutes", singular: "minute" },
580
- seconds: { plural: "seconds", singular: "second" }
581
- };
582
- /**
583
- * Formats a unit string with correct pluralization based on the value. Returns singular form when value is 1, plural otherwise. Units not in the pluralization
584
- * mapping (abbreviations) pass through unchanged.
585
- * @param value - The numeric value to check for pluralization.
586
- * @param unit - The unit string to format.
587
- * @returns The correctly pluralized unit string.
588
- */
589
- function formatUnitForValue(value, unit) {
590
- const forms = UNIT_PLURALIZATION[unit];
591
- if (!forms) {
592
- return unit;
593
- }
594
- return (value === 1) ? forms.singular : forms.plural;
595
- }
596
- /**
597
- * Gets the effective min value for display (converted if displayDivisor is set).
598
- * @param setting - The setting metadata.
599
- * @returns The min value for the input field.
600
- */
601
- function getDisplayMin(setting) {
602
- if ((setting.min === undefined) || !setting.displayDivisor) {
603
- return setting.min;
604
- }
605
- return setting.min / setting.displayDivisor;
606
- }
607
- /**
608
- * Gets the effective max value for display (converted if displayDivisor is set).
609
- * @param setting - The setting metadata.
610
- * @returns The max value for the input field.
611
- */
612
- function getDisplayMax(setting) {
613
- if ((setting.max === undefined) || !setting.displayDivisor) {
614
- return setting.max;
615
- }
616
- return setting.max / setting.displayDivisor;
617
- }
618
- /**
619
- * Determines the appropriate width class for a form field (input or select) based on the setting type, constraints, and displayed value range. Width is proportional
620
- * to the actual displayed content rather than raw stored values, accounting for displayDivisor conversion.
621
- * @param setting - The setting metadata.
622
- * @returns CSS class name for field width (field-narrow, field-medium, or field-wide).
623
- */
624
- function getFieldWidthClass(setting) {
625
- // Ports always get narrow (max 5 digits: 65535).
626
- if (setting.type === "port") {
627
- return "field-narrow";
628
- }
629
- // For selects (settings with validValues), determine width based on content.
630
- if (setting.validValues && (setting.validValues.length > 0)) {
631
- // Quality preset dropdown needs wide width because it displays dynamic degradation text like "1080p (limited to 720p High)" which is much longer than the
632
- // static validValues entries.
633
- if (setting.path === "streaming.qualityPreset") {
634
- return "field-wide";
635
- }
636
- const maxLength = Math.max(...setting.validValues.map((v) => v.length));
637
- // Short options (e.g., "none", "all", "errors") get narrow width.
638
- if (maxLength <= 8) {
639
- return "field-narrow";
640
- }
641
- // Medium options (e.g., "filtered") get medium width.
642
- if (maxLength <= 12) {
643
- return "field-medium";
644
- }
645
- // Long options get wide width.
646
- return "field-wide";
647
- }
648
- // For numeric types, calculate displayed digit count to determine width.
649
- if ((setting.type === "integer") || (setting.type === "float")) {
650
- // Calculate the displayed max value, accounting for displayDivisor conversion.
651
- let displayMax = setting.max;
652
- if ((displayMax !== undefined) && setting.displayDivisor) {
653
- displayMax = displayMax / setting.displayDivisor;
654
- }
655
- // If no max is defined, default to medium width as a safe middle ground.
656
- if (displayMax === undefined) {
657
- return "field-medium";
658
- }
659
- // Count digits needed for the displayed max value. For floats, add characters for decimal point and fractional digits.
660
- let digitCount = Math.max(1, Math.floor(Math.log10(Math.abs(displayMax))) + 1);
661
- if (setting.type === "float") {
662
- digitCount = digitCount + 3;
663
- }
664
- // 1-4 digits get narrow (e.g., port, small counts, converted timeouts like "30" seconds).
665
- if (digitCount <= 4) {
666
- return "field-narrow";
667
- }
668
- // 5-7 digits get medium (e.g., larger bitrates).
669
- if (digitCount <= 7) {
670
- return "field-medium";
671
- }
672
- // 8+ digits get wide.
673
- return "field-wide";
674
- }
675
- // Hosts and paths get wide width. Hosts can be IP addresses like "192.168.100.100" (15 chars) or hostnames.
676
- if ((setting.type === "host") || (setting.type === "path")) {
677
- return "field-wide";
678
- }
679
- // Generic strings get wide width.
680
- return "field-wide";
681
- }
682
- /**
683
- * Generates HTML for a single setting form field. Supports text inputs, number inputs, and select dropdowns based on the setting type and validValues.
684
- * @param setting - The setting metadata.
685
- * @param currentValue - The current effective value (in storage units).
686
- * @param defaultValue - The default value (in storage units).
687
- * @param envOverride - The environment variable value if overridden, undefined otherwise.
688
- * @param validationError - Validation error message if any.
689
- * @returns HTML string for the form field.
690
- */
691
- function generateSettingField(setting, currentValue, defaultValue, envOverride, validationError) {
692
- const isDisabled = (envOverride !== undefined) || (setting.disabledReason !== undefined);
693
- const inputId = setting.path.replace(/\./g, "-");
694
- const hasError = validationError !== undefined;
695
- const isModified = !isDisabled && !isEqualToDefault(currentValue, defaultValue);
696
- // Convert values for display.
697
- const displayValue = toDisplayValue(currentValue, setting);
698
- const displayDefault = toDisplayValue(defaultValue, setting);
699
- const displayUnit = getDisplayUnit(setting);
700
- const displayMin = getDisplayMin(setting);
701
- const displayMax = getDisplayMax(setting);
702
- // Determine if this should be a select dropdown.
703
- const hasValidValues = setting.validValues && (setting.validValues.length > 0);
704
- // Check if this setting depends on a boolean toggle that is currently disabled. The depends-disabled class applies a visual grey-out without actually
705
- // disabling the inputs, so values are still submitted during save.
706
- const dependsOnId = setting.dependsOn ? setting.dependsOn.replace(/\./g, "-") : undefined;
707
- const isDependencyDisabled = setting.dependsOn ? !getNestedValue(CONFIG, setting.dependsOn) : false;
708
- // Build CSS classes for the form group.
709
- const groupClasses = ["form-group"];
710
- if (isDisabled) {
711
- groupClasses.push("disabled");
712
- }
713
- if (isModified) {
714
- groupClasses.push("modified");
715
- }
716
- if (isDependencyDisabled) {
717
- groupClasses.push("depends-disabled");
718
- }
719
- // Build the opening div with optional data-depends-on attribute for client-side toggle behavior.
720
- const dependsAttr = dependsOnId ? " data-depends-on=\"" + dependsOnId + "\"" : "";
721
- const lines = [
722
- "<div class=\"" + groupClasses.join(" ") + "\"" + dependsAttr + ">",
723
- "<div class=\"form-row\">",
724
- "<label class=\"form-label\" for=\"" + inputId + "\">"
725
- ];
726
- // Add modified indicator before label text.
727
- if (isModified) {
728
- lines.push("<span class=\"modified-dot\" title=\"Modified from default\"></span>");
729
- }
730
- lines.push(escapeHtml(setting.label));
731
- if (envOverride !== undefined) {
732
- lines.push("<span class=\"env-badge\">ENV</span>");
733
- }
734
- lines.push("</label>");
735
- // Track if the selected preset is degraded (used for inline message).
736
- let selectedPresetDegradedTo = null;
737
- if (hasValidValues) {
738
- // Render as select dropdown.
739
- const selectAttrs = [
740
- "class=\"form-select " + getFieldWidthClass(setting) + (hasError ? " error" : "") + "\"",
741
- "id=\"" + inputId + "\"",
742
- "name=\"" + setting.path + "\"",
743
- "data-default=\"" + escapeHtml(String(displayDefault ?? "")) + "\""
744
- ];
745
- if (isDisabled) {
746
- selectAttrs.push("disabled");
747
- }
748
- if (isDependencyDisabled) {
749
- selectAttrs.push("tabindex=\"-1\"");
750
- }
751
- lines.push("<select " + selectAttrs.join(" ") + ">");
752
- // Special handling for quality preset dropdown to show degradation info.
753
- if (setting.path === "streaming.qualityPreset") {
754
- const presetOptions = getPresetOptionsWithDegradation();
755
- for (const option of presetOptions.options) {
756
- const presetId = option.preset.id;
757
- const isSelected = presetId === currentValue;
758
- const selected = isSelected ? " selected" : "";
759
- // Build the display label with degradation annotation if applicable.
760
- let label = option.preset.name;
761
- if (option.degradedTo) {
762
- label = label + " (limited to " + option.degradedTo.name + ")";
763
- // Track if the selected preset is degraded.
764
- if (isSelected) {
765
- selectedPresetDegradedTo = option.degradedTo.name;
766
- }
767
- }
768
- lines.push("<option value=\"" + escapeHtml(presetId) + "\"" + selected + ">" + escapeHtml(label) + "</option>");
769
- }
770
- }
771
- else {
772
- // Standard dropdown for non-preset fields.
773
- for (const validValue of setting.validValues ?? []) {
774
- // For boolean types, compare string validValue with stringified currentValue to handle boolean-to-string comparison.
775
- const isSelected = (setting.type === "boolean") ?
776
- (validValue === String(currentValue)) :
777
- (validValue === currentValue);
778
- const selected = isSelected ? " selected" : "";
779
- lines.push("<option value=\"" + escapeHtml(validValue) + "\"" + selected + ">" + escapeHtml(validValue) + "</option>");
780
- }
781
- }
782
- lines.push("</select>");
783
- }
784
- else if (setting.type === "boolean") {
785
- // Render boolean as a checkbox. A hidden input with value "false" precedes the checkbox so that unchecking submits "false" rather than omitting the field
786
- // entirely (which would cause the server to skip it and fall back to the default).
787
- const isChecked = (currentValue === true) || (currentValue === "true");
788
- const defaultStr = defaultValue ? "true" : "false";
789
- lines.push("<input type=\"hidden\" name=\"" + setting.path + "\" value=\"false\">");
790
- const checkboxAttrs = [
791
- "class=\"form-checkbox\"",
792
- "type=\"checkbox\"",
793
- "id=\"" + inputId + "\"",
794
- "name=\"" + setting.path + "\"",
795
- "value=\"true\"",
796
- "data-default=\"" + escapeHtml(defaultStr) + "\""
797
- ];
798
- if (isChecked) {
799
- checkboxAttrs.push("checked");
800
- }
801
- if (isDisabled) {
802
- checkboxAttrs.push("disabled");
803
- }
804
- if (isDependencyDisabled) {
805
- checkboxAttrs.push("tabindex=\"-1\"");
806
- }
807
- lines.push("<input " + checkboxAttrs.join(" ") + ">");
808
- }
809
- else {
810
- // Render as input field.
811
- const inputType = (setting.type === "float") ? "number" : (((setting.type === "integer") || (setting.type === "port")) ? "number" : "text");
812
- // Calculate step for arrow key increments. Auto-derived from min/displayDivisor: when the min in display units is between 0 and 1 (exclusive), use it as the
813
- // step (e.g., 500ms → 0.5s step); otherwise step is 1 whole display unit. This gives meaningful arrow increments and constrains input to a sensible value grid
814
- // (e.g., 0.5, 1.0, 1.5, ... for half-second steps) rather than the old 1/displayDivisor approach which produced unusably small increments.
815
- let step = "1";
816
- if (setting.displayDivisor && (setting.min !== undefined)) {
817
- const displayMin = setting.min / setting.displayDivisor;
818
- step = (displayMin > 0) && (displayMin < 1) ? String(displayMin) : "1";
819
- }
820
- else if (setting.type === "float") {
821
- step = "0.01";
822
- }
823
- const inputAttrs = [
824
- "class=\"form-input " + getFieldWidthClass(setting) + (hasError ? " error" : "") + "\"",
825
- "type=\"" + inputType + "\"",
826
- "id=\"" + inputId + "\"",
827
- "name=\"" + setting.path + "\"",
828
- "data-default=\"" + escapeHtml(String(displayDefault ?? "")) + "\""
829
- ];
830
- // Add value.
831
- if (displayValue !== null) {
832
- inputAttrs.push("value=\"" + escapeHtml(String(displayValue)) + "\"");
833
- }
834
- // Add step for numbers.
835
- if (inputType === "number") {
836
- inputAttrs.push("step=\"" + step + "\"");
837
- }
838
- // Add min/max if specified (using display values).
839
- if (displayMin !== undefined) {
840
- inputAttrs.push("min=\"" + String(displayMin) + "\"");
841
- }
842
- if (displayMax !== undefined) {
843
- inputAttrs.push("max=\"" + String(displayMax) + "\"");
844
- }
845
- // Disable if overridden by env var.
846
- if (isDisabled) {
847
- inputAttrs.push("disabled");
848
- }
849
- if (isDependencyDisabled) {
850
- inputAttrs.push("tabindex=\"-1\"");
851
- }
852
- lines.push("<input " + inputAttrs.join(" ") + ">");
853
- }
854
- // Add unit label if present.
855
- if (displayUnit) {
856
- lines.push("<span class=\"form-unit\">" + escapeHtml(displayUnit) + "</span>");
857
- }
858
- // Add reset button for modified settings.
859
- if (isModified) {
860
- lines.push("<button type=\"button\" class=\"btn-reset\" onclick=\"resetSetting('" + escapeHtml(setting.path) +
861
- "')\" title=\"Reset to default\" aria-label=\"Reset to default\">&#8635;</button>");
862
- }
863
- lines.push("</div>");
864
- // Add description.
865
- lines.push("<div class=\"form-description\">" + escapeHtml(setting.description) + "</div>");
866
- // Add disabled reason warning when a setting is locked out due to an upstream issue.
867
- if (setting.disabledReason) {
868
- lines.push("<div class=\"form-warning\">" + escapeHtml(setting.disabledReason) + "</div>");
869
- }
870
- // Add inline message for degraded preset.
871
- if (selectedPresetDegradedTo) {
872
- lines.push("<div class=\"form-warning\">Your display cannot support this resolution. Streams will use " +
873
- escapeHtml(selectedPresetDegradedTo) + " instead.</div>");
874
- }
875
- // Add default value hint with properly pluralized unit.
876
- let defaultDisplay;
877
- if (displayDefault === null) {
878
- defaultDisplay = "autodetect";
879
- }
880
- else if (typeof displayDefault === "number") {
881
- defaultDisplay = formatValueForDisplay(displayDefault, setting.type);
882
- }
883
- else {
884
- defaultDisplay = displayDefault;
885
- }
886
- // Format the unit with correct pluralization based on the default value.
887
- let formattedUnit = "";
888
- if (displayUnit && (typeof displayDefault === "number")) {
889
- formattedUnit = " " + formatUnitForValue(displayDefault, displayUnit);
890
- }
891
- else if (displayUnit) {
892
- formattedUnit = " " + displayUnit;
893
- }
894
- lines.push("<div class=\"form-default\">Default: " + escapeHtml(defaultDisplay) + formattedUnit + "</div>");
895
- // Add env var override notice if applicable.
896
- if (isDisabled && setting.envVar && envOverride) {
897
- lines.push("<div class=\"form-env\">Overridden by environment variable: <code>" + escapeHtml(setting.envVar) + "=" +
898
- escapeHtml(envOverride) + "</code></div>");
899
- }
900
- // Add validation error if present.
901
- if (hasError) {
902
- lines.push("<div class=\"form-error\">" + escapeHtml(validationError) + "</div>");
903
- }
904
- lines.push("</div>");
905
- return lines.join("\n");
906
- }
907
- /**
908
- * Validates a single setting value (in storage units, after conversion from display units).
909
- * @param setting - The setting metadata.
910
- * @param value - The value to validate (in storage units).
911
- * @returns Validation error message if invalid, undefined if valid.
912
- */
913
- function validateSettingValue(setting, value) {
914
- // Allow empty string for path type (means null/autodetect).
915
- if ((setting.type === "path") && ((value === "") || (value === null))) {
916
- return undefined;
917
- }
918
- // Validate string type with validValues.
919
- if ((setting.type === "string") && setting.validValues && (setting.validValues.length > 0)) {
920
- if (!setting.validValues.includes(value)) {
921
- return setting.label + " must be one of: " + setting.validValues.join(", ");
922
- }
923
- return undefined;
924
- }
925
- // Validate based on type.
926
- switch (setting.type) {
927
- case "boolean": {
928
- // After parseFormValue, value should be a boolean. No additional validation needed since the dropdown constrains input.
929
- return undefined;
930
- }
931
- case "integer":
932
- case "port": {
933
- const numValue = Number(value);
934
- const error = validatePositiveInt(setting.label, numValue, setting.min, setting.max);
935
- return error ?? undefined;
936
- }
937
- case "float": {
938
- const numValue = Number(value);
939
- const error = validatePositiveNumber(setting.label, numValue, setting.min, setting.max);
940
- return error ?? undefined;
941
- }
942
- case "host": {
943
- if ((typeof value !== "string") || (value.trim() === "")) {
944
- return setting.label + " must be a non-empty string";
945
- }
946
- return undefined;
947
- }
948
- case "path": {
949
- // Path can be any string or empty.
950
- return undefined;
951
- }
952
- case "string": {
953
- // String without validValues - no validation needed.
954
- return undefined;
955
- }
956
- default: {
957
- return undefined;
958
- }
959
- }
960
- }
961
- /**
962
- * Parses a form value into the appropriate type for a setting, converting from display units to storage units if necessary.
963
- * @param setting - The setting metadata.
964
- * @param value - The raw string value from the form (in display units).
965
- * @returns The parsed value (in storage units).
966
- */
967
- function parseFormValue(setting, value) {
968
- // Handle empty values for path type.
969
- if ((setting.type === "path") && (value.trim() === "")) {
970
- return null;
971
- }
972
- switch (setting.type) {
973
- case "boolean": {
974
- // Convert string "true" to boolean true, anything else to false.
975
- return value === "true";
976
- }
977
- case "integer":
978
- case "port": {
979
- const displayValue = parseFloat(value);
980
- // Convert from display units to storage units if displayDivisor is set.
981
- if (setting.displayDivisor) {
982
- return Math.round(displayValue * setting.displayDivisor);
983
- }
984
- return parseInt(value, 10);
985
- }
986
- case "float": {
987
- const displayValue = parseFloat(value);
988
- // Convert from display units to storage units if displayDivisor is set.
989
- if (setting.displayDivisor) {
990
- return displayValue * setting.displayDivisor;
991
- }
992
- return displayValue;
993
- }
994
- case "host":
995
- case "path":
996
- case "string": {
997
- return value;
998
- }
999
- default: {
1000
- return value;
1001
- }
1002
- }
1003
- }
1004
- /**
1005
- * Generates the provider filter toolbar HTML with a multi-select dropdown, dismissable chips, and a bulk-assign dropdown.
1006
- * @returns HTML string for the provider filter toolbar.
1007
- */
1008
- export function generateProviderFilterToolbar() {
1009
- const allTags = getAllProviderTags();
1010
- const enabled = getEnabledProviders();
1011
- const hasFilter = enabled.length > 0;
1012
- const lines = [];
1013
- lines.push("<div class=\"provider-toolbar\">");
1014
- // Left group: Provider filter dropdown and chips.
1015
- lines.push("<div class=\"toolbar-group\">");
1016
- lines.push("<span class=\"toolbar-label\">Providers:</span>");
1017
- lines.push("<div class=\"dropdown provider-dropdown\">");
1018
- const buttonText = hasFilter ? "Filtered" : "All Providers";
1019
- lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\" onclick=\"toggleDropdown(this)\">" + buttonText + " &#9662;</button>");
1020
- lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
1021
- for (const tagInfo of allTags) {
1022
- const isDirectTag = tagInfo.tag === "direct";
1023
- const isChecked = isDirectTag || !hasFilter || enabled.includes(tagInfo.tag);
1024
- const checkedAttr = isChecked ? " checked" : "";
1025
- const disabledAttr = isDirectTag ? " disabled" : "";
1026
- lines.push("<label class=\"provider-option\">");
1027
- lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
1028
- " onchange=\"toggleProviderTag(this)\"> " + escapeHtml(tagInfo.displayName));
1029
- lines.push("</label>");
1030
- }
1031
- lines.push("</div>");
1032
- lines.push("</div>");
1033
- // Chips container for active filter tags.
1034
- lines.push("<div class=\"provider-chips\" id=\"provider-chips\">");
1035
- if (hasFilter) {
1036
- for (const tag of enabled) {
1037
- if (tag === "direct") {
1038
- continue;
1039
- }
1040
- const displayName = allTags.find((t) => t.tag === tag)?.displayName ?? tag;
1041
- lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" + escapeHtml(displayName) +
1042
- "<button type=\"button\" class=\"chip-close\" aria-label=\"Remove " + escapeHtml(displayName) + "\" onclick=\"removeProviderChip('" + escapeHtml(tag) +
1043
- "')\">&times;</button></span>");
1044
- }
1045
- }
1046
- lines.push("</div>");
1047
- lines.push("</div>");
1048
- // Spacer.
1049
- lines.push("<div class=\"toolbar-spacer\"></div>");
1050
- // Right group: Bulk assign dropdown.
1051
- lines.push("<div class=\"toolbar-group\">");
1052
- lines.push("<span class=\"toolbar-label\">Set all channels to:</span>");
1053
- lines.push("<select class=\"form-select bulk-assign-select\" id=\"bulk-assign\" onchange=\"bulkAssignProvider(this)\">");
1054
- lines.push("<option value=\"\" disabled selected>Choose provider...</option>");
1055
- // Render all provider options so the client-side _allOptions snapshot captures them for reinsertion when the filter changes. Filtered-out options get the hidden
1056
- // attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls updateBulkAssignOptions() to remove them
1057
- // from the DOM.
1058
- for (const tagInfo of allTags) {
1059
- const optionHidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
1060
- lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\"" + optionHidden + ">" + escapeHtml(tagInfo.displayName) + "</option>");
1061
- }
1062
- lines.push("</select>");
1063
- lines.push("</div>");
1064
- lines.push("</div>");
1065
- return lines.join("\n");
1066
- }
1067
- /**
1068
- * Generates the Channels panel HTML content.
1069
- * @param channelMessage - Optional message to display (success or error).
1070
- * @param channelError - If true, display as error; otherwise as success.
1071
- * @param editingChannelKey - If set, show the edit form for this channel.
1072
- * @param showAddForm - If true, show the add channel form.
1073
- * @param formErrors - Validation errors for the channel form.
1074
- * @param formValues - Form values to re-populate after validation error.
1075
- * @returns HTML string for the Channels panel content.
1076
- */
1077
- export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
1078
- // Get the canonical channel listing (provider variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
1079
- // it handles predefined/user merging, disabled state, and provider availability.
1080
- const listing = getChannelListing();
1081
- const profiles = getProfiles();
1082
- const disabledPredefined = getDisabledPredefinedChannels();
1083
- const predefinedCount = Object.keys(getPredefinedChannels()).length;
1084
- const allDisabled = disabledPredefined.length === predefinedCount;
1085
- // Count channels hidden from the default view: disabled predefined channels OR channels with no available providers.
1086
- const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByProvider).length;
1087
- const lines = [];
1088
- // Panel description.
1089
- lines.push("<div class=\"settings-panel-description\">");
1090
- lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
1091
- lines.push("<p class=\"description-hint\">Tip: Use the <strong>provider filter</strong> above to show only channels from services you subscribe to &mdash; ", "this also controls which channels Channels DVR sees in the playlist. Use the <strong>provider dropdown</strong> on any multi-provider channel to choose ", "which streaming service delivers it (e.g., Comedy Central via Hulu vs Sling). Click the <strong>edit icon</strong> to customize any channel's name, ", "Gracenote station ID, URL, or other properties.</p>");
1092
- lines.push("</div>");
1093
- // Toolbar with channel operations and display controls.
1094
- lines.push("<div class=\"channel-toolbar\">");
1095
- // Left group: channel operations. Import uses a dropdown menu to consolidate M3U and JSON import into a single button.
1096
- lines.push("<div class=\"toolbar-group\">");
1097
- lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm\" id=\"add-channel-btn\" onclick=\"document.getElementById('add-channel-form')", ".style.display='block'; this.style.display='none';\">Add Channel</button>");
1098
- lines.push("<div class=\"dropdown\">");
1099
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" onclick=\"toggleDropdown(this)\">Import &#9662;</button>");
1100
- lines.push("<div class=\"dropdown-menu\">");
1101
- lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); document.getElementById('import-channels-file').click()\">Channels (JSON)</div>");
1102
- lines.push("<div class=\"dropdown-divider\"></div>");
1103
- lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); document.getElementById('import-m3u-file').click()\">M3U Playlist</div>");
1104
- lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates</label>");
1105
- lines.push("</div>");
1106
- lines.push("</div>");
1107
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" onclick=\"exportChannels()\">Export</button>");
1108
- lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
1109
- lines.push("</div>");
1110
- // Spacer.
1111
- lines.push("<div class=\"toolbar-spacer\"></div>");
1112
- // Right group: display controls.
1113
- lines.push("<div class=\"toolbar-group\">");
1114
- if (allDisabled) {
1115
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" id=\"bulk-toggle-btn\" ", "onclick=\"toggleAllPredefined(true)\">Enable All Predefined</button>");
1116
- }
1117
- else {
1118
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" id=\"bulk-toggle-btn\" ", "onclick=\"toggleAllPredefined(false)\">Disable All Predefined</button>");
1119
- }
1120
- lines.push("<label class=\"toggle-label\"><input type=\"checkbox\" id=\"show-disabled-toggle\" onchange=\"toggleDisabledVisibility()\"> ", "Show disabled (<span id=\"disabled-count\">" + String(totalHiddenCount) + "</span>)</label>");
1121
- lines.push("</div>");
1122
- lines.push("</div>");
1123
- // Show channels file parse error if applicable.
1124
- if (hasChannelsParseError()) {
1125
- lines.push("<div class=\"error\">");
1126
- lines.push("<div class=\"error-title\">Channels File Error</div>");
1127
- lines.push("The channels file at <code>" + escapeHtml(getUserChannelsFilePath()) + "</code> contains invalid JSON and could not be loaded. ");
1128
- lines.push("User channels are disabled. Fix the file manually or add a new channel to create a valid file.");
1129
- const parseError = getChannelsParseErrorMessage();
1130
- if (parseError) {
1131
- lines.push("<br><br>Error: <code>" + escapeHtml(parseError) + "</code>");
1132
- }
1133
- lines.push("</div>");
1134
- }
1135
- // Show channel message if present.
1136
- if (channelMessage) {
1137
- const messageClass = channelError ? "error" : "success";
1138
- const titleClass = channelError ? "error-title" : "success-title";
1139
- const title = channelError ? "Error" : "Success";
1140
- lines.push("<div class=\"" + messageClass + "\">");
1141
- lines.push("<div class=\"" + titleClass + "\">" + title + "</div>");
1142
- lines.push(escapeHtml(channelMessage));
1143
- lines.push("</div>");
1144
- }
1145
- // Show validation errors if present.
1146
- if (formErrors && (formErrors.size > 0)) {
1147
- lines.push("<div class=\"error\">");
1148
- lines.push("<div class=\"error-title\">Validation Errors</div>");
1149
- lines.push("Please correct the following errors:");
1150
- lines.push("<ul>");
1151
- for (const [field, error] of formErrors) {
1152
- lines.push("<li><strong>" + escapeHtml(field) + "</strong>: " + escapeHtml(error) + "</li>");
1153
- }
1154
- lines.push("</ul>");
1155
- lines.push("</div>");
1156
- }
1157
- // Add channel form (hidden by default unless showAddForm is true or there are form errors for a new channel).
1158
- const addFormVisible = (showAddForm === true) || (formErrors && formErrors.has("key") && !editingChannelKey);
1159
- lines.push("<div id=\"add-channel-form\" class=\"channel-form\" style=\"display: " + (addFormVisible ? "block" : "none") + ";\">");
1160
- lines.push("<h3>Add New Channel</h3>");
1161
- lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
1162
- lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
1163
- // Channel key (add form only).
1164
- lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
1165
- hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
1166
- pattern: "[a-z0-9-]+",
1167
- placeholder: "e.g., my-channel",
1168
- required: true
1169
- }));
1170
- // Channel name.
1171
- lines.push(...generateTextField("add-name", "name", "Display Name", formValues?.get("name") ?? "", {
1172
- hint: "Friendly name shown in the playlist and UI.",
1173
- placeholder: "e.g., My Channel",
1174
- required: true
1175
- }));
1176
- // Channel URL.
1177
- lines.push(...generateTextField("add-url", "url", "Stream URL", formValues?.get("url") ?? "", {
1178
- hint: "The URL of the streaming page to capture.",
1179
- placeholder: "https://example.com/live",
1180
- required: true,
1181
- type: "url"
1182
- }));
1183
- // Profile dropdown.
1184
- lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
1185
- // Advanced fields (station ID, channel selector, channel number).
1186
- lines.push(...generateAdvancedFields("add", formValues?.get("stationId") ?? "", formValues?.get("channelSelector") ?? "", formValues?.get("channelNumber") ?? ""));
1187
- // Form buttons.
1188
- lines.push("<div class=\"form-buttons\">");
1189
- lines.push("<button type=\"submit\" class=\"btn btn-primary\">Add Channel</button>");
1190
- lines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"document.getElementById('add-channel-form').style.display='none'; ", "document.getElementById('add-channel-btn').style.display='inline-block';\">Cancel</button>");
1191
- lines.push("</div>");
1192
- lines.push("</form>");
1193
- lines.push("</div>"); // End add-channel-form.
1194
- // Provider filter toolbar with multi-select dropdown, chips, and bulk assign. Placed after the add channel form so the form flows directly from its trigger button.
1195
- lines.push(generateProviderFilterToolbar());
1196
- // Profile reference section (hidden by default, toggled via link in profile dropdown hint).
1197
- lines.push(generateProfileReference(profiles));
1198
- // Channels table. Disabled predefined channels are hidden by default and revealed via the "Show disabled" toggle. The wrapper div enables horizontal scrolling on
1199
- // small screens.
1200
- lines.push("<div class=\"channel-table-wrapper\">");
1201
- lines.push("<table class=\"channel-table hide-disabled\">");
1202
- lines.push("<thead>");
1203
- lines.push("<tr>");
1204
- lines.push("<th class=\"col-key\">Key</th>");
1205
- lines.push("<th class=\"col-name\">Name</th>");
1206
- lines.push("<th class=\"col-provider\">Provider</th>");
1207
- lines.push("<th class=\"col-actions\">Actions</th>");
1208
- lines.push("</tr>");
1209
- lines.push("</thead>");
1210
- lines.push("<tbody>");
1211
- // Generate rows for all channels using the shared row generator.
1212
- for (const entry of listing) {
1213
- const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
1214
- lines.push(rowHtml.displayRow);
1215
- lines.push(rowHtml.editRow);
1216
- }
1217
- lines.push("</tbody>");
1218
- lines.push("</table>");
1219
- lines.push("</div>");
1220
- // Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
1221
- // multi-channel site like Disney+ or USA Network.
1222
- lines.push("<script>" + generateChannelSelectorData() + "</script>");
1223
- return lines.join("\n");
1224
- }
1225
- /**
1226
- * Generates the content for the Settings tab with non-collapsible section headers.
1227
- * @param validationErrors - Map of setting path to validation error message.
1228
- * @param formValues - Map of setting path to submitted form value.
1229
- * @returns HTML string for the Settings tab content.
1230
- */
1231
- export function generateSettingsTabContent(validationErrors, formValues) {
1232
- const sections = getSettingsTabSections();
1233
- const tabs = getUITabs();
1234
- const settingsTab = tabs.find((t) => t.id === "settings");
1235
- const defaults = getDefaults();
1236
- const envOverrides = getEnvOverrides();
1237
- const lines = [];
1238
- // Panel header with description and reset button.
1239
- lines.push("<div class=\"panel-header\">");
1240
- lines.push("<p class=\"settings-panel-description\">" + escapeHtml(settingsTab?.description ?? "Configure common options.") + "</p>");
1241
- lines.push("<a href=\"#\" class=\"panel-reset\" onclick=\"resetTabToDefaults('settings'); return false;\">Reset to Defaults</a>");
1242
- lines.push("</div>");
1243
- // Generate each section with a header.
1244
- for (const section of sections) {
1245
- lines.push("<div class=\"settings-section\">");
1246
- lines.push("<div class=\"settings-section-header\">" + escapeHtml(section.displayName) + "</div>");
1247
- // Generate setting fields for this section.
1248
- for (const setting of section.settings) {
1249
- const currentValue = formValues?.get(setting.path) ?? getNestedValue(CONFIG, setting.path);
1250
- const defaultValue = getNestedValue(defaults, setting.path);
1251
- const envOverride = envOverrides.get(setting.path);
1252
- const validationError = validationErrors?.get(setting.path);
1253
- lines.push(generateSettingField(setting, currentValue, defaultValue, envOverride, validationError));
1254
- }
1255
- lines.push("</div>");
1256
- }
1257
- return lines.join("\n");
1258
- }
1259
- /**
1260
- * Generates the content for a collapsible section within the Advanced tab.
1261
- * @param section - The section definition.
1262
- * @param validationErrors - Map of setting path to validation error message.
1263
- * @param formValues - Map of setting path to submitted form value.
1264
- * @returns HTML string for the section.
1265
- */
1266
- export function generateCollapsibleSection(section, validationErrors, formValues) {
1267
- const defaults = getDefaults();
1268
- const envOverrides = getEnvOverrides();
1269
- const lines = [];
1270
- const settingCount = section.settings.length;
1271
- // Section container.
1272
- lines.push("<div class=\"advanced-section\" data-section=\"" + escapeHtml(section.id) + "\">");
1273
- // Section header with chevron, title, and count.
1274
- lines.push("<div class=\"section-header\" onclick=\"toggleSection('" + escapeHtml(section.id) + "')\">");
1275
- lines.push("<span class=\"section-chevron\">&#9654;</span>");
1276
- lines.push("<span class=\"section-title\">" + escapeHtml(section.displayName) + "</span>");
1277
- lines.push("<span class=\"section-count\">(" + String(settingCount) + " setting" + (settingCount === 1 ? "" : "s") + ")</span>");
1278
- lines.push("</div>");
1279
- // Section content (collapsed by default).
1280
- lines.push("<div class=\"section-content\">");
1281
- // Generate setting fields for this section.
1282
- for (const setting of section.settings) {
1283
- const currentValue = formValues?.get(setting.path) ?? getNestedValue(CONFIG, setting.path);
1284
- const defaultValue = getNestedValue(defaults, setting.path);
1285
- const envOverride = envOverrides.get(setting.path);
1286
- const validationError = validationErrors?.get(setting.path);
1287
- lines.push(generateSettingField(setting, currentValue, defaultValue, envOverride, validationError));
1288
- }
1289
- lines.push("</div>"); // End section-content.
1290
- lines.push("</div>"); // End advanced-section.
1291
- return lines.join("\n");
1292
- }
1293
- /**
1294
- * Generates the content for the Advanced tab with collapsible sections.
1295
- * @param validationErrors - Map of setting path to validation error message.
1296
- * @param formValues - Map of setting path to submitted form value.
1297
- * @returns HTML string for the Advanced tab content.
1298
- */
1299
- export function generateAdvancedTabContent(validationErrors, formValues) {
1300
- const sections = getAdvancedSections();
1301
- const tabs = getUITabs();
1302
- const advancedTab = tabs.find((t) => t.id === "advanced");
1303
- const lines = [];
1304
- // Panel header with description and reset button.
1305
- lines.push("<div class=\"panel-header\">");
1306
- lines.push("<p class=\"settings-panel-description\">" + escapeHtml(advancedTab?.description ?? "Expert tuning options.") + "</p>");
1307
- lines.push("<a href=\"#\" class=\"panel-reset\" onclick=\"resetTabToDefaults('advanced'); return false;\">Reset All to Defaults</a>");
1308
- lines.push("</div>");
1309
- // Generate each collapsible section.
1310
- for (const section of sections) {
1311
- lines.push(generateCollapsibleSection(section, validationErrors, formValues));
1312
- }
1313
- return lines.join("\n");
1314
- }
1315
- /**
1316
- * Generates the config path display for settings.
1317
- * @returns HTML string with config path.
1318
- */
1319
- export function generateSettingsFormFooter() {
1320
- return "<div class=\"config-path\">Configuration file: <code>" + escapeHtml(getConfigFilePath()) + "</code></div>";
1321
- }
1322
- /**
1323
- * Checks if there are any environment variable overrides for configuration settings.
1324
- * @returns True if any settings are overridden by environment variables.
1325
- */
1326
- export function hasEnvOverrides() {
1327
- return getEnvOverrides().size > 0;
1328
- }
1329
- /**
1330
- * Configures the configuration endpoints. The GET /config endpoint has been removed - configuration is now accessed via hash navigation on the main page
1331
- * (e.g., /#config/server). Channels are accessed via /#channels. POST endpoints remain for form submission handling.
1332
- * @param app - The Express application.
1333
- */
1334
- export function setupConfigEndpoint(app) {
1335
- // POST /config - Save configuration and restart. Returns JSON response.
1336
- app.post("/config", async (req, res) => {
1337
- try {
1338
- const envOverrides = getEnvOverrides();
1339
- const validationErrors = {};
1340
- const newConfig = {};
1341
- // Process each setting from the nested JSON structure.
1342
- for (const settings of Object.values(CONFIG_METADATA)) {
1343
- for (const setting of settings) {
1344
- // Skip settings overridden by environment variables.
1345
- if (envOverrides.has(setting.path)) {
1346
- continue;
1347
- }
1348
- // Get the value from the nested JSON body using the setting path.
1349
- const rawValue = getNestedValue(req.body, setting.path);
1350
- // Skip undefined values (not submitted).
1351
- if (rawValue === undefined) {
1352
- continue;
1353
- }
1354
- // Parse the value (convert from display units to storage units if needed).
1355
- const parsedValue = parseFormValue(setting, String(rawValue));
1356
- // Validate the value.
1357
- const validationError = validateSettingValue(setting, parsedValue);
1358
- if (validationError) {
1359
- validationErrors[setting.path] = validationError;
1360
- continue;
1361
- }
1362
- // Add to new config.
1363
- setNestedValue(newConfig, setting.path, parsedValue);
1364
- }
1365
- }
1366
- // If there are validation errors, return them as JSON.
1367
- if (Object.keys(validationErrors).length > 0) {
1368
- res.status(400).json({ errors: validationErrors, success: false });
1369
- return;
1370
- }
1371
- // The settings form only submits CONFIG_METADATA scalar values. The config file also stores complex fields managed by their own endpoints: disabled channel
1372
- // list, enabled provider filter, and the auto-generated HDHomeRun device ID. We must preserve these from the existing file, otherwise saving settings wipes them.
1373
- const existingResult = await loadUserConfig();
1374
- const existingConfig = existingResult.config;
1375
- if (Array.isArray(existingConfig.channels?.disabledPredefined) && (existingConfig.channels.disabledPredefined.length > 0)) {
1376
- newConfig.channels ??= {};
1377
- newConfig.channels.disabledPredefined = existingConfig.channels.disabledPredefined;
1378
- }
1379
- if (Array.isArray(existingConfig.channels?.enabledProviders) && (existingConfig.channels.enabledProviders.length > 0)) {
1380
- newConfig.channels ??= {};
1381
- newConfig.channels.enabledProviders = existingConfig.channels.enabledProviders;
1382
- }
1383
- if ((typeof existingConfig.hdhr?.deviceId === "string") && (existingConfig.hdhr.deviceId.length > 0)) {
1384
- newConfig.hdhr ??= {};
1385
- newConfig.hdhr.deviceId = existingConfig.hdhr.deviceId;
1386
- }
1387
- if ((typeof existingConfig.logging?.debugFilter === "string") && (existingConfig.logging.debugFilter.length > 0)) {
1388
- newConfig.logging ??= {};
1389
- newConfig.logging.debugFilter = existingConfig.logging.debugFilter;
1390
- }
1391
- // Filter out values that match defaults to keep the config file clean.
1392
- const filteredConfig = filterDefaults(newConfig);
1393
- // Save the configuration.
1394
- await saveUserConfig(filteredConfig);
1395
- // Schedule restart after response is sent and return success response with restart info.
1396
- const restartResult = scheduleServerRestart("to apply configuration changes");
1397
- res.json({
1398
- activeStreams: restartResult.activeStreams,
1399
- deferred: restartResult.deferred,
1400
- message: restartResult.message,
1401
- success: true,
1402
- willRestart: restartResult.willRestart
1403
- });
1404
- }
1405
- catch (error) {
1406
- LOG.error("Failed to save configuration: %s", formatError(error));
1407
- res.status(500).json({ message: "Failed to save configuration: " + formatError(error), success: false });
1408
- }
1409
- });
1410
- // GET /config/export - Export current configuration as JSON.
1411
- app.get("/config/export", async (_req, res) => {
1412
- try {
1413
- const result = await loadUserConfig();
1414
- res.setHeader("Content-Type", "application/json");
1415
- res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-config.json\"");
1416
- res.send(JSON.stringify(result.config, null, 2) + "\n");
1417
- }
1418
- catch (error) {
1419
- LOG.error("Failed to export configuration: %s", formatError(error));
1420
- res.status(500).json({ error: "Failed to export configuration: " + formatError(error) });
1421
- }
1422
- });
1423
- // POST /config/import - Import configuration from JSON.
1424
- app.post("/config/import", async (req, res) => {
1425
- try {
1426
- // Cast to unknown first for runtime validation, then to UserConfig after validation.
1427
- const rawConfig = req.body;
1428
- // Basic validation - ensure it's an object.
1429
- if ((typeof rawConfig !== "object") || (rawConfig === null) || Array.isArray(rawConfig)) {
1430
- res.status(400).json({ error: "Invalid configuration format: expected an object." });
1431
- return;
1432
- }
1433
- const importedConfig = rawConfig;
1434
- // Validate each setting in the imported config.
1435
- const validationErrors = [];
1436
- for (const [category, settings] of Object.entries(CONFIG_METADATA)) {
1437
- const categoryConfig = importedConfig[category];
1438
- if (categoryConfig === undefined) {
1439
- continue;
1440
- }
1441
- if ((typeof categoryConfig !== "object") || (categoryConfig === null)) {
1442
- validationErrors.push("Invalid " + category + " configuration: expected an object.");
1443
- continue;
1444
- }
1445
- for (const setting of settings) {
1446
- const pathParts = setting.path.split(".");
1447
- let value = importedConfig;
1448
- for (const part of pathParts) {
1449
- if ((value === null) || (value === undefined) || (typeof value !== "object")) {
1450
- value = undefined;
1451
- break;
1452
- }
1453
- value = value[part];
1454
- }
1455
- if (value === undefined) {
1456
- continue;
1457
- }
1458
- // Validate the value.
1459
- const error = validateSettingValue(setting, value);
1460
- if (error) {
1461
- validationErrors.push(setting.label + ": " + error);
1462
- }
1463
- }
1464
- }
1465
- if (validationErrors.length > 0) {
1466
- res.status(400).json({ error: "Validation errors:\n" + validationErrors.join("\n") });
1467
- return;
1468
- }
1469
- // Filter out values that match defaults to keep the config file clean.
1470
- const filteredConfig = filterDefaults(importedConfig);
1471
- // Save the imported configuration.
1472
- await saveUserConfig(filteredConfig);
1473
- // Schedule restart after response is sent and return success response with restart info.
1474
- const restartResult = scheduleServerRestart("after configuration import");
1475
- res.json({
1476
- activeStreams: restartResult.activeStreams,
1477
- deferred: restartResult.deferred,
1478
- message: restartResult.message,
1479
- success: true,
1480
- willRestart: restartResult.willRestart
1481
- });
1482
- }
1483
- catch (error) {
1484
- LOG.error("Failed to import configuration: %s", formatError(error));
1485
- res.status(500).json({ error: "Failed to import configuration: " + formatError(error) });
1486
- }
1487
- });
1488
- // POST /config/restart-now - Force immediate server restart regardless of active streams.
1489
- app.post("/config/restart-now", (_req, res) => {
1490
- if (!isRunningAsService()) {
1491
- res.status(400).json({ message: "Cannot restart: not running as a service.", success: false });
1492
- return;
1493
- }
1494
- LOG.info("Forced restart requested via API.");
1495
- res.json({ message: "Server is restarting...", success: true });
1496
- // Close the browser first to avoid orphan Chrome processes.
1497
- setTimeout(() => {
1498
- LOG.info("Exiting for forced service manager restart.");
1499
- void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
1500
- }, 500);
1501
- });
1502
- // GET /config/channels/export - Export user channels as JSON. Deltas are resolved to full definitions for backward compatibility with the import validator
1503
- // which requires url as a required field.
1504
- app.get("/config/channels/export", (_req, res) => {
1505
- try {
1506
- const storedChannels = getUserChannels();
1507
- const resolved = {};
1508
- for (const [key, stored] of Object.entries(storedChannels)) {
1509
- resolved[key] = resolveStoredChannel(key, stored);
1510
- }
1511
- res.setHeader("Content-Type", "application/json");
1512
- res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
1513
- res.send(JSON.stringify(resolved, null, 2) + "\n");
1514
- }
1515
- catch (error) {
1516
- LOG.error("Failed to export channels: %s", formatError(error));
1517
- res.status(500).json({ error: "Failed to export channels: " + formatError(error) });
1518
- }
1519
- });
1520
- // POST /config/channels/import - Import channels from JSON, replacing all existing user channels.
1521
- app.post("/config/channels/import", async (req, res) => {
1522
- try {
1523
- const rawData = req.body;
1524
- // Validate the imported channels.
1525
- const validProfiles = getProfiles().map((p) => p.name);
1526
- const validationResult = validateImportedChannels(rawData, validProfiles);
1527
- if (!validationResult.valid) {
1528
- res.status(400).json({ error: "Validation errors:\n" + validationResult.errors.join("\n") });
1529
- return;
1530
- }
1531
- // Save the imported channels, replacing all existing user channels.
1532
- await saveUserChannels(validationResult.channels);
1533
- const channelCount = Object.keys(validationResult.channels).length;
1534
- // Send success response. Changes take effect immediately due to hot-reloading in saveUserChannels().
1535
- res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
1536
- }
1537
- catch (error) {
1538
- LOG.error("Failed to import channels: %s", formatError(error));
1539
- res.status(500).json({ error: "Failed to import channels: " + formatError(error) });
1540
- }
1541
- });
1542
- // POST /config/channels/import-m3u - Import channels from M3U playlist file.
1543
- app.post("/config/channels/import-m3u", async (req, res) => {
1544
- try {
1545
- const body = req.body;
1546
- const content = body.content;
1547
- const conflictMode = body.conflictMode ?? "skip";
1548
- // Validate content is provided.
1549
- if (!content || (typeof content !== "string") || (content.trim() === "")) {
1550
- res.status(400).json({ error: "No M3U content provided.", success: false });
1551
- return;
1552
- }
1553
- // Validate conflict mode.
1554
- if ((conflictMode !== "skip") && (conflictMode !== "replace")) {
1555
- res.status(400).json({ error: "Invalid conflict mode. Must be 'skip' or 'replace'.", success: false });
1556
- return;
1557
- }
1558
- // Parse the M3U content.
1559
- const parseResult = parseM3U(content);
1560
- // Check for empty result.
1561
- if (parseResult.channels.length === 0) {
1562
- res.status(400).json({
1563
- error: "No channels found in M3U file." + (parseResult.errors.length > 0 ? " Parse errors: " + parseResult.errors.join("; ") : ""),
1564
- success: false
1565
- });
1566
- return;
1567
- }
1568
- // Load existing user channels.
1569
- const loadResult = await loadUserChannels();
1570
- const existingChannels = loadResult.parseError ? {} : loadResult.channels;
1571
- // Track import statistics.
1572
- const conflicts = [];
1573
- const importErrors = [];
1574
- const seenKeys = new Set();
1575
- let imported = 0;
1576
- let replaced = 0;
1577
- let skipped = 0;
1578
- // Process each parsed channel.
1579
- for (const m3uChannel of parseResult.channels) {
1580
- // Generate the channel key from the name.
1581
- const key = generateChannelKey(m3uChannel.name);
1582
- // Validate the generated key.
1583
- if (!key || (key.length === 0)) {
1584
- importErrors.push("Could not generate key for channel '" + m3uChannel.name + "'.");
1585
- continue;
1586
- }
1587
- // Skip duplicate keys within the same M3U file (first occurrence wins).
1588
- if (seenKeys.has(key)) {
1589
- continue;
1590
- }
1591
- seenKeys.add(key);
1592
- // Validate the URL.
1593
- const urlError = validateChannelUrl(m3uChannel.url);
1594
- if (urlError) {
1595
- importErrors.push("Channel '" + m3uChannel.name + "': " + urlError);
1596
- continue;
1597
- }
1598
- // Validate the name.
1599
- const nameError = validateChannelName(m3uChannel.name);
1600
- if (nameError) {
1601
- importErrors.push("Channel '" + m3uChannel.name + "': " + nameError);
1602
- continue;
1603
- }
1604
- // Check for conflicts with existing channels.
1605
- if (key in existingChannels) {
1606
- conflicts.push(key);
1607
- if (conflictMode === "skip") {
1608
- skipped++;
1609
- continue;
1610
- }
1611
- // Replace mode - count as replaced instead of imported.
1612
- replaced++;
1613
- }
1614
- else {
1615
- imported++;
1616
- }
1617
- // Build the channel object.
1618
- const channel = {
1619
- name: m3uChannel.name,
1620
- url: m3uChannel.url
1621
- };
1622
- // Add station ID if present.
1623
- if (m3uChannel.stationId) {
1624
- channel.stationId = m3uChannel.stationId;
1625
- }
1626
- // Add to channels collection.
1627
- existingChannels[key] = channel;
1628
- }
1629
- // Save the updated channels.
1630
- await saveUserChannels(existingChannels);
1631
- // Log the import.
1632
- LOG.info("M3U import completed: %d imported, %d replaced, %d skipped.", imported, replaced, skipped);
1633
- // Build response.
1634
- res.json({
1635
- conflicts,
1636
- errors: [...parseResult.errors, ...importErrors],
1637
- imported,
1638
- replaced,
1639
- skipped,
1640
- success: true
1641
- });
1642
- }
1643
- catch (error) {
1644
- LOG.error("Failed to import M3U channels: %s", formatError(error));
1645
- res.status(500).json({ error: "Failed to import channels: " + formatError(error), success: false });
1646
- }
1647
- });
1648
- // POST /config/channels/toggle-predefined - Toggle a single predefined channel's enabled/disabled state.
1649
- app.post("/config/channels/toggle-predefined", async (req, res) => {
1650
- try {
1651
- const body = req.body;
1652
- const key = body.key?.trim();
1653
- const enabled = body.enabled;
1654
- // Validate key is provided.
1655
- if (!key) {
1656
- res.status(400).json({ error: "Channel key is required.", success: false });
1657
- return;
1658
- }
1659
- // Validate enabled is provided.
1660
- if (typeof enabled !== "boolean") {
1661
- res.status(400).json({ error: "Enabled state (true/false) is required.", success: false });
1662
- return;
1663
- }
1664
- // Validate the channel exists as a predefined channel.
1665
- if (!isPredefinedChannel(key)) {
1666
- res.status(400).json({ error: "Channel '" + key + "' is not a predefined channel.", success: false });
1667
- return;
1668
- }
1669
- // Load current config.
1670
- const configResult = await loadUserConfig();
1671
- const userConfig = configResult.config;
1672
- // Initialize channels.disabledPredefined if not present.
1673
- userConfig.channels ??= {};
1674
- userConfig.channels.disabledPredefined ??= [];
1675
- const disabledSet = new Set(userConfig.channels.disabledPredefined);
1676
- if (enabled) {
1677
- // Enable: remove from disabled list.
1678
- disabledSet.delete(key);
1679
- }
1680
- else {
1681
- // Disable: add to disabled list.
1682
- disabledSet.add(key);
1683
- }
1684
- // Update and save config.
1685
- userConfig.channels.disabledPredefined = [...disabledSet].sort();
1686
- await saveUserConfig(userConfig);
1687
- // Update the runtime CONFIG to reflect the change immediately.
1688
- CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
1689
- LOG.info("Predefined channel '%s' %s.", key, enabled ? "enabled" : "disabled");
1690
- res.json({ enabled, key, success: true });
1691
- }
1692
- catch (error) {
1693
- LOG.error("Failed to toggle predefined channel: %s", formatError(error));
1694
- res.status(500).json({ error: "Failed to toggle channel: " + formatError(error), success: false });
1695
- }
1696
- });
1697
- // POST /config/provider - Update provider selection for a multi-provider channel.
1698
- app.post("/config/provider", async (req, res) => {
1699
- try {
1700
- const body = req.body;
1701
- const channelKey = body.channel?.trim();
1702
- const providerKey = body.provider?.trim();
1703
- // Validate channel key is provided.
1704
- if (!channelKey) {
1705
- res.status(400).json({ error: "Channel key is required.", success: false });
1706
- return;
1707
- }
1708
- // Validate provider key is provided.
1709
- if (!providerKey) {
1710
- res.status(400).json({ error: "Provider key is required.", success: false });
1711
- return;
1712
- }
1713
- // Canonicalize the channel key to ensure selections are stored under the canonical key, not variant keys.
1714
- const canonicalKey = getCanonicalKey(channelKey);
1715
- // Validate the channel has provider options.
1716
- const providerGroup = getProviderGroup(canonicalKey);
1717
- if (!providerGroup) {
1718
- res.status(400).json({ error: "Channel '" + canonicalKey + "' does not have multiple providers.", success: false });
1719
- return;
1720
- }
1721
- // Validate the provider key is valid for this channel.
1722
- const validProviderKeys = providerGroup.variants.map((v) => v.key);
1723
- if (!validProviderKeys.includes(providerKey)) {
1724
- res.status(400).json({ error: "Invalid provider '" + providerKey + "' for channel '" + canonicalKey + "'.", success: false });
1725
- return;
1726
- }
1727
- // Update the provider selection.
1728
- setProviderSelection(canonicalKey, providerKey);
1729
- // Save to disk.
1730
- await saveProviderSelections();
1731
- LOG.info("Provider selection for '%s' changed to '%s'.", canonicalKey, providerKey);
1732
- // Return full row HTML so the client can replace both the display and edit rows, keeping the edit form in sync with the selected provider.
1733
- const profiles = getProfiles();
1734
- const rowHtml = generateChannelRowHtml(canonicalKey, profiles);
1735
- res.json({ channel: canonicalKey, html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, provider: providerKey, success: true });
1736
- }
1737
- catch (error) {
1738
- LOG.error("Failed to update provider selection: %s", formatError(error));
1739
- res.status(500).json({ error: "Failed to update provider: " + formatError(error), success: false });
1740
- }
1741
- });
1742
- // POST /config/channels/toggle-all-predefined - Toggle all predefined channels' enabled/disabled state.
1743
- app.post("/config/channels/toggle-all-predefined", async (req, res) => {
1744
- try {
1745
- const body = req.body;
1746
- const enabled = body.enabled;
1747
- // Validate enabled is provided.
1748
- if (typeof enabled !== "boolean") {
1749
- res.status(400).json({ error: "Enabled state (true/false) is required.", success: false });
1750
- return;
1751
- }
1752
- // Load current config.
1753
- const configResult = await loadUserConfig();
1754
- const userConfig = configResult.config;
1755
- // Initialize channels.disabledPredefined if not present.
1756
- userConfig.channels ??= {};
1757
- const predefinedKeys = Object.keys(getPredefinedChannels());
1758
- let affected;
1759
- if (enabled) {
1760
- // Enable all: clear the disabled list.
1761
- affected = userConfig.channels.disabledPredefined?.length ?? 0;
1762
- userConfig.channels.disabledPredefined = [];
1763
- }
1764
- else {
1765
- // Disable all: add all predefined channel keys.
1766
- const previousCount = userConfig.channels.disabledPredefined?.length ?? 0;
1767
- userConfig.channels.disabledPredefined = predefinedKeys.sort();
1768
- affected = predefinedKeys.length - previousCount;
1769
- }
1770
- await saveUserConfig(userConfig);
1771
- // Update the runtime CONFIG to reflect the change immediately.
1772
- CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
1773
- LOG.info("All predefined channels %s (%d affected).", enabled ? "enabled" : "disabled", affected);
1774
- res.json({ affected, enabled, success: true });
1775
- }
1776
- catch (error) {
1777
- LOG.error("Failed to toggle all predefined channels: %s", formatError(error));
1778
- res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
1779
- }
1780
- });
1781
- // POST /config/provider-filter - Update the provider filter (enabled provider tags).
1782
- app.post("/config/provider-filter", async (req, res) => {
1783
- try {
1784
- const body = req.body;
1785
- const tags = body.enabledProviders;
1786
- // Validate tags is an array.
1787
- if (!Array.isArray(tags)) {
1788
- res.status(400).json({ error: "enabledProviders must be an array.", success: false });
1789
- return;
1790
- }
1791
- // Validate all tags are known.
1792
- const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
1793
- for (const tag of tags) {
1794
- if (!knownTags.has(tag)) {
1795
- res.status(400).json({ error: "Unknown provider tag: " + tag, success: false });
1796
- return;
1797
- }
1798
- }
1799
- // Update module-level state.
1800
- setEnabledProviders(tags);
1801
- // Update runtime CONFIG.
1802
- CONFIG.channels.enabledProviders = [...tags];
1803
- // Save to config file.
1804
- const configResult = await loadUserConfig();
1805
- const userConfig = configResult.config;
1806
- userConfig.channels ??= {};
1807
- userConfig.channels.enabledProviders = tags;
1808
- await saveUserConfig(filterDefaults(userConfig));
1809
- LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
1810
- res.json({ enabledProviders: tags, success: true });
1811
- }
1812
- catch (error) {
1813
- LOG.error("Failed to update provider filter: %s", formatError(error));
1814
- res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
1815
- }
1816
- });
1817
- // POST /config/provider-bulk-assign - Set all channels to a specific provider.
1818
- app.post("/config/provider-bulk-assign", async (req, res) => {
1819
- try {
1820
- const body = req.body;
1821
- const providerTag = body.provider?.trim();
1822
- // Validate provider tag.
1823
- if (!providerTag) {
1824
- res.status(400).json({ error: "Provider tag is required.", success: false });
1825
- return;
1826
- }
1827
- let affected = 0;
1828
- const previousSelections = {};
1829
- const selections = {};
1830
- // Iterate all channels and set those with a matching variant.
1831
- const listing = getChannelListing();
1832
- for (const entry of listing) {
1833
- const group = getProviderGroup(entry.key);
1834
- if (!group || (group.variants.length <= 1)) {
1835
- continue;
1836
- }
1837
- // Find a variant matching the requested provider tag.
1838
- const matchingVariant = group.variants.find((v) => (getProviderTagForChannel(v.key) === providerTag));
1839
- if (matchingVariant) {
1840
- // Snapshot the current selection before overwriting so the client can offer undo.
1841
- const currentVariant = getProviderSelection(entry.key);
1842
- previousSelections[entry.key] = currentVariant ?? null;
1843
- setProviderSelection(entry.key, matchingVariant.key);
1844
- affected++;
1845
- // Collect the resolved profile name for client-side UI update.
1846
- const resolvedChannel = getResolvedChannel(matchingVariant.key);
1847
- selections[entry.key] = { profile: resolvedChannel?.profile ?? null, variant: matchingVariant.key };
1848
- }
1849
- }
1850
- // Save to disk.
1851
- await saveProviderSelections();
1852
- LOG.info("Bulk assign to '%s': %d of %d channels affected.", providerTag, affected, listing.length);
1853
- res.json({ affected, previousSelections, selections, success: true, total: listing.length });
1854
- }
1855
- catch (error) {
1856
- LOG.error("Failed to bulk assign provider: %s", formatError(error));
1857
- res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
1858
- }
1859
- });
1860
- // POST /config/provider-bulk-restore - Restore previous provider selections (undo bulk assign).
1861
- app.post("/config/provider-bulk-restore", async (req, res) => {
1862
- try {
1863
- const body = req.body;
1864
- const previousSelections = body.selections;
1865
- if (!previousSelections || (typeof previousSelections !== "object")) {
1866
- res.status(400).json({ error: "Selections map is required.", success: false });
1867
- return;
1868
- }
1869
- let restored = 0;
1870
- const selections = {};
1871
- for (const [key, variantKey] of Object.entries(previousSelections)) {
1872
- const group = getProviderGroup(key);
1873
- if (!group) {
1874
- continue;
1875
- }
1876
- // A null value means the channel was using the default (canonical) selection. Restoring by setting the selection to the canonical key clears the override.
1877
- if (variantKey === null) {
1878
- setProviderSelection(key, key);
1879
- }
1880
- else {
1881
- // Validate the variant belongs to this channel's provider group before restoring.
1882
- const isValid = group.variants.some((v) => (v.key === variantKey));
1883
- if (!isValid) {
1884
- continue;
1885
- }
1886
- setProviderSelection(key, variantKey);
1887
- }
1888
- restored++;
1889
- // Build the same selection response format as bulk assign for client-side UI updates.
1890
- const effectiveKey = variantKey ?? key;
1891
- const resolvedChannel = getResolvedChannel(effectiveKey);
1892
- selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
1893
- }
1894
- // Save to disk.
1895
- await saveProviderSelections();
1896
- LOG.info("Bulk restore: %d channel(s) reverted.", restored);
1897
- res.json({ restored, selections, success: true });
1898
- }
1899
- catch (error) {
1900
- LOG.error("Failed to bulk restore providers: %s", formatError(error));
1901
- res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
1902
- }
1903
- });
1904
- // POST /config/channels - Handle channel add, edit, delete, and revert operations. Returns JSON response.
1905
- app.post("/config/channels", async (req, res) => {
1906
- try {
1907
- const body = req.body;
1908
- const action = body.action;
1909
- const key = body.key?.trim();
1910
- const profiles = getProfiles();
1911
- // Handle revert action — remove override of a predefined channel, restoring it to defaults.
1912
- if (action === "revert") {
1913
- if (!key) {
1914
- res.status(400).json({ message: "Channel key is required for revert.", success: false });
1915
- return;
1916
- }
1917
- if (!isPredefinedChannel(key)) {
1918
- res.status(400).json({ message: "Cannot revert '" + key + "': it is not a predefined channel.", success: false });
1919
- return;
1920
- }
1921
- if (!isUserChannel(key)) {
1922
- res.status(400).json({ message: "Cannot revert '" + key + "': no override exists.", success: false });
1923
- return;
1924
- }
1925
- // Remove the override.
1926
- const result = await loadUserChannels();
1927
- if (result.parseError) {
1928
- res.status(400).json({ message: "Cannot revert channel: channels file contains invalid JSON.", success: false });
1929
- return;
1930
- }
1931
- // Check if the override contains M3U-relevant fields before removing it.
1932
- const revertHint = playlistHintForStored(result.channels[key]);
1933
- Reflect.deleteProperty(result.channels, key);
1934
- await saveUserChannels(result.channels);
1935
- LOG.info("Channel '%s' reverted to predefined defaults.", key);
1936
- // Generate the predefined row HTML so the client can replace the override row.
1937
- const rowHtml = generateChannelRowHtml(key, profiles);
1938
- res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
1939
- message: "Channel '" + key + "' reverted to defaults." + revertHint, success: true });
1940
- return;
1941
- }
1942
- // Handle delete action.
1943
- if (action === "delete") {
1944
- if (!key) {
1945
- res.status(400).json({ message: "Channel key is required for delete.", success: false });
1946
- return;
1947
- }
1948
- if (!isUserChannel(key)) {
1949
- res.status(400).json({ message: "Cannot delete '" + key + "': it is not a user-defined channel.", success: false });
1950
- return;
1951
- }
1952
- // Delete the channel.
1953
- const result = await loadUserChannels();
1954
- if (result.parseError) {
1955
- res.status(400).json({ message: "Cannot delete channel: channels file contains invalid JSON.", success: false });
1956
- return;
1957
- }
1958
- Reflect.deleteProperty(result.channels, key);
1959
- await saveUserChannels(result.channels);
1960
- LOG.info("User channel '%s' deleted.", key);
1961
- // If a predefined channel exists with the same key, generate its HTML so the client can replace the user channel row with the predefined version instead of
1962
- // just removing it. Without this, deleting a user override of a predefined channel would leave the predefined channel invisible until a page refresh.
1963
- const predefined = isPredefinedChannel(key) ? generateChannelRowHtml(key, profiles) : undefined;
1964
- // Return success response with key for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
1965
- res.json({ html: predefined, key,
1966
- message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT, success: true });
1967
- return;
1968
- }
1969
- // Handle add and edit actions.
1970
- if ((action !== "add") && (action !== "edit")) {
1971
- res.status(400).json({ message: "Invalid channel action.", success: false });
1972
- return;
1973
- }
1974
- // Key is required for both add and edit actions.
1975
- if (!key) {
1976
- res.status(400).json({ message: "Channel key is required.", success: false });
1977
- return;
1978
- }
1979
- // Validate channel fields.
1980
- const formErrors = {};
1981
- // Collect form values.
1982
- const name = body.name?.trim() ?? "";
1983
- const url = body.url?.trim() ?? "";
1984
- const profile = body.profile?.trim() ?? "";
1985
- const stationId = body.stationId?.trim() ?? "";
1986
- const channelSelector = body.channelSelector?.trim() ?? "";
1987
- const channelNumberStr = body.channelNumber?.trim() ?? "";
1988
- // Validate channel number if provided.
1989
- if (channelNumberStr) {
1990
- const num = parseInt(channelNumberStr, 10);
1991
- if (Number.isNaN(num) || (num < 1) || (num > 99999)) {
1992
- formErrors.channelNumber = "Channel number must be between 1 and 99999.";
1993
- }
1994
- else {
1995
- // Check for duplicate channel numbers across all resolved channels.
1996
- for (const entry of getChannelListing()) {
1997
- if ((entry.channel.channelNumber === num) && (entry.key !== key)) {
1998
- formErrors.channelNumber = "Channel number " + String(num) + " is already used by '" + entry.key + "'.";
1999
- break;
2000
- }
2001
- }
2002
- }
2003
- }
2004
- // Validate key (only for add action, not edit).
2005
- if (action === "add") {
2006
- const keyError = validateChannelKey(key, true);
2007
- if (keyError) {
2008
- formErrors.key = keyError;
2009
- }
2010
- }
2011
- // Validate name.
2012
- const nameError = validateChannelName(name);
2013
- if (nameError) {
2014
- formErrors.name = nameError;
2015
- }
2016
- // Validate URL.
2017
- const urlError = validateChannelUrl(url);
2018
- if (urlError) {
2019
- formErrors.url = urlError;
2020
- }
2021
- // Validate profile (if specified).
2022
- const profileError = validateChannelProfile(profile, profiles.map((p) => p.name));
2023
- if (profileError) {
2024
- formErrors.profile = profileError;
2025
- }
2026
- // If validation errors, return them as JSON.
2027
- if (Object.keys(formErrors).length > 0) {
2028
- res.status(400).json({ errors: formErrors, success: false });
2029
- return;
2030
- }
2031
- // Load existing user channels.
2032
- const result = await loadUserChannels();
2033
- if (result.parseError) {
2034
- // If channels file is corrupt, start fresh on add (which will create a valid file).
2035
- if (action === "add") {
2036
- result.channels = {};
2037
- }
2038
- else {
2039
- res.status(400).json({ message: "Cannot edit channel: channels file contains invalid JSON.", success: false });
2040
- return;
2041
- }
2042
- }
2043
- let playlistChanged = false;
2044
- // For predefined channels being edited, compute a delta of only the changed fields. For user-defined channels and adds, build a full channel object.
2045
- const predefinedBase = getPredefinedChannel(key);
2046
- if ((action === "edit") && predefinedBase) {
2047
- // Build a record of submitted form values keyed by channel property name. This record drives both the displayChannel comparison and the predefined delta
2048
- // computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined.
2049
- const formValues = {
2050
- channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
2051
- channelSelector,
2052
- name,
2053
- profile,
2054
- stationId,
2055
- url
2056
- };
2057
- // Helper to read a comparable value from a Channel object. String fields default to "" when undefined so they match the form's empty-string representation.
2058
- // channelNumber stays as number | undefined since the form value uses the same representation.
2059
- const channelValue = (ch, field) => {
2060
- const val = ch[field];
2061
- return (field === "channelNumber") ? val : val ?? "";
2062
- };
2063
- // First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
2064
- // may differ from the canonical predefined base when a variant is selected (e.g., the Hulu variant has a different URL and channelSelector). If the submitted
2065
- // values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
2066
- const resolvedKey = resolveProviderKey(key);
2067
- const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
2068
- if (Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field))) {
2069
- res.json({ key, message: "No changes to save.", success: true });
2070
- return;
2071
- }
2072
- // Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
2073
- // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
2074
- const delta = {};
2075
- let hasChanges = false;
2076
- for (const field of Object.keys(formValues)) {
2077
- if (formValues[field] !== channelValue(predefinedBase, field)) {
2078
- const formVal = formValues[field];
2079
- delta[field] = ((formVal === "") || (formVal === undefined)) ? null : formVal;
2080
- hasChanges = true;
2081
- }
2082
- }
2083
- // Helper to check if form values match a given channel's properties.
2084
- const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field));
2085
- if (!hasChanges) {
2086
- // The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away — treat it as an
2087
- // implicit revert by removing the override and returning the predefined row HTML.
2088
- if (isUserChannel(key)) {
2089
- const implicitRevertHint = playlistHintForStored(result.channels[key]);
2090
- Reflect.deleteProperty(result.channels, key);
2091
- await saveUserChannels(result.channels);
2092
- LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
2093
- const rowHtml = generateChannelRowHtml(key, profiles);
2094
- res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
2095
- message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint, success: true });
2096
- return;
2097
- }
2098
- res.json({ key, message: "No changes to save.", success: true });
2099
- return;
2100
- }
2101
- // The delta has changes vs the canonical predefined. Before storing a custom override, check if the form values match any provider variant's predefined
2102
- // definition. This handles the case where a user edits from a variant (e.g., Hulu), makes a change, saves (creating a custom override), then edits again
2103
- // and reverts the change. The URL and channelSelector still differ from the canonical predefined but match the variant — that's a revert to the variant,
2104
- // not a new customization. We resolve each variant against pure PREDEFINED data (not the user-overridden channelsRef) to avoid contamination from the
2105
- // current override.
2106
- const providerGroup = getProviderGroup(key);
2107
- let matchedVariantKey;
2108
- if (providerGroup && isUserChannel(key)) {
2109
- for (const variant of providerGroup.variants) {
2110
- // Skip the canonical entry (already handled by the !hasChanges check above) and :predefined entries (synthetic entries for override UI).
2111
- if ((variant.key === key) || variant.key.includes(":")) {
2112
- continue;
2113
- }
2114
- // Resolve this variant against pure predefined data (no user override contamination).
2115
- const resolvedVariant = resolvePredefinedVariant(variant.key);
2116
- if (resolvedVariant && formMatchesChannel(resolvedVariant)) {
2117
- matchedVariantKey = variant.key;
2118
- break;
2119
- }
2120
- }
2121
- }
2122
- if (matchedVariantKey) {
2123
- // Form values match a known variant — revert the override and switch back to that variant.
2124
- const variantRevertHint = playlistHintForStored(result.channels[key]);
2125
- Reflect.deleteProperty(result.channels, key);
2126
- setProviderSelection(key, matchedVariantKey);
2127
- await saveUserChannels(result.channels);
2128
- LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
2129
- const rowHtml = generateChannelRowHtml(key, profiles);
2130
- res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
2131
- message: "Channel '" + key + "' reverted to defaults." + variantRevertHint, success: true });
2132
- return;
2133
- }
2134
- // No variant match — store the delta and switch the provider selection to the canonical key (the custom override). This ensures the provider dropdown shows
2135
- // "Custom" after saving, which is the expected behavior when a user customizes a predefined channel.
2136
- setProviderSelection(key, key);
2137
- result.channels[key] = delta;
2138
- playlistChanged = M3U_FIELDS.some((f) => f in delta);
2139
- }
2140
- else {
2141
- // User-defined channel or add: build a full channel object. For edits, snapshot the old channel to detect M3U-relevant changes.
2142
- const oldChannel = ((action === "edit") && (key in result.channels)) ? result.channels[key] : undefined;
2143
- const channel = {
2144
- name,
2145
- url
2146
- };
2147
- if (profile) {
2148
- channel.profile = profile;
2149
- }
2150
- if (stationId) {
2151
- channel.stationId = stationId;
2152
- }
2153
- if (channelSelector) {
2154
- channel.channelSelector = channelSelector;
2155
- }
2156
- if (channelNumberStr) {
2157
- channel.channelNumber = parseInt(channelNumberStr, 10);
2158
- }
2159
- result.channels[key] = channel;
2160
- // For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
2161
- playlistChanged = (action === "add") ||
2162
- ((oldChannel !== undefined) &&
2163
- M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
2164
- }
2165
- await saveUserChannels(result.channels);
2166
- const actionLabel = (action === "add") ? "added" : "updated";
2167
- LOG.info("User channel '%s' %s.", key, actionLabel);
2168
- // Generate HTML for the channel row so the client can update the DOM without a full page reload.
2169
- const rowHtml = generateChannelRowHtml(key, profiles);
2170
- // Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
2171
- const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
2172
- // Return success response with HTML for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
2173
- res.json({
2174
- html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow },
2175
- isNew: action === "add",
2176
- key,
2177
- message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
2178
- success: true
2179
- });
2180
- }
2181
- catch (error) {
2182
- LOG.error("Failed to save channel: %s", formatError(error));
2183
- res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
2184
- }
2185
- });
2186
- }
2187
- //# sourceMappingURL=config.js.map