prismcast 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +6 -6
  2. package/dist/app.js +15 -5
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +4 -6
  5. package/dist/browser/cdp.js +13 -17
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.js +3 -3
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/precaching.d.ts +1 -1
  10. package/dist/browser/precaching.js +23 -23
  11. package/dist/browser/precaching.js.map +1 -1
  12. package/dist/browser/tuning/directv.js +4 -7
  13. package/dist/browser/tuning/directv.js.map +1 -1
  14. package/dist/browser/tuning/fox.js +1 -1
  15. package/dist/browser/tuning/fox.js.map +1 -1
  16. package/dist/browser/tuning/hbo.js +1 -1
  17. package/dist/browser/tuning/hbo.js.map +1 -1
  18. package/dist/browser/tuning/hulu.js +3 -3
  19. package/dist/browser/tuning/hulu.js.map +1 -1
  20. package/dist/browser/tuning/sling.js +2 -2
  21. package/dist/browser/tuning/sling.js.map +1 -1
  22. package/dist/channels/index.d.ts +1 -1
  23. package/dist/channels/index.js +416 -416
  24. package/dist/channels/index.js.map +1 -1
  25. package/dist/config/health.d.ts +3 -3
  26. package/dist/config/health.js +3 -3
  27. package/dist/config/index.js +2 -2
  28. package/dist/config/index.js.map +1 -1
  29. package/dist/config/persistence.d.ts +59 -0
  30. package/dist/config/persistence.js +127 -0
  31. package/dist/config/persistence.js.map +1 -0
  32. package/dist/config/servicePacks.d.ts +46 -0
  33. package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
  34. package/dist/config/servicePacks.js.map +1 -0
  35. package/dist/config/services.d.ts +202 -0
  36. package/dist/config/{providers.js → services.js} +277 -210
  37. package/dist/config/services.js.map +1 -0
  38. package/dist/config/sites.js +61 -61
  39. package/dist/config/sites.js.map +1 -1
  40. package/dist/config/userChannels.d.ts +36 -27
  41. package/dist/config/userChannels.js +300 -212
  42. package/dist/config/userChannels.js.map +1 -1
  43. package/dist/config/userConfig.d.ts +11 -9
  44. package/dist/config/userConfig.js +53 -60
  45. package/dist/config/userConfig.js.map +1 -1
  46. package/dist/config/userProfiles.d.ts +31 -15
  47. package/dist/config/userProfiles.js +139 -96
  48. package/dist/config/userProfiles.js.map +1 -1
  49. package/dist/hdhr/channelMap.d.ts +1 -1
  50. package/dist/hdhr/channelMap.js +4 -7
  51. package/dist/hdhr/channelMap.js.map +1 -1
  52. package/dist/hdhr/index.js +6 -9
  53. package/dist/hdhr/index.js.map +1 -1
  54. package/dist/native/index.js +3 -3
  55. package/dist/native/index.js.map +1 -1
  56. package/dist/native/intercept.js +86 -89
  57. package/dist/native/intercept.js.map +1 -1
  58. package/dist/native/probe.js +2 -2
  59. package/dist/native/probe.js.map +1 -1
  60. package/dist/native/proxy.js +4 -4
  61. package/dist/native/proxy.js.map +1 -1
  62. package/dist/routes/assets.js +3 -3
  63. package/dist/routes/assets.js.map +1 -1
  64. package/dist/routes/auth.js +3 -3
  65. package/dist/routes/auth.js.map +1 -1
  66. package/dist/routes/config/channels/index.d.ts +1 -1
  67. package/dist/routes/config/channels/index.js +1 -1
  68. package/dist/routes/config/channels/index.js.map +1 -1
  69. package/dist/routes/config/channels/routes.js +488 -460
  70. package/dist/routes/config/channels/routes.js.map +1 -1
  71. package/dist/routes/config/channels/table.d.ts +4 -4
  72. package/dist/routes/config/channels/table.js +206 -147
  73. package/dist/routes/config/channels/table.js.map +1 -1
  74. package/dist/routes/config/index.d.ts +2 -2
  75. package/dist/routes/config/index.js +3 -3
  76. package/dist/routes/config/index.js.map +1 -1
  77. package/dist/routes/config/{providers.js → services.js} +44 -39
  78. package/dist/routes/config/services.js.map +1 -0
  79. package/dist/routes/config/settings.js +47 -29
  80. package/dist/routes/config/settings.js.map +1 -1
  81. package/dist/routes/debug.js +5 -7
  82. package/dist/routes/debug.js.map +1 -1
  83. package/dist/routes/health.js +1 -1
  84. package/dist/routes/health.js.map +1 -1
  85. package/dist/routes/index.js +2 -2
  86. package/dist/routes/index.js.map +1 -1
  87. package/dist/routes/logs.js +4 -4
  88. package/dist/routes/logs.js.map +1 -1
  89. package/dist/routes/playlist.d.ts +4 -4
  90. package/dist/routes/playlist.js +56 -44
  91. package/dist/routes/playlist.js.map +1 -1
  92. package/dist/routes/root/content.js +58 -58
  93. package/dist/routes/root/content.js.map +1 -1
  94. package/dist/routes/root/scripts/channels.js +529 -572
  95. package/dist/routes/root/scripts/channels.js.map +1 -1
  96. package/dist/routes/root/scripts/config.js +838 -992
  97. package/dist/routes/root/scripts/config.js.map +1 -1
  98. package/dist/routes/root/scripts/shared.js +431 -197
  99. package/dist/routes/root/scripts/shared.js.map +1 -1
  100. package/dist/routes/root/scripts/status.js +247 -196
  101. package/dist/routes/root/scripts/status.js.map +1 -1
  102. package/dist/routes/root/styles.js +12 -4
  103. package/dist/routes/root/styles.js.map +1 -1
  104. package/dist/routes/services.d.ts +6 -0
  105. package/dist/routes/{providers.js → services.js} +43 -43
  106. package/dist/routes/services.js.map +1 -0
  107. package/dist/routes/ui.js +2 -0
  108. package/dist/routes/ui.js.map +1 -1
  109. package/dist/service/commands.js +5 -8
  110. package/dist/service/commands.js.map +1 -1
  111. package/dist/service/generators.d.ts +1 -1
  112. package/dist/service/generators.js +143 -41
  113. package/dist/service/generators.js.map +1 -1
  114. package/dist/streaming/hls.d.ts +4 -4
  115. package/dist/streaming/hls.js +24 -23
  116. package/dist/streaming/hls.js.map +1 -1
  117. package/dist/streaming/hlsResume.js +1 -1
  118. package/dist/streaming/hlsResume.js.map +1 -1
  119. package/dist/streaming/monitor.d.ts +2 -2
  120. package/dist/streaming/monitor.js +19 -13
  121. package/dist/streaming/monitor.js.map +1 -1
  122. package/dist/streaming/preroll.js +1 -1
  123. package/dist/streaming/preroll.js.map +1 -1
  124. package/dist/streaming/registry.js +4 -8
  125. package/dist/streaming/registry.js.map +1 -1
  126. package/dist/streaming/setup.d.ts +2 -2
  127. package/dist/streaming/setup.js +23 -22
  128. package/dist/streaming/setup.js.map +1 -1
  129. package/dist/streaming/showInfo.js +8 -10
  130. package/dist/streaming/showInfo.js.map +1 -1
  131. package/dist/streaming/statusEmitter.d.ts +4 -3
  132. package/dist/streaming/statusEmitter.js +3 -3
  133. package/dist/streaming/statusEmitter.js.map +1 -1
  134. package/dist/types/channels.d.ts +15 -11
  135. package/dist/types/config.d.ts +2 -2
  136. package/dist/types/index.d.ts +2 -2
  137. package/dist/types/profiles.d.ts +9 -9
  138. package/dist/types/selection.d.ts +1 -1
  139. package/dist/types/shared.d.ts +1 -1
  140. package/dist/types/streaming.d.ts +1 -1
  141. package/dist/utils/chromeFetch.js +1 -1
  142. package/dist/utils/debugFilter.js +3 -3
  143. package/dist/utils/debugFilter.js.map +1 -1
  144. package/dist/utils/fileLogger.js +4 -3
  145. package/dist/utils/fileLogger.js.map +1 -1
  146. package/dist/utils/format.d.ts +7 -1
  147. package/dist/utils/format.js +21 -1
  148. package/dist/utils/format.js.map +1 -1
  149. package/dist/utils/logger.js +17 -27
  150. package/dist/utils/logger.js.map +1 -1
  151. package/dist/utils/morganStream.js +2 -2
  152. package/dist/utils/morganStream.js.map +1 -1
  153. package/dist/utils/platform.js +3 -2
  154. package/dist/utils/platform.js.map +1 -1
  155. package/dist/utils/streamContext.d.ts +7 -0
  156. package/dist/utils/streamContext.js +10 -1
  157. package/dist/utils/streamContext.js.map +1 -1
  158. package/dist/utils/version.js +4 -4
  159. package/dist/utils/version.js.map +1 -1
  160. package/package.json +5 -7
  161. package/dist/config/providerPacks.d.ts +0 -46
  162. package/dist/config/providerPacks.js.map +0 -1
  163. package/dist/config/providers.d.ts +0 -175
  164. package/dist/config/providers.js.map +0 -1
  165. package/dist/routes/config/providers.js.map +0 -1
  166. package/dist/routes/providers.d.ts +0 -6
  167. package/dist/routes/providers.js.map +0 -1
  168. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -3,9 +3,9 @@
3
3
  * table.ts: Channel table rendering for the PrismCast configuration interface.
4
4
  */
5
5
  import { ICON_BOLT, ICON_COPY, ICON_DELETE, ICON_DISABLE, ICON_EDIT, ICON_ENABLE, ICON_FILTER, ICON_HEALTH, ICON_LINK, ICON_LOGIN, ICON_MANAGE, ICON_REVERT, ICON_TRANSFER } from "../../icons.js";
6
- import { compareChannelSort, getAllProviderTags, getAuthDomainForChannel, getChannelProviderLabel, getChannelProviderTags, getChannelSortKey, getEnabledProviders, getProviderGroup, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolvePredefinedVariant, resolveProviderKey } from "../../../config/providers.js";
6
+ import { compareChannelSort, getAllServiceTags, getAuthDomainForChannel, getChannelServiceLabel, getChannelServiceTags, getChannelSortKey, getEnabledServices, getPredefinedDomainMap, getServiceGroup, hasMultipleServices, isChannelAvailableByService, isServiceTagEnabled, resolvePredefinedVariant, resolveServiceKey } from "../../../config/services.js";
7
7
  import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
8
- import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
8
+ import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, tagsMatch } from "../../../config/userChannels.js";
9
9
  import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
10
10
  import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
11
11
  import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
@@ -14,22 +14,22 @@ import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
14
14
  import { categorizeProfiles } from "../index.js";
15
15
  import { generateWizardModal } from "../../components.js";
16
16
  /**
17
- * Generates an annotated provider display span. The client-side page-load script processes these elements via providerIconHtml, rendering the appropriate
18
- * icon + text combination. The server just emits the text with data attributes — all icon rendering is client-side through the single providerIconHtml path.
19
- * @param name - The provider display name.
20
- * @param domain - The provider's domain for icon fallback derivation. Undefined for providers without a known domain.
17
+ * Generates an annotated service display span. The client-side page-load script processes these elements via serviceIconHtml, rendering the appropriate
18
+ * icon + text combination. The server just emits the text with data attributes — all icon rendering is client-side through the single serviceIconHtml path.
19
+ * @param name - The service display name.
20
+ * @param domain - The service's domain for icon fallback derivation. Undefined for services without a known domain.
21
21
  * @param iconUrl - Optional explicit icon URL to try before domain-derived fallbacks.
22
22
  * @param small - When true, uses the small icon variant for chips.
23
23
  * @returns HTML span string with data attributes for client-side processing.
24
24
  */
25
- function providerDisplaySpan(name, domain, iconUrl, small) {
25
+ function serviceDisplaySpan(name, domain, iconUrl, small) {
26
26
  const domainAttr = domain ? " data-domain=\"" + escapeHtml(domain) + "\"" : "";
27
27
  const iconAttr = iconUrl ? " data-icon-url=\"" + escapeHtml(iconUrl) + "\"" : "";
28
28
  const sizeAttr = small ? " data-sm" : "";
29
29
  return "<span class=\"provider-display\"" + domainAttr + iconAttr + sizeAttr + ">" + escapeHtml(name) + "</span>";
30
30
  }
31
31
  // Optional column definitions for the channels table. These columns can be shown or hidden by the user via the column picker. The order here determines the
32
- // column order in the table, slotted between Provider and Actions.
32
+ // column order in the table, slotted between Service and Actions.
33
33
  export const OPTIONAL_COLUMNS = [
34
34
  { align: "center", cssClass: "col-chnum", field: "channelNumber", label: "Number", width: "70px" },
35
35
  { align: "center", cssClass: "col-hdhr", field: "hdhrEnabled", label: "HDHR", width: "55px" },
@@ -58,10 +58,17 @@ function generateTextField(id, name, label, value, options = {}) {
58
58
  const required = options.required ? " required" : "";
59
59
  const pattern = options.pattern ? " pattern=\"" + options.pattern + "\"" : "";
60
60
  const placeholder = options.placeholder ? " placeholder=\"" + escapeHtml(options.placeholder) + "\"" : "";
61
- lines.push("<div class=\"form-row\">");
62
- lines.push("<label for=\"" + id + "\">" + label + "</label>");
61
+ // Modification tracking: compare against the predefined default and render the settings form's "modified" visual treatment when they differ.
62
+ const isModified = (options.defaultValue !== undefined) && (options.defaultValue !== value);
63
+ const modifiedClass = isModified ? " modified" : "";
64
+ const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
65
+ const defaultAttr = (options.defaultValue !== undefined) ? " data-default=\"" + escapeHtml(options.defaultValue) + "\"" : "";
66
+ const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
67
+ "onclick=\"resetChannelField('" + id + "')\">&#8635;</button>" : "";
68
+ lines.push("<div class=\"form-row" + modifiedClass + "\">");
69
+ lines.push(modifiedDot + "<label for=\"" + id + "\">" + label + "</label>");
63
70
  lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
64
- placeholder + " value=\"" + escapeHtml(value) + "\">");
71
+ placeholder + defaultAttr + " value=\"" + escapeHtml(value) + "\">" + resetBtn);
65
72
  lines.push("</div>");
66
73
  // When a datalist ID is specified, append an empty <datalist> element outside the form-row flex container. The client-side JavaScript populates it dynamically
67
74
  // based on the URL field value.
@@ -81,9 +88,17 @@ function generateTextField(id, name, label, value, options = {}) {
81
88
  * @param showHint - Whether to show the hint text with profile reference link.
82
89
  * @returns Array of HTML strings for the form row.
83
90
  */
84
- function generateProfileDropdown(id, selectedProfile, profiles, showHint = true) {
91
+ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true, defaultProfile) {
85
92
  const lines = [];
86
93
  const groups = categorizeProfiles(profiles);
94
+ // Modification tracking for the profile dropdown. Same visual treatment as text fields — border accent, dot, and reset button when the current selection
95
+ // differs from the predefined default.
96
+ const isModified = (defaultProfile !== undefined) && (defaultProfile !== selectedProfile);
97
+ const modifiedClass = isModified ? " modified" : "";
98
+ const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
99
+ const defaultAttr = (defaultProfile !== undefined) ? " data-default=\"" + escapeHtml(defaultProfile) + "\"" : "";
100
+ const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
101
+ "onclick=\"resetChannelField('" + id + "')\">&#8635;</button>" : "";
87
102
  // Helper to generate option elements for a profile.
88
103
  const renderOption = (profile) => {
89
104
  const selected = (profile.name === selectedProfile) ? " selected" : "";
@@ -91,9 +106,9 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
91
106
  const displayText = profile.summary ? profile.name + " \u2014 " + profile.summary : profile.name;
92
107
  return "<option value=\"" + escapeHtml(profile.name) + "\"" + title + selected + ">" + escapeHtml(displayText) + "</option>";
93
108
  };
94
- lines.push("<div class=\"form-row\">");
95
- lines.push("<label for=\"" + id + "\">Profile</label>");
96
- lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\">");
109
+ lines.push("<div class=\"form-row" + modifiedClass + "\">");
110
+ lines.push(modifiedDot + "<label for=\"" + id + "\">Profile</label>");
111
+ lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\"" + defaultAttr + ">");
97
112
  lines.push("<option value=\"\">Autodetect (Recommended)</option>");
98
113
  // Fullscreen API profiles (most common).
99
114
  if (groups.api.length > 0) {
@@ -135,7 +150,7 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
135
150
  }
136
151
  lines.push("</optgroup>");
137
152
  }
138
- lines.push("</select>");
153
+ lines.push("</select>" + resetBtn);
139
154
  lines.push("</div>");
140
155
  if (showHint) {
141
156
  lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
@@ -248,7 +263,7 @@ function generateProfileReference(profiles) {
248
263
  if (groups.custom.length > 0) {
249
264
  lines.push("<div class=\"profile-category\">");
250
265
  lines.push("<h4>Custom Profiles</h4>");
251
- lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from provider packs.</p>");
266
+ lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from service packs.</p>");
252
267
  lines.push("<dl class=\"profile-list\">");
253
268
  for (const profile of groups.custom) {
254
269
  lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
@@ -261,7 +276,7 @@ function generateProfileReference(profiles) {
261
276
  return lines.join("\n");
262
277
  }
263
278
  function generateAdvancedFields(idPrefix, options = {}) {
264
- const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "", tagsValue = "" } = options;
279
+ const { channelNumberValue = "", channelSelectorValue = "", guideTitleValue = "", hdhrEnabled = true, logoUrlValue = "", showHints = true, stationIdValue = "", tagsValue = "" } = options;
265
280
  const lines = [];
266
281
  // Advanced fields toggle.
267
282
  lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
@@ -270,8 +285,15 @@ function generateAdvancedFields(idPrefix, options = {}) {
270
285
  lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
271
286
  // Station ID.
272
287
  const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
273
- lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { hint: stationIdHint, placeholder: showHints ? "e.g., 12345" : undefined }));
274
- // Channel selector. The guide-based provider list is derived from the provider module registry so it stays current as providers are added.
288
+ const defs = options.defaults;
289
+ lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { defaultValue: defs?.stationId, hint: stationIdHint, placeholder: showHints ? "e.g., 12345" : undefined }));
290
+ // Guide title override. When set, this value replaces the channel name in the M3U playlist's tvg-name attribute and guide placeholder airings.
291
+ const guideTitleHint = showHints ? "Optional title for guide display. When set, overrides the channel name in the M3U playlist (tvg-name)." : undefined;
292
+ lines.push(...generateTextField(idPrefix + "-guideTitle", "guideTitle", "Guide Title", guideTitleValue, { defaultValue: defs?.guideTitle, hint: guideTitleHint, placeholder: showHints ? "e.g., Flighty Airport Delays" : undefined }));
293
+ // Custom logo URL. When set, this value is emitted as the tvg-logo attribute in the M3U playlist.
294
+ const logoUrlHint = showHints ? "Optional logo URL for the channel. Overrides any logo derived from Channels DVR." : undefined;
295
+ lines.push(...generateTextField(idPrefix + "-logoUrl", "logoUrl", "Logo URL", logoUrlValue, { defaultValue: defs?.logoUrl, hint: logoUrlHint, placeholder: showHints ? "https://example.com/logo.png" : undefined, type: "url" }));
296
+ // Channel selector. The guide-based service list is derived from the provider module registry so it stays current as services are added.
275
297
  const guideProviderNames = getProviderModuleInfo().map((p) => p.label).sort().join(", ");
276
298
  const channelSelectorHint = showHints ?
277
299
  "Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
@@ -279,23 +301,27 @@ function generateAdvancedFields(idPrefix, options = {}) {
279
301
  "For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
280
302
  "(typically a portion of the image src URL)." :
281
303
  undefined;
282
- lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
304
+ lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { defaultValue: defs?.channelSelector, hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
283
305
  // Channel number for Channels DVR and Plex integration.
284
306
  const channelNumberHint = showHints ?
285
307
  "Optional numeric channel number for guide matching in Channels DVR and Plex." :
286
308
  undefined;
287
- lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
309
+ lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { defaultValue: defs?.channelNumber, hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
288
310
  // Tags field: checkbox grid of the active tag vocabulary. Checked tags are collected into a hidden input as a comma-separated value on form submission.
289
311
  // This prevents users from entering tag names that don't exist in the managed vocabulary.
290
312
  const vocabulary = getActiveTagVocabulary();
291
- const currentTags = new Set(tagsValue ? tagsValue.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0) : []);
313
+ const currentTags = tagsValue ? tagsValue.split(",").map((t) => t.trim()).filter((t) => t.length > 0) : [];
292
314
  if (vocabulary.length > 0) {
293
- lines.push("<div class=\"form-row\">");
294
- lines.push("<label>Tags</label>");
295
- lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\">");
315
+ const tagsModified = (defs?.tags !== undefined) && (defs.tags !== tagsValue);
316
+ const tagsModifiedClass = tagsModified ? " modified" : "";
317
+ const tagsModifiedDot = tagsModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
318
+ const tagsDefaultAttr = (defs?.tags !== undefined) ? " data-default=\"" + escapeHtml(defs.tags) + "\"" : "";
319
+ lines.push("<div class=\"form-row" + tagsModifiedClass + "\">");
320
+ lines.push(tagsModifiedDot + "<label>Tags</label>");
321
+ lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\"" + tagsDefaultAttr + ">");
296
322
  lines.push("<div class=\"tag-checkbox-grid\">");
297
323
  for (const tag of vocabulary) {
298
- const checked = currentTags.has(tag) ? " checked" : "";
324
+ const checked = currentTags.some((t) => tagsMatch(t, tag)) ? " checked" : "";
299
325
  lines.push("<label class=\"tag-checkbox-label\">" +
300
326
  "<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
301
327
  " onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
@@ -310,11 +336,16 @@ function generateAdvancedFields(idPrefix, options = {}) {
310
336
  // HDHomeRun lineup inclusion. A hidden input provides the "false" value when the checkbox is unchecked (unchecked checkboxes are not submitted in FormData).
311
337
  // When checked, the checkbox value "true" overwrites the hidden input's "false" since it appears later in DOM order.
312
338
  const hdhrChecked = hdhrEnabled ? " checked" : "";
339
+ const hdhrDefaultEnabled = defs?.hdhrEnabled ?? true;
340
+ const hdhrModified = (defs?.hdhrEnabled !== undefined) && (hdhrEnabled !== hdhrDefaultEnabled);
341
+ const hdhrModifiedClass = hdhrModified ? " modified" : "";
342
+ const hdhrModifiedDot = hdhrModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
343
+ const hdhrDefaultAttr = (defs?.hdhrEnabled !== undefined) ? " data-default=\"" + String(hdhrDefaultEnabled) + "\"" : "";
313
344
  const hdhrHint = showHints ? "When unchecked, this channel is hidden from the HDHomeRun lineup and not available in Plex." : undefined;
314
- lines.push("<div class=\"form-row form-row-checkbox\">");
315
- lines.push("<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
345
+ lines.push("<div class=\"form-row form-row-checkbox" + hdhrModifiedClass + "\">");
346
+ lines.push(hdhrModifiedDot + "<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
316
347
  lines.push("<input type=\"hidden\" name=\"hdhrEnabled\" value=\"false\">");
317
- lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + ">");
348
+ lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + hdhrDefaultAttr + ">");
318
349
  lines.push("</div>");
319
350
  if (hdhrHint) {
320
351
  lines.push("<div class=\"hint\">" + hdhrHint + "</div>");
@@ -324,8 +355,8 @@ function generateAdvancedFields(idPrefix, options = {}) {
324
355
  }
325
356
  /**
326
357
  * Generates JavaScript variables for channel selector datalist population. Produces three variables: `channelSelectorsByDomain` maps URL hostnames to known
327
- * channel selector values (from predefined channels and cached provider discovery), `providerByDomain` maps provider guide URL hostnames to provider slugs for
328
- * client-side async discovery, and `providerGuideUrl` maps provider slugs to their guide URLs for URL correction hints. Embedded as a `<script>` block in the
358
+ * channel selector values (from predefined channels and cached service discovery), `serviceByDomain` maps service guide URL hostnames to service slugs for
359
+ * client-side async discovery, and `serviceGuideUrl` maps service slugs to their guide URLs for URL correction hints. Embedded as a `<script>` block in the
329
360
  * channels panel.
330
361
  *
331
362
  * @returns JavaScript variable declarations ready to embed in a `<script>` tag.
@@ -350,8 +381,8 @@ function generateChannelSelectorData() {
350
381
  byDomain[hostname] ??= [];
351
382
  byDomain[hostname].push({ label: channel.name ?? selector, stationId: channel.stationId, value: selector });
352
383
  }
353
- // Merge cached provider-discovered channels into the domain map. Predefined entries take precedence — we only add discovered channels whose channelSelector
354
- // value is not already present for that domain. This enriches the datalist with the full provider lineup when precaching or prior discovery has run.
384
+ // Merge cached service-discovered channels into the domain map. Predefined entries take precedence — we only add discovered channels whose channelSelector
385
+ // value is not already present for that domain. This enriches the datalist with the full service lineup when precaching or prior discovery has run.
355
386
  for (const provider of getCachedProviderChannels()) {
356
387
  seen[provider.hostname] ??= new Set();
357
388
  byDomain[provider.hostname] ??= [];
@@ -362,17 +393,18 @@ function generateChannelSelectorData() {
362
393
  }
363
394
  }
364
395
  }
365
- // Build the hostname→slug map and slug→guideUrl map for all providers (including those with cold caches) so the client-side fetch can trigger discovery for any
366
- // provider domain and the URL hint can suggest the correct guide URL.
367
- const providerByDomain = getProviderDomainMap();
368
- const providerGuideUrl = getProviderGuideUrls();
396
+ // Build the hostname→slug map and slug→guideUrl map for all services (including those with cold caches) so the client-side fetch can trigger discovery for any
397
+ // service domain and the URL hint can suggest the correct guide URL.
398
+ const serviceByDomain = getProviderDomainMap();
399
+ const serviceGuideUrl = getProviderGuideUrls();
369
400
  // Sort entries within each domain alphabetically by label for consistent ordering in the datalist dropdown.
370
401
  for (const entries of Object.values(byDomain)) {
371
402
  entries.sort((a, b) => a.label.localeCompare(b.label));
372
403
  }
373
404
  return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";\n" +
374
- "var providerByDomain = " + JSON.stringify(providerByDomain) + ";\n" +
375
- "var providerGuideUrl = " + JSON.stringify(providerGuideUrl) + ";";
405
+ "var serviceByDomain = " + JSON.stringify(serviceByDomain) + ";\n" +
406
+ "var serviceGuideUrl = " + JSON.stringify(serviceGuideUrl) + ";\n" +
407
+ "var predefinedByDomain = " + JSON.stringify(getPredefinedDomainMap()) + ";";
376
408
  }
377
409
  /**
378
410
  * Generates the tag column filter dropdown content. This is the single source of truth for the filter checkbox list — used for both the initial header render
@@ -424,7 +456,7 @@ export function generateTagManagerBody() {
424
456
  "for playlist filtering and organization.</p>" +
425
457
  "<div class=\"tag-manager-add\">" +
426
458
  "<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
427
- "pattern=\"[a-z0-9]([a-z0-9-]*[a-z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
459
+ "pattern=\"[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
428
460
  "<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
429
461
  "</div>" +
430
462
  "<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
@@ -452,13 +484,13 @@ function generateTagManagementModal() {
452
484
  });
453
485
  }
454
486
  /**
455
- * Generates the Browse Channels 2-step wizard modal. Step 1 is the provider picker grid, step 2 is channel discovery and management. Channel state detection
456
- * (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Provider and guide URL data are
487
+ * Generates the Browse Channels 2-step wizard modal. Step 1 is the service picker grid, step 2 is channel discovery and management. Channel state detection
488
+ * (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Service and guide URL data are
457
489
  * embedded as JSON data blocks for the client-side wizard controller.
458
490
  * @returns HTML string for the browse modal.
459
491
  */
460
492
  function generateBrowseModal() {
461
- // Build the provider info array from the provider module registry. The client uses this to render the provider picker.
493
+ // Build the service info array from the provider module registry. The client uses this to render the service picker.
462
494
  const providers = getProviderModuleInfo();
463
495
  const guideUrls = getProviderGuideUrls();
464
496
  return generateWizardModal({
@@ -469,26 +501,26 @@ function generateBrowseModal() {
469
501
  ],
470
502
  contentId: "browse-content",
471
503
  dataBlocks: [
472
- "<script type=\"application/json\" id=\"browse-providers-data\">" + JSON.stringify(providers) + "</script>",
504
+ "<script type=\"application/json\" id=\"browse-services-data\">" + JSON.stringify(providers) + "</script>",
473
505
  "<script type=\"application/json\" id=\"browse-guide-urls-data\">" + JSON.stringify(guideUrls) + "</script>"
474
506
  ],
475
- description: "Manage your channel lineup by provider. Add new channels, switch providers for existing channels, or remove channels you no longer need.",
507
+ description: "Manage your channel lineup by service. Add new channels, switch services for existing channels, or remove channels you no longer need.",
476
508
  id: "browse-modal",
477
- steps: ["Provider", "Channels"],
509
+ steps: ["Service", "Channels"],
478
510
  title: "Browse Channels",
479
511
  titleId: "browse-title"
480
512
  });
481
513
  }
482
514
  /**
483
- * Generates the Provider Setup 3-step wizard modal. Steps: Providers (multi-select), Sign In (sequential auth), Channels (summary + finish). The
515
+ * Generates the Service Setup 3-step wizard modal. Steps: Services (multi-select), Sign In (sequential auth), Channels (summary + finish). The
484
516
  * setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
485
517
  * @returns HTML string for the setup wizard modal.
486
518
  */
487
519
  function generateSetupWizardModal() {
488
- const enabled = getEnabledProviders();
489
- // Build provider data with enabled state from the current provider filter. getAllProviderTags is the single source of truth for provider metadata
490
- // (domain, iconUrl, displayName). The enabled field lets the client pre-check providers on re-run without scraping DOM state.
491
- const tags = getAllProviderTags()
520
+ const enabled = getEnabledServices();
521
+ // Build service data with enabled state from the current service filter. getAllServiceTags is the single source of truth for service metadata
522
+ // (domain, iconUrl, displayName). The enabled field lets the client pre-check services on re-run without reading DOM state.
523
+ const tags = getAllServiceTags()
492
524
  .filter((t) => t.tag !== "direct")
493
525
  .map((t) => ({ ...t, enabled: enabled.includes(t.tag) }));
494
526
  return generateWizardModal({
@@ -501,14 +533,14 @@ function generateSetupWizardModal() {
501
533
  contentId: "setup-content",
502
534
  dataAttributes: { "setup-completed": CONFIG.channels.setupCompleted ? "true" : "false" },
503
535
  dataBlocks: [
504
- "<script type=\"application/json\" id=\"setup-providers-data\">" + JSON.stringify(tags) + "</script>"
536
+ "<script type=\"application/json\" id=\"setup-services-data\">" + JSON.stringify(tags) + "</script>"
505
537
  ],
506
- description: "Set up your streaming providers in three steps: select your services, sign in, and choose your channels.",
538
+ description: "Set up your streaming services in three steps: select your services, sign in, and choose your channels.",
507
539
  errorId: "setup-error",
508
540
  id: "setup-modal",
509
- steps: ["Providers", "Sign In", "Channels"],
541
+ steps: ["Services", "Sign In", "Channels"],
510
542
  stepsId: "setup-steps",
511
- title: "Provider Setup",
543
+ title: "Service Setup",
512
544
  titleId: "setup-title"
513
545
  });
514
546
  }
@@ -534,60 +566,66 @@ export function generateChannelRowHtml(key, profiles, entry) {
534
566
  const isPredefined = isPredefinedChannel(key);
535
567
  const isOverride = isPredefined && isUser;
536
568
  const isDisabled = isPredefinedChannelDisabled(key);
537
- const isAvailableByProvider = isChannelAvailableByProvider(key);
538
- // Check if this channel has multiple providers.
539
- const providerGroup = getProviderGroup(key);
540
- // Build the provider tags data attribute for client-side filtering.
541
- const providerTags = getChannelProviderTags(key).join(",");
542
- // Generate display row. User channels get one CSS class, disabled predefined get another, provider-filtered get a third.
569
+ const isAvailableByService = isChannelAvailableByService(key);
570
+ // Check if this channel has multiple services.
571
+ const serviceGroup = getServiceGroup(key);
572
+ // Build the service tags data attribute for client-side filtering.
573
+ const serviceTags = getChannelServiceTags(key).join(",");
574
+ // Generate display row. User-created channels and predefined overrides get distinct CSS classes — user-created channels have a subtle background tint
575
+ // ("user-channel"), while predefined overrides get a left-border accent and blue dot indicator via CSS ::before ("channel-override") matching the settings
576
+ // form's visual language for "modified from defaults." Disabled and service-filtered rows get additional classes for opacity and visibility control.
543
577
  const displayLines = [];
544
578
  const rowClasses = [];
545
- if (isUser) {
579
+ if (isOverride) {
580
+ rowClasses.push("channel-override");
581
+ }
582
+ else if (isUser) {
546
583
  rowClasses.push("user-channel");
547
584
  }
548
585
  if (isDisabled) {
549
586
  rowClasses.push("channel-disabled");
550
587
  }
551
- if (!isAvailableByProvider) {
588
+ if (!isAvailableByService) {
552
589
  rowClasses.push("channel-unavailable");
553
590
  }
554
591
  const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
592
+ const rowTitleAttr = isOverride ? " title=\"Customized from predefined defaults\"" : "";
555
593
  // Compute effective tags early — used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
556
594
  const effectiveTags = getChannelEffectiveTags(channel);
557
595
  const channelTagsAttr = (effectiveTags.length > 0) ? " data-channel-tags=\"" + escapeHtml(effectiveTags.join(",")) + "\"" : "";
558
- displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) +
596
+ displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + rowTitleAttr + " data-provider-tags=\"" + escapeHtml(serviceTags) +
559
597
  "\"" + channelTagsAttr + ">");
560
598
  displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
561
599
  const channelLogoUrl = getChannelLogo(key) ?? "";
562
600
  const logoAttr = channelLogoUrl ? " data-logo=\"" + escapeHtml(channelLogoUrl) + "\"" : "";
563
601
  displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "name")) + "\"" + logoAttr +
564
602
  "><span class=\"channel-name-cell\">" + escapeHtml(channel.name ?? key) + "</span></td>");
565
- // Provider column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers"
566
- // label alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
567
- displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "provider")) + "\">");
568
- const labelHidden = isAvailableByProvider ? " style=\"display:none\"" : "";
569
- const contentHidden = isAvailableByProvider ? "" : " style=\"display:none\"";
570
- displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available providers</em>");
571
- if (hasMultipleProviders(key) && providerGroup) {
572
- // Multi-provider: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the provider selection changes. Filtered-out
573
- // options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls filterChannelRows()
603
+ // Service column: dropdown for multi-service channels, static service name for single-service. Both states always render a hidden "No available services"
604
+ // label alongside the service content so that client-side channelTable.filter() can toggle between them without a page reload.
605
+ displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "service")) + "\">");
606
+ const labelHidden = isAvailableByService ? " style=\"display:none\"" : "";
607
+ const contentHidden = isAvailableByService ? "" : " style=\"display:none\"";
608
+ displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available services</em>");
609
+ if (hasMultipleServices(key) && serviceGroup) {
610
+ // Multi-service: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the service selection changes. Filtered-out
611
+ // options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls channelTable.filter()
574
612
  // to remove them from the DOM.
575
- const currentSelection = resolveProviderKey(key);
613
+ const currentSelection = resolveServiceKey(key);
576
614
  displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
577
- "\" title=\"Choose which streaming provider delivers this channel\" onchange=\"updateProviderSelection(this)\"" + contentHidden + ">");
578
- for (const variant of providerGroup.variants) {
615
+ "\" title=\"Choose which streaming service delivers this channel\" onchange=\"updateServiceSelection(this)\"" + contentHidden + ">");
616
+ for (const variant of serviceGroup.variants) {
579
617
  const selected = (variant.key === currentSelection) ? " selected" : "";
580
- const optionHidden = !isProviderTagEnabled(variant.tag) ? " hidden" : "";
618
+ const optionHidden = !isServiceTagEnabled(variant.tag) ? " hidden" : "";
581
619
  displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(variant.tag) + "\"" + selected + optionHidden + ">" +
582
620
  escapeHtml(variant.label) + "</option>");
583
621
  }
584
622
  displayLines.push("</select>");
585
623
  }
586
624
  else {
587
- // Single-provider: wrap the provider name in a span so client-side JS can toggle it with the no-provider label. Uses profile-aware label resolution so
588
- // channels with explicit profile assignments show the profile's provider name rather than the built-in name for the URL domain.
625
+ // Single-service: wrap the service name in a span so client-side JS can toggle it with the no-service label. Uses profile-aware label resolution so
626
+ // channels with explicit profile assignments show the profile's service name rather than the built-in name for the URL domain.
589
627
  displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
590
- escapeHtml(getChannelProviderLabel(channel)) + "</span>");
628
+ escapeHtml(getChannelServiceLabel(channel)) + "</span>");
591
629
  }
592
630
  displayLines.push("</td>");
593
631
  // Optional columns: Number, HDHR, Station ID, Profile, Selector. All five are always rendered; visibility is controlled by CSS classes on the table element.
@@ -612,7 +650,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
612
650
  else {
613
651
  const resolved = getProfileForChannel(channel);
614
652
  if (resolved.profileName !== "default") {
615
- const label = getChannelProviderLabel(channel);
653
+ const label = getChannelServiceLabel(channel);
616
654
  displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey +
617
655
  "\"><span class=\"text-muted\">" + escapeHtml(label + " (auto)") + "</span></td>");
618
656
  }
@@ -634,14 +672,14 @@ export function generateChannelRowHtml(key, profiles, entry) {
634
672
  displayLines.push("<td>");
635
673
  displayLines.push("<div class=\"btn-group\">");
636
674
  const escapedKey = escapeHtml(key);
637
- // Resolve the auth domain for the currently selected provider variant. Used for both login icon color and health icon lookups.
638
- const variantKey = resolveProviderKey(key);
675
+ // Resolve the auth domain for the currently selected service variant. Used for both login icon color and health icon lookups.
676
+ const variantKey = resolveServiceKey(key);
639
677
  const authDomain = getAuthDomainForChannel(variantKey);
640
678
  // Position 1: Edit (all channels).
641
679
  displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
642
680
  "')\">" + ICON_EDIT + "</button>");
643
681
  // Position 2: Login for enabled channels (with domain auth color), placeholder for disabled predefined. Custom channels (user-defined, not predefined) skip
644
- // login coloring because they have no provider concept.
682
+ // login coloring because they have no service concept.
645
683
  if (!isDisabled) {
646
684
  const authTimestamp = (isPredefined || isOverride) ? getDomainAuth(authDomain) : null;
647
685
  const loginColorClass = authTimestamp ? " health-success" : "";
@@ -694,8 +732,8 @@ export function generateChannelRowHtml(key, profiles, entry) {
694
732
  displayLines.push("</td>");
695
733
  displayLines.push("</tr>");
696
734
  const displayRow = displayLines.join("\n");
697
- // 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
698
- // canonical definition which may differ when a provider variant is selected.
735
+ // Generate edit form row for all channels. Pre-populate with the currently selected service's values so the user sees what they're actually streaming, not the
736
+ // canonical definition which may differ when a service variant is selected.
699
737
  const editLines = [];
700
738
  editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
701
739
  editLines.push("<td colspan=\"" + String(TOTAL_COLUMN_COUNT) + "\">");
@@ -704,24 +742,42 @@ export function generateChannelRowHtml(key, profiles, entry) {
704
742
  editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
705
743
  editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
706
744
  editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
745
+ // For override channels, look up the predefined defaults so each field can show the "modified from defaults" indicator when its value differs. This applies
746
+ // the same visual treatment used by the settings form — border accent, blue dot, and per-field reset button — so users can immediately see which fields they
747
+ // customized and reset individual fields without reverting the entire channel.
748
+ const predefined = isOverride ? PREDEFINED_CHANNELS[key] : undefined;
749
+ const predefinedTags = predefined ? getChannelEffectiveTags(predefined).join(", ") : undefined;
707
750
  // Channel name.
708
751
  editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", channel.name ?? key, {
752
+ defaultValue: predefined ? (predefined.name ?? key) : undefined,
709
753
  hint: "Friendly name shown in the playlist and UI.",
710
754
  required: true
711
755
  }));
712
756
  // Channel URL.
713
757
  editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", channel.url, {
758
+ defaultValue: predefined?.url,
714
759
  hint: "The URL of the streaming page to capture.",
715
760
  required: true,
716
761
  type: "url"
717
762
  }));
718
763
  // Profile dropdown.
719
- editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles));
764
+ editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles, true, isOverride ? (predefined?.profile ?? "") : undefined));
720
765
  // Advanced fields.
721
766
  editLines.push(...generateAdvancedFields("edit-" + key, {
722
767
  channelNumberValue: channel.channelNumber ? String(channel.channelNumber) : "",
723
768
  channelSelectorValue: channel.channelSelector ?? "",
769
+ defaults: predefined ? {
770
+ channelNumber: predefined.channelNumber ? String(predefined.channelNumber) : "",
771
+ channelSelector: predefined.channelSelector ?? "",
772
+ guideTitle: predefined.guideTitle ?? "",
773
+ hdhrEnabled: predefined.hdhrEnabled !== false,
774
+ logoUrl: predefined.logoUrl ?? "",
775
+ stationId: predefined.stationId ?? "",
776
+ tags: predefinedTags
777
+ } : undefined,
778
+ guideTitleValue: channel.guideTitle ?? "",
724
779
  hdhrEnabled: channel.hdhrEnabled !== false,
780
+ logoUrlValue: channel.logoUrl ?? "",
725
781
  stationIdValue: channel.stationId ?? "",
726
782
  tagsValue: effectiveTags.join(", ")
727
783
  }));
@@ -760,7 +816,7 @@ export function buildChannelTableState(listing) {
760
816
  else {
761
817
  predefined++;
762
818
  }
763
- if (!entry.enabled || !entry.availableByProvider) {
819
+ if (!entry.enabled || !entry.availableByService) {
764
820
  disabled++;
765
821
  }
766
822
  }
@@ -772,7 +828,7 @@ export function buildChannelTableState(listing) {
772
828
  };
773
829
  }
774
830
  /**
775
- * Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled, provider-available channels (the same set visible in the table).
831
+ * Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled, service-available channels (the same set visible in the table).
776
832
  * @param listing - The channel listing entries.
777
833
  * @returns An object with `enabled` (HDHR-included) and `total` counts.
778
834
  */
@@ -780,7 +836,7 @@ function getHdhrCounts(listing) {
780
836
  let enabled = 0;
781
837
  let total = 0;
782
838
  for (const entry of listing) {
783
- if (!entry.enabled || !entry.availableByProvider) {
839
+ if (!entry.enabled || !entry.availableByService) {
784
840
  continue;
785
841
  }
786
842
  total++;
@@ -791,7 +847,7 @@ function getHdhrCounts(listing) {
791
847
  return { enabled, total };
792
848
  }
793
849
  /**
794
- * Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, provider-available
850
+ * Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, service-available
795
851
  * channels have that tag in their effective tags. Returns undefined when the tags column is not visible (no tag toggles to update).
796
852
  * @param listing - The channel listing entries.
797
853
  * @returns A record mapping tag names to { count, total }, or undefined when hidden.
@@ -814,7 +870,7 @@ function getTagCounts(listing) {
814
870
  counts.set(tag, 0);
815
871
  }
816
872
  for (const entry of listing) {
817
- if (!entry.enabled || !entry.availableByProvider) {
873
+ if (!entry.enabled || !entry.availableByService) {
818
874
  continue;
819
875
  }
820
876
  total++;
@@ -857,21 +913,21 @@ export function buildChannelTablePatch(affectedKeys, profiles) {
857
913
  return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts, tagCounts: getTagCounts(listing) };
858
914
  }
859
915
  /**
860
- * Generates the provider filter toolbar HTML with a multi-select dropdown and dismissable chips.
861
- * @returns HTML string for the provider filter toolbar.
916
+ * Generates the service filter toolbar HTML with a multi-select dropdown and dismissable chips.
917
+ * @returns HTML string for the service filter toolbar.
862
918
  */
863
- export function generateProviderFilterToolbar() {
864
- const allTags = getAllProviderTags();
865
- const enabled = getEnabledProviders();
919
+ export function generateServiceFilterToolbar() {
920
+ const allTags = getAllServiceTags();
921
+ const enabled = getEnabledServices();
866
922
  const hasFilter = enabled.length > 0;
867
923
  const lines = [];
868
924
  lines.push("<div class=\"provider-toolbar\">");
869
- // Provider filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
925
+ // Service filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
870
926
  lines.push("<div class=\"toolbar-group\">");
871
927
  lines.push("<div class=\"dropdown provider-dropdown\">");
872
- const buttonText = hasFilter ? "Filtered" : "All Providers";
873
- lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-dropdown-btn\" id=\"provider-filter-btn\" " +
874
- "title=\"Filter channels by streaming provider\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " &#9662;</button>");
928
+ const buttonText = hasFilter ? "Filtered" : "All Services";
929
+ lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-icon-btn\" id=\"provider-filter-btn\" " +
930
+ "title=\"Filter channels by streaming service\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " &#9662;</button>");
875
931
  lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
876
932
  for (const tagInfo of allTags) {
877
933
  const isDirectTag = tagInfo.tag === "direct";
@@ -880,7 +936,7 @@ export function generateProviderFilterToolbar() {
880
936
  const disabledAttr = isDirectTag ? " disabled" : "";
881
937
  lines.push("<label class=\"provider-option\">");
882
938
  lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
883
- " onchange=\"toggleProviderTag(this)\"> " + providerDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
939
+ " onchange=\"toggleServiceTag(this)\"> " + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
884
940
  lines.push("</label>");
885
941
  }
886
942
  lines.push("</div>");
@@ -895,9 +951,9 @@ export function generateProviderFilterToolbar() {
895
951
  const chipTag = allTags.find((t) => t.tag === tag);
896
952
  const displayName = chipTag?.displayName ?? tag;
897
953
  lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" +
898
- providerDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
954
+ serviceDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
899
955
  "<button type=\"button\" class=\"chip-close\" title=\"Remove " + escapeHtml(displayName) + " from filter\" aria-label=\"Remove " +
900
- escapeHtml(displayName) + "\" onclick=\"removeProviderChip('" + escapeHtml(tag) + "')\">&times;</button></span>");
956
+ escapeHtml(displayName) + "\" onclick=\"removeServiceChip('" + escapeHtml(tag) + "')\">&times;</button></span>");
901
957
  }
902
958
  }
903
959
  lines.push("</div>");
@@ -916,17 +972,17 @@ export function generateProviderFilterToolbar() {
916
972
  * @returns HTML string for the Channels panel content.
917
973
  */
918
974
  export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
919
- // Get the canonical channel listing (provider variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
920
- // it handles predefined/user merging, disabled state, and provider availability.
975
+ // Get the canonical channel listing (service variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
976
+ // it handles predefined/user merging, disabled state, and service availability.
921
977
  const listing = getChannelListing();
922
978
  const profiles = getProfiles();
923
- // Count channels hidden from the default view: disabled predefined channels OR channels with no available providers.
924
- const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByProvider).length;
979
+ // Count channels hidden from the default view: disabled predefined channels OR channels with no available services.
980
+ const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByService).length;
925
981
  const lines = [];
926
982
  // Panel description.
927
983
  lines.push("<div class=\"settings-panel-description\">");
928
984
  lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
929
- 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>");
985
+ lines.push("<p class=\"description-hint\">Tip: Use the <strong>service 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>service dropdown</strong> on any multi-service 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>");
930
986
  lines.push("</div>");
931
987
  // Toolbar with three dropdown menus: Manage Channels (primary actions), Import/Export (data I/O), and Quick Actions (bulk operations). Each dropdown has an
932
988
  // inline SVG icon + label + chevron for visual discoverability. Grouped menus reduce visual clutter and separate channel management from data I/O and bulk
@@ -935,7 +991,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
935
991
  lines.push("<div class=\"toolbar-group\">");
936
992
  // Manage Channels dropdown — primary channel creation and setup actions.
937
993
  lines.push("<div class=\"dropdown\">");
938
- lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-dropdown-btn\" title=\"Add, browse, or set up channels\" " +
994
+ lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-icon-btn\" title=\"Add, browse, or set up channels\" " +
939
995
  "onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels &#9662;</button>");
940
996
  lines.push("<div class=\"dropdown-menu\">");
941
997
  // Dropdown item icons: tinted variants of the shared icons for visual differentiation in menu items. Each uses a distinct color so items are scannable at
@@ -951,32 +1007,32 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
951
1007
  const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
952
1008
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1.5 8.7V2.5a1 1 0 011-1h6.2a1 1 0 01.7.3l5.1 5.1a1 1 0 010 1.4l-5.5 5.5a1 " +
953
1009
  "1 0 01-1.4 0L1.8 9.4a1 1 0 01-.3-.7z\"/><circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>";
954
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); " +
1010
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); " +
955
1011
  "document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
956
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openBrowseModal()\">" + TINTED_BROWSE +
957
- " Browse Provider Channels</div>");
958
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openTagManager()\">" + TINTED_TAG +
1012
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openBrowseModal()\">" + TINTED_BROWSE +
1013
+ " Browse Service Channels</div>");
1014
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openTagManager()\">" + TINTED_TAG +
959
1015
  " Manage Tags</div>");
960
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openSetupWizard()\">" + TINTED_SETUP +
961
- " Provider Setup</div>");
1016
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openSetupWizard()\">" + TINTED_SETUP +
1017
+ " Service Setup</div>");
962
1018
  lines.push("</div>");
963
1019
  lines.push("</div>");
964
1020
  // Import / Export dropdown — data I/O operations.
965
1021
  lines.push("<div class=\"dropdown\">");
966
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-dropdown-btn\" title=\"Import or export channel data\" " +
1022
+ lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Import or export channel data\" " +
967
1023
  "onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export &#9662;</button>");
968
1024
  lines.push("<div class=\"dropdown-menu\">");
969
1025
  const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
970
1026
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 2v8M5 7l3 3 3-3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
971
1027
  const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
972
1028
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 10V2M5 5l3-3 3 3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
973
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); document.getElementById('import-channels-file').click()\">" +
1029
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-channels-file').click()\">" +
974
1030
  TINTED_IMPORT + " Import Channels (JSON)</div>");
975
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); document.getElementById('import-m3u-file').click()\">" +
1031
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-m3u-file').click()\">" +
976
1032
  TINTED_IMPORT + " Import M3U Playlist</div>");
977
1033
  lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
978
1034
  lines.push("<div class=\"dropdown-divider\"></div>");
979
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); exportChannels()\">" +
1035
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); exportChannels()\">" +
980
1036
  TINTED_EXPORT + " Export Channels (JSON)</div>");
981
1037
  lines.push("</div>");
982
1038
  lines.push("</div>");
@@ -985,7 +1041,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
985
1041
  const visibleCols = new Set(CONFIG.channels.visibleColumns);
986
1042
  // Quick Actions dropdown — bulk operations for predefined channels.
987
1043
  lines.push("<div class=\"dropdown quick-actions-dropdown\">");
988
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-dropdown-btn\" title=\"Bulk operations for predefined channels\" " +
1044
+ lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Bulk operations for predefined channels\" " +
989
1045
  "onclick=\"toggleDropdown(this)\">" + ICON_BOLT + " Quick Actions &#9662;</button>");
990
1046
  lines.push("<div class=\"dropdown-menu\">");
991
1047
  // Compute initial toggle counts for predefined channel scopes. The server is the single source of truth — the client renders what we return here.
@@ -1005,15 +1061,15 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1005
1061
  "\">" + String(s.count) + " of " + String(s.total) + " enabled</span></label>");
1006
1062
  }
1007
1063
  lines.push("<div class=\"dropdown-divider\"></div>");
1008
- // Bulk assign select — switch all multi-provider channels to a single provider.
1009
- const allTags = getAllProviderTags();
1010
- const enabled = getEnabledProviders();
1064
+ // Bulk assign select — switch all multi-service channels to a single service.
1065
+ const allTags = getAllServiceTags();
1066
+ const enabled = getEnabledServices();
1011
1067
  const hasFilter = enabled.length > 0;
1012
1068
  lines.push("<div class=\"bulk-assign-row\">");
1013
1069
  lines.push("<span>Set all channels to:</span>");
1014
- lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-provider channels to this provider\" " +
1015
- "onchange=\"if(this.value) { closeDropdowns(); bulkAssignProvider(this.value); this.value = ''; }\">");
1016
- lines.push("<option value=\"\">Select provider</option>");
1070
+ lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-service channels to this service\" " +
1071
+ "onchange=\"if(this.value) { dropdowns.close(); bulkAssignService(this.value); this.value = ''; }\">");
1072
+ lines.push("<option value=\"\">Select service</option>");
1017
1073
  for (const tagInfo of allTags) {
1018
1074
  const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
1019
1075
  lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\" data-provider-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + hidden + ">" +
@@ -1031,7 +1087,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1031
1087
  lines.push("<span>Auto-number from:</span>");
1032
1088
  lines.push("<input type=\"number\" id=\"auto-number-start\" class=\"auto-number-input\" value=\"1\" min=\"0\" max=\"99999\" " +
1033
1089
  "placeholder=\"Clear\" onclick=\"event.stopPropagation()\">");
1034
- lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"closeDropdowns(); autoNumberChannels()\">Apply</button>");
1090
+ lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"dropdowns.close(); autoNumberChannels()\">Apply</button>");
1035
1091
  lines.push("</div>");
1036
1092
  lines.push("</div>");
1037
1093
  // HDHR bulk toggle — tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
@@ -1051,8 +1107,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1051
1107
  const tagsVisible = visibleCols.has("tags") ? "" : " style=\"display: none;\"";
1052
1108
  const tagsVocabulary = getActiveTagVocabulary();
1053
1109
  if (tagsVocabulary.length > 0) {
1054
- // Count how many enabled, provider-available channels have each tag.
1055
- const enabledListing = listing.filter((entry) => entry.enabled && entry.availableByProvider);
1110
+ // Count how many enabled, service-available channels have each tag.
1111
+ const enabledListing = listing.filter((entry) => entry.enabled && entry.availableByService);
1056
1112
  const totalEnabled = enabledListing.length;
1057
1113
  const tagCountMap = new Map();
1058
1114
  for (const tag of tagsVocabulary) {
@@ -1131,12 +1187,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1131
1187
  lines.push("<h3>Add New Channel</h3>");
1132
1188
  lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
1133
1189
  lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
1134
- // Provider pills. Clicking a provider auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
1135
- // resolution). The pills use providerDisplaySpan for icon rendering via the shared processProviderDisplays path. Guide-grid providers fill with their
1136
- // guideUrl; non-guide-grid providers fill with https://{domain}/.
1137
- const addFormTags = getAllProviderTags();
1190
+ // Service pills. Clicking a service auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
1191
+ // resolution). The pills use serviceDisplaySpan for icon rendering via the shared processServiceDisplays path. Guide-grid services fill with their
1192
+ // guideUrl; non-guide-grid services fill with https://{domain}/.
1193
+ const addFormTags = getAllServiceTags();
1138
1194
  const addFormGuideUrls = getProviderGuideUrls();
1139
- lines.push("<div class=\"form-row\"><label>Provider</label>");
1195
+ lines.push("<div class=\"form-row\"><label>Service</label>");
1140
1196
  lines.push("<div class=\"provider-pills\">");
1141
1197
  for (const tagInfo of addFormTags) {
1142
1198
  if (tagInfo.tag === "direct") {
@@ -1144,11 +1200,11 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1144
1200
  }
1145
1201
  const pillUrl = addFormGuideUrls[tagInfo.tag] ?? (tagInfo.domain ? "https://" + tagInfo.domain + "/" : "");
1146
1202
  lines.push("<button type=\"button\" class=\"provider-pill\" data-slug=\"" + escapeHtml(tagInfo.tag) + "\" data-url=\"" + escapeHtml(pillUrl) +
1147
- "\" onclick=\"selectProviderPill(this)\">" + providerDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
1203
+ "\" onclick=\"selectServicePill(this)\">" + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
1148
1204
  }
1149
1205
  lines.push("</div>");
1150
1206
  lines.push("</div>");
1151
- lines.push("<div class=\"hint\">Select a provider to auto-fill the URL, or enter one manually below.</div>");
1207
+ lines.push("<div class=\"hint\">Select a service to auto-fill the URL, or enter one manually below.</div>");
1152
1208
  // Channel key (add form only).
1153
1209
  lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
1154
1210
  hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
@@ -1169,9 +1225,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1169
1225
  required: true,
1170
1226
  type: "url"
1171
1227
  }));
1228
+ // Inline hint for predefined channel matches. Hidden by default, shown by the URL field change listener in config.ts when the entered URL's domain matches a
1229
+ // predefined channel. This extends the form's existing URL-based intelligence (channelSelector suggestions, stationId auto-fill, profile auto-detection).
1230
+ lines.push("<div id=\"add-predefined-hint\" class=\"hint predefined-hint\" style=\"display: none;\"></div>");
1172
1231
  // Profile dropdown.
1173
1232
  lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
1174
- // Advanced fields (station ID, channel selector, channel number, HDHR).
1233
+ // Advanced fields (station ID, guide title, logo URL, channel selector, channel number, tags, HDHR).
1175
1234
  lines.push(...generateAdvancedFields("add", {
1176
1235
  channelNumberValue: formValues?.get("channelNumber") ?? "",
1177
1236
  channelSelectorValue: formValues?.get("channelSelector") ?? "",
@@ -1197,8 +1256,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1197
1256
  }
1198
1257
  const sortField = CONFIG.channels.channelSortField;
1199
1258
  const sortDir = CONFIG.channels.channelSortDirection;
1200
- // Provider filter toolbar with multi-select dropdown and chips.
1201
- lines.push(generateProviderFilterToolbar());
1259
+ // Service filter toolbar with multi-select dropdown and chips.
1260
+ lines.push(generateServiceFilterToolbar());
1202
1261
  // Channel summary line with predefined/user breakdown, placed directly above the table for visual proximity. The user-count span contains the entire user
1203
1262
  // portion (comma, count, and label) so the client can toggle it by setting textContent. When there are no user channels, the span is empty.
1204
1263
  const userPortion = (userCount > 0) ? ", " + String(userCount) + " user" : "";
@@ -1216,7 +1275,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1216
1275
  const sortableHeaders = [
1217
1276
  { cssClass: "col-key", field: "key", label: "Key" },
1218
1277
  { cssClass: "col-name", field: "name", label: "Name" },
1219
- { cssClass: "col-provider", field: "provider", label: "Provider" },
1278
+ { cssClass: "col-provider", field: "provider", label: "Service" },
1220
1279
  ...OPTIONAL_COLUMNS
1221
1280
  ];
1222
1281
  // All sortable headers use the same DOM structure: <th onclick> wraps a <span class="sort-label"> for the label text. The sort update logic targets
@@ -1226,7 +1285,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1226
1285
  const isActive = (sortField === hdr.field);
1227
1286
  const activeIndicator = isActive ? ((sortDir === "asc") ? " &#9650;" : " &#9660;") : "";
1228
1287
  lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
1229
- "\" onclick=\"sortChannelTable('" + hdr.field + "')\">");
1288
+ "\" onclick=\"channelTable.sort('" + hdr.field + "')\">");
1230
1289
  lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
1231
1290
  // Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
1232
1291
  // shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() — the single source of truth
@@ -1269,7 +1328,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1269
1328
  lines.push("</thead>");
1270
1329
  lines.push("<tbody>");
1271
1330
  // Sort the listing by the user's preferred field and direction before rendering rows. The canonical getChannelListing() order is preserved for other callers.
1272
- const sortedListing = [...listing].sort((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
1331
+ const sortedListing = listing.toSorted((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
1273
1332
  // Generate rows for all channels using the shared row generator.
1274
1333
  for (const entry of sortedListing) {
1275
1334
  const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
@@ -1282,10 +1341,10 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1282
1341
  // Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
1283
1342
  // multi-channel site like Disney+ or USA Network.
1284
1343
  lines.push("<script>" + generateChannelSelectorData() + "</script>");
1285
- // Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (provider picker, channel list) is rendered
1286
- // client-side after fetching discovered channels from the provider endpoint.
1344
+ // Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (service picker, channel list) is rendered
1345
+ // client-side after fetching discovered channels from the services endpoint.
1287
1346
  lines.push(generateBrowseModal());
1288
- // Provider Setup wizard modal. Follows the same wizard pattern as the provider profile builder. Three steps: pick providers, authenticate, browse channels.
1347
+ // Service Setup wizard modal. Follows the same wizard pattern as the service profile builder. Three steps: pick services, authenticate, browse channels.
1289
1348
  // The setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
1290
1349
  lines.push(generateSetupWizardModal());
1291
1350
  // Tag Management modal. A simple dialog for creating, deleting, and restoring organizational tags. Client-side handlers drive the CRUD operations via the