prismcast 1.2.0 → 1.3.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 (129) hide show
  1. package/README.md +26 -1
  2. package/dist/app.js +29 -2
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.js +1 -1
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +14 -4
  7. package/dist/browser/channelSelection.js +48 -704
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/index.d.ts +18 -4
  10. package/dist/browser/index.js +205 -39
  11. package/dist/browser/index.js.map +1 -1
  12. package/dist/browser/tuning/fox.d.ts +19 -0
  13. package/dist/browser/tuning/fox.js +61 -0
  14. package/dist/browser/tuning/fox.js.map +1 -0
  15. package/dist/browser/tuning/hbo.d.ts +22 -0
  16. package/dist/browser/tuning/hbo.js +182 -0
  17. package/dist/browser/tuning/hbo.js.map +1 -0
  18. package/dist/browser/tuning/hulu.d.ts +30 -0
  19. package/dist/browser/tuning/hulu.js +514 -0
  20. package/dist/browser/tuning/hulu.js.map +1 -0
  21. package/dist/browser/tuning/sling.d.ts +22 -0
  22. package/dist/browser/tuning/sling.js +287 -0
  23. package/dist/browser/tuning/sling.js.map +1 -0
  24. package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
  25. package/dist/browser/tuning/thumbnailRow.js +107 -0
  26. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  27. package/dist/browser/tuning/tileClick.d.ts +18 -0
  28. package/dist/browser/tuning/tileClick.js +102 -0
  29. package/dist/browser/tuning/tileClick.js.map +1 -0
  30. package/dist/browser/tuning/youtubeTv.d.ts +17 -0
  31. package/dist/browser/tuning/youtubeTv.js +99 -0
  32. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  33. package/dist/browser/video.d.ts +2 -2
  34. package/dist/browser/video.js +18 -8
  35. package/dist/browser/video.js.map +1 -1
  36. package/dist/channels/index.js +131 -12
  37. package/dist/channels/index.js.map +1 -1
  38. package/dist/config/profiles.d.ts +5 -5
  39. package/dist/config/profiles.js +35 -11
  40. package/dist/config/profiles.js.map +1 -1
  41. package/dist/config/providers.d.ts +48 -4
  42. package/dist/config/providers.js +182 -22
  43. package/dist/config/providers.js.map +1 -1
  44. package/dist/config/sites.d.ts +10 -0
  45. package/dist/config/sites.js +96 -17
  46. package/dist/config/sites.js.map +1 -1
  47. package/dist/config/userChannels.js +21 -3
  48. package/dist/config/userChannels.js.map +1 -1
  49. package/dist/config/userConfig.d.ts +2 -1
  50. package/dist/config/userConfig.js +12 -4
  51. package/dist/config/userConfig.js.map +1 -1
  52. package/dist/index.js +10 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/routes/auth.js +6 -0
  55. package/dist/routes/auth.js.map +1 -1
  56. package/dist/routes/config.d.ts +5 -0
  57. package/dist/routes/config.js +273 -35
  58. package/dist/routes/config.js.map +1 -1
  59. package/dist/routes/debug.d.ts +6 -0
  60. package/dist/routes/debug.js +274 -0
  61. package/dist/routes/debug.js.map +1 -0
  62. package/dist/routes/index.d.ts +1 -0
  63. package/dist/routes/index.js +3 -0
  64. package/dist/routes/index.js.map +1 -1
  65. package/dist/routes/logs.js +17 -5
  66. package/dist/routes/logs.js.map +1 -1
  67. package/dist/routes/root.js +507 -128
  68. package/dist/routes/root.js.map +1 -1
  69. package/dist/routes/theme.js +7 -0
  70. package/dist/routes/theme.js.map +1 -1
  71. package/dist/service/commands.js +8 -8
  72. package/dist/service/commands.js.map +1 -1
  73. package/dist/service/generators.js +22 -16
  74. package/dist/service/generators.js.map +1 -1
  75. package/dist/streaming/fmp4Segmenter.d.ts +7 -0
  76. package/dist/streaming/fmp4Segmenter.js +124 -15
  77. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  78. package/dist/streaming/hls.d.ts +74 -6
  79. package/dist/streaming/hls.js +212 -130
  80. package/dist/streaming/hls.js.map +1 -1
  81. package/dist/streaming/hlsSegments.js +11 -4
  82. package/dist/streaming/hlsSegments.js.map +1 -1
  83. package/dist/streaming/lifecycle.js +1 -1
  84. package/dist/streaming/lifecycle.js.map +1 -1
  85. package/dist/streaming/monitor.d.ts +1 -1
  86. package/dist/streaming/monitor.js +202 -53
  87. package/dist/streaming/monitor.js.map +1 -1
  88. package/dist/streaming/mp4Parser.d.ts +29 -1
  89. package/dist/streaming/mp4Parser.js +243 -18
  90. package/dist/streaming/mp4Parser.js.map +1 -1
  91. package/dist/streaming/mpegts.d.ts +5 -2
  92. package/dist/streaming/mpegts.js +122 -29
  93. package/dist/streaming/mpegts.js.map +1 -1
  94. package/dist/streaming/registry.d.ts +1 -1
  95. package/dist/streaming/registry.js.map +1 -1
  96. package/dist/streaming/setup.d.ts +13 -1
  97. package/dist/streaming/setup.js +136 -36
  98. package/dist/streaming/setup.js.map +1 -1
  99. package/dist/streaming/showInfo.js +9 -9
  100. package/dist/streaming/showInfo.js.map +1 -1
  101. package/dist/streaming/statusEmitter.js +5 -6
  102. package/dist/streaming/statusEmitter.js.map +1 -1
  103. package/dist/types/index.d.ts +16 -3
  104. package/dist/types/index.js +5 -1
  105. package/dist/types/index.js.map +1 -1
  106. package/dist/utils/debugFilter.d.ts +38 -0
  107. package/dist/utils/debugFilter.js +157 -0
  108. package/dist/utils/debugFilter.js.map +1 -0
  109. package/dist/utils/evaluate.js.map +1 -1
  110. package/dist/utils/ffmpeg.d.ts +5 -0
  111. package/dist/utils/ffmpeg.js +13 -2
  112. package/dist/utils/ffmpeg.js.map +1 -1
  113. package/dist/utils/fileLogger.d.ts +2 -1
  114. package/dist/utils/fileLogger.js +4 -2
  115. package/dist/utils/fileLogger.js.map +1 -1
  116. package/dist/utils/index.d.ts +1 -0
  117. package/dist/utils/index.js +1 -0
  118. package/dist/utils/index.js.map +1 -1
  119. package/dist/utils/logEmitter.d.ts +1 -0
  120. package/dist/utils/logEmitter.js.map +1 -1
  121. package/dist/utils/logger.d.ts +9 -8
  122. package/dist/utils/logger.js +52 -28
  123. package/dist/utils/logger.js.map +1 -1
  124. package/dist/utils/retry.d.ts +1 -1
  125. package/dist/utils/retry.js +3 -3
  126. package/dist/utils/retry.js.map +1 -1
  127. package/dist/utils/version.js +4 -9
  128. package/dist/utils/version.js.map +1 -1
  129. package/package.json +6 -6
@@ -1,8 +1,8 @@
1
1
  import { CONFIG, getDefaults, validatePositiveInt, validatePositiveNumber } from "../config/index.js";
2
2
  import { CONFIG_METADATA, filterDefaults, getAdvancedSections, getConfigFilePath, getEnvOverrides, getNestedValue, getSettingsTabSections, getUITabs, isEqualToDefault, loadUserConfig, saveUserConfig, setNestedValue } from "../config/userConfig.js";
3
3
  import { LOG, escapeHtml, formatError, generateChannelKey, isRunningAsService, parseM3U } from "../utils/index.js";
4
- import { getCanonicalKey, getProviderDisplayName, getProviderGroup, getProviderSelection, getResolvedChannel, hasMultipleProviders, isProviderVariant, resolveProviderKey, setProviderSelection } from "../config/providers.js";
5
- import { getChannelsParseErrorMessage, getDisabledPredefinedChannels, getPredefinedChannels, getUserChannels, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, loadUserChannels, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../config/userChannels.js";
4
+ import { getAllProviderTags, getCanonicalKey, getChannelProviderTags, getEnabledProviders, getProviderDisplayName, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../config/providers.js";
5
+ import { getChannelListing, getChannelsParseErrorMessage, getDisabledPredefinedChannels, getPredefinedChannels, getUserChannels, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, loadUserChannels, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../config/userChannels.js";
6
6
  import { PREDEFINED_CHANNELS } from "../channels/index.js";
7
7
  import { closeBrowser } from "../browser/index.js";
8
8
  import { getPresetOptionsWithDegradation } from "../config/presets.js";
@@ -39,10 +39,9 @@ function scheduleServerRestart(reason) {
39
39
  };
40
40
  }
41
41
  // No active streams - restart immediately. Close the browser first to avoid orphan Chrome processes.
42
- setTimeout(async () => {
42
+ setTimeout(() => {
43
43
  LOG.info("Exiting for service manager restart %s.", reason);
44
- await closeBrowser();
45
- process.exit(0);
44
+ void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
46
45
  }, 500);
47
46
  return {
48
47
  activeStreams: 0,
@@ -226,6 +225,35 @@ function generateProfileReference(profiles) {
226
225
  lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
227
226
  }
228
227
  lines.push("</dl>");
228
+ // Per-strategy guidance for finding Channel Selector values. Organized by strategy type since the same strategy can be used across multiple profiles.
229
+ lines.push("<h4 class=\"selector-guide-heading\">Finding Your Channel Selector</h4>");
230
+ lines.push("<p class=\"category-desc\">Predefined channels already have Channel Selector values set. For custom channels, the value depends on the ");
231
+ lines.push("profile's strategy type:</p>");
232
+ lines.push("<dl class=\"profile-list\">");
233
+ lines.push("<dt>apiMultiVideo, keyboardDynamicMultiVideo (image URL)</dt>");
234
+ lines.push("<dd>Right-click the channel's image on the site \u2192 Inspect Element \u2192 find the &lt;img&gt; tag \u2192 copy a unique portion ");
235
+ lines.push("of the <code>src</code> URL that identifies the channel (e.g., \"espn\" from a URL containing \"poster_linear_espn_none\").</dd>");
236
+ lines.push("<dt>foxLive (station code)</dt>");
237
+ lines.push("<dd>Inspect a channel logo in the guide \u2192 find the <code>&lt;button&gt;</code> inside <code>GuideChannelLogo</code> \u2192 use ");
238
+ lines.push("the <code>title</code> attribute value (e.g., BTN, FOXD2C, FS1, FS2, FWX).</dd>");
239
+ lines.push("<dt>hboMax (channel name)</dt>");
240
+ 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 ");
241
+ lines.push("content (e.g., HBO, HBO Comedy, HBO Drama, HBO Hits, HBO Movies).</dd>");
242
+ lines.push("<dt>huluLive (channel name)</dt>");
243
+ lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with ");
244
+ lines.push("<code>live-guide-channel-kyber-</code> \u2192 use the portion after that prefix. The name may differ from the logo shown ");
245
+ lines.push("(e.g., the full name rather than an abbreviation). For local affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast ");
246
+ lines.push("resolves the local station automatically.</dd>");
247
+ lines.push("<dt>slingLive (channel name)</dt>");
248
+ lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with <code>channel-</code> ");
249
+ 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 ");
250
+ lines.push("affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast resolves the local station automatically.</dd>");
251
+ lines.push("<dt>youtubeTV (channel name)</dt>");
252
+ lines.push("<dd>Inspect a channel thumbnail in the guide \u2192 find the <code>aria-label</code> attribute on the ");
253
+ lines.push("<code>ytu-endpoint</code> element \u2192 use the name after \"watch \" (e.g., <code>aria-label=\"watch CNN\"</code> \u2192 CNN). ");
254
+ lines.push("For locals, use the network name (e.g., NBC) \u2014 affiliates like \"NBC 5\" are resolved automatically. PBS resolves to the ");
255
+ lines.push("local affiliate in major markets.</dd>");
256
+ lines.push("</dl>");
229
257
  lines.push("</div>");
230
258
  }
231
259
  lines.push("</div>");
@@ -253,7 +281,8 @@ function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue,
253
281
  // Channel selector.
254
282
  const channelSelectorHint = showHints ?
255
283
  "Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
256
- "For other sites, right-click a channel image → Inspect → copy a unique portion of the image src URL." :
284
+ "For guide-based profiles (Fox, HBO Max, Hulu, Sling, YouTube TV), use the channel name or station code from the guide. " +
285
+ "For image-based profiles, right-click a channel image \u2192 Inspect \u2192 copy a unique portion of the image src URL." :
257
286
  undefined;
258
287
  lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
259
288
  // Channel number for HDHomeRun/Plex integration.
@@ -309,9 +338,12 @@ export function generateChannelRowHtml(key, profiles) {
309
338
  const isUser = isUserChannel(key);
310
339
  const isPredefined = isPredefinedChannel(key);
311
340
  const isDisabled = isPredefinedChannelDisabled(key);
341
+ const isAvailableByProvider = isChannelAvailableByProvider(key);
312
342
  // Check if this channel has multiple providers.
313
343
  const providerGroup = getProviderGroup(key);
314
- // Generate display row. User channels get one CSS class, disabled predefined get another.
344
+ // Build the provider tags data attribute for client-side filtering.
345
+ const providerTags = getChannelProviderTags(key).join(",");
346
+ // Generate display row. User channels get one CSS class, disabled predefined get another, provider-filtered get a third.
315
347
  const displayLines = [];
316
348
  const rowClasses = [];
317
349
  if (isUser) {
@@ -320,25 +352,39 @@ export function generateChannelRowHtml(key, profiles) {
320
352
  if (isDisabled) {
321
353
  rowClasses.push("channel-disabled");
322
354
  }
355
+ if (!isAvailableByProvider) {
356
+ rowClasses.push("channel-unavailable");
357
+ }
323
358
  const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
324
- displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + ">");
359
+ displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) + "\">");
325
360
  displayLines.push("<td><code>" + escapeHtml(key) + "</code></td>");
326
361
  displayLines.push("<td>" + escapeHtml(channel.name ?? key) + "</td>");
327
- // Source column: dropdown for multi-provider channels, static domain text for single-provider.
362
+ // Source column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers" label
363
+ // alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
328
364
  displayLines.push("<td>");
365
+ const labelHidden = isAvailableByProvider ? " style=\"display:none\"" : "";
366
+ const contentHidden = isAvailableByProvider ? "" : " style=\"display:none\"";
367
+ displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available providers</em>");
329
368
  if (hasMultipleProviders(key) && providerGroup) {
330
- // Multi-provider: show dropdown.
369
+ // Multi-provider: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the provider selection changes. Filtered-out
370
+ // options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls filterChannelRows()
371
+ // to remove them from the DOM.
331
372
  const currentSelection = getProviderSelection(key) ?? key;
332
- displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) + "\" onchange=\"updateProviderSelection(this)\">");
373
+ displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) + "\" onchange=\"updateProviderSelection(this)\"" +
374
+ contentHidden + ">");
333
375
  for (const variant of providerGroup.variants) {
334
376
  const selected = (variant.key === currentSelection) ? " selected" : "";
335
- displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\"" + selected + ">" + escapeHtml(variant.label) + "</option>");
377
+ const tag = getProviderTagForChannel(variant.key);
378
+ const optionHidden = !isProviderTagEnabled(tag) ? " hidden" : "";
379
+ displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(tag) + "\"" + selected + optionHidden + ">" +
380
+ escapeHtml(variant.label) + "</option>");
336
381
  }
337
382
  displayLines.push("</select>");
338
383
  }
339
384
  else {
340
- // Single-provider: show provider name if set, otherwise the domain.
341
- displayLines.push(escapeHtml(channel.provider ?? getProviderDisplayName(channel.url)));
385
+ // Single-provider: wrap the provider name in a span so client-side JS can toggle it with the no-provider label.
386
+ displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
387
+ escapeHtml(channel.provider ?? getProviderDisplayName(channel.url)) + "</span>");
342
388
  }
343
389
  displayLines.push("</td>");
344
390
  displayLines.push("<td>" + (displayChannel.profile ? escapeHtml(displayChannel.profile) : "<em>auto</em>") + "</td>");
@@ -424,6 +470,10 @@ function formatValueForDisplay(value, settingType) {
424
470
  }
425
471
  return String(value);
426
472
  }
473
+ if (typeof value === "string") {
474
+ return value;
475
+ }
476
+ // Config values are always primitives (string, number, boolean). Numbers and strings are handled above.
427
477
  return String(value);
428
478
  }
429
479
  /**
@@ -651,7 +701,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
651
701
  }
652
702
  else {
653
703
  // Standard dropdown for non-preset fields.
654
- for (const validValue of setting.validValues) {
704
+ for (const validValue of setting.validValues ?? []) {
655
705
  // For boolean types, compare string validValue with stringified currentValue to handle boolean-to-string comparison.
656
706
  const isSelected = (setting.type === "boolean") ?
657
707
  (validValue === String(currentValue)) :
@@ -666,7 +716,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
666
716
  // Render boolean as a checkbox. A hidden input with value "false" precedes the checkbox so that unchecking submits "false" rather than omitting the field
667
717
  // entirely (which would cause the server to skip it and fall back to the default).
668
718
  const isChecked = (currentValue === true) || (currentValue === "true");
669
- const defaultStr = String(defaultValue ?? false);
719
+ const defaultStr = defaultValue ? "true" : "false";
670
720
  lines.push("<input type=\"hidden\" name=\"" + setting.path + "\" value=\"false\">");
671
721
  const checkboxAttrs = [
672
722
  "class=\"form-checkbox\"",
@@ -760,7 +810,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
760
810
  defaultDisplay = formatValueForDisplay(displayDefault, setting.type);
761
811
  }
762
812
  else {
763
- defaultDisplay = String(displayDefault);
813
+ defaultDisplay = displayDefault;
764
814
  }
765
815
  // Format the unit with correct pluralization based on the default value.
766
816
  let formattedUnit = "";
@@ -880,6 +930,68 @@ function parseFormValue(setting, value) {
880
930
  }
881
931
  }
882
932
  }
933
+ /**
934
+ * Generates the provider filter toolbar HTML with a multi-select dropdown, dismissable chips, and a bulk-assign dropdown.
935
+ * @returns HTML string for the provider filter toolbar.
936
+ */
937
+ export function generateProviderFilterToolbar() {
938
+ const allTags = getAllProviderTags();
939
+ const enabled = getEnabledProviders();
940
+ const hasFilter = enabled.length > 0;
941
+ const lines = [];
942
+ lines.push("<div class=\"provider-toolbar\">");
943
+ // Left group: Provider filter dropdown and chips.
944
+ lines.push("<div class=\"toolbar-group\">");
945
+ lines.push("<span class=\"toolbar-label\">Providers:</span>");
946
+ lines.push("<div class=\"dropdown provider-dropdown\">");
947
+ const buttonText = hasFilter ? "Filtered" : "All Providers";
948
+ lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\" onclick=\"toggleDropdown(this)\">" + buttonText + " &#9662;</button>");
949
+ lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
950
+ for (const tagInfo of allTags) {
951
+ const isDirectTag = tagInfo.tag === "direct";
952
+ const isChecked = isDirectTag || !hasFilter || enabled.includes(tagInfo.tag);
953
+ const checkedAttr = isChecked ? " checked" : "";
954
+ const disabledAttr = isDirectTag ? " disabled" : "";
955
+ lines.push("<label class=\"provider-option\">");
956
+ lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
957
+ " onchange=\"toggleProviderTag(this)\"> " + escapeHtml(tagInfo.displayName));
958
+ lines.push("</label>");
959
+ }
960
+ lines.push("</div>");
961
+ lines.push("</div>");
962
+ // Chips container for active filter tags.
963
+ lines.push("<div class=\"provider-chips\" id=\"provider-chips\">");
964
+ if (hasFilter) {
965
+ for (const tag of enabled) {
966
+ if (tag === "direct") {
967
+ continue;
968
+ }
969
+ const displayName = allTags.find((t) => t.tag === tag)?.displayName ?? tag;
970
+ lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" + escapeHtml(displayName) +
971
+ "<button type=\"button\" class=\"chip-close\" onclick=\"removeProviderChip('" + escapeHtml(tag) + "')\">&times;</button></span>");
972
+ }
973
+ }
974
+ lines.push("</div>");
975
+ lines.push("</div>");
976
+ // Spacer.
977
+ lines.push("<div class=\"toolbar-spacer\"></div>");
978
+ // Right group: Bulk assign dropdown.
979
+ lines.push("<div class=\"toolbar-group\">");
980
+ lines.push("<span class=\"toolbar-label\">Set all channels to:</span>");
981
+ lines.push("<select class=\"form-select bulk-assign-select\" id=\"bulk-assign\" onchange=\"bulkAssignProvider(this)\">");
982
+ lines.push("<option value=\"\" disabled selected>Choose provider...</option>");
983
+ // Render all provider options so the client-side _allOptions snapshot captures them for reinsertion when the filter changes. Filtered-out options get the hidden
984
+ // attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls updateBulkAssignOptions() to remove them
985
+ // from the DOM.
986
+ for (const tagInfo of allTags) {
987
+ const optionHidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
988
+ lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\"" + optionHidden + ">" + escapeHtml(tagInfo.displayName) + "</option>");
989
+ }
990
+ lines.push("</select>");
991
+ lines.push("</div>");
992
+ lines.push("</div>");
993
+ return lines.join("\n");
994
+ }
883
995
  /**
884
996
  * Generates the Channels panel HTML content.
885
997
  * @param channelMessage - Optional message to display (success or error).
@@ -891,18 +1003,15 @@ function parseFormValue(setting, value) {
891
1003
  * @returns HTML string for the Channels panel content.
892
1004
  */
893
1005
  export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
894
- // Get all channels including disabled predefined ones. User channels override predefined. Filter out provider variants — only canonical keys are shown in the UI.
895
- const userChannels = getUserChannels();
896
- const predefinedChannels = getPredefinedChannels();
897
- const allChannelKeys = new Set([...Object.keys(predefinedChannels), ...Object.keys(userChannels)]);
898
- const channelKeys = [...allChannelKeys].filter((key) => !isProviderVariant(key)).sort();
1006
+ // Get the canonical channel listing (provider variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
1007
+ // it handles predefined/user merging, disabled state, and provider availability.
1008
+ const listing = getChannelListing();
899
1009
  const profiles = getProfiles();
900
1010
  const disabledPredefined = getDisabledPredefinedChannels();
901
- const predefinedCount = Object.keys(predefinedChannels).length;
1011
+ const predefinedCount = Object.keys(getPredefinedChannels()).length;
902
1012
  const allDisabled = disabledPredefined.length === predefinedCount;
903
- // Count predefined channels that are actually disabled in the DOM — excludes those overridden by user channels, since overridden channels render as user channel
904
- // rows rather than disabled predefined rows.
905
- const visibleDisabledCount = disabledPredefined.filter((key) => !isUserChannel(key)).length;
1013
+ // Count channels hidden from the default view: disabled predefined channels OR channels with no available providers.
1014
+ const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByProvider).length;
906
1015
  const lines = [];
907
1016
  // Panel description.
908
1017
  lines.push("<div class=\"settings-panel-description\">");
@@ -936,7 +1045,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
936
1045
  else {
937
1046
  lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" id=\"bulk-toggle-btn\" ", "onclick=\"toggleAllPredefined(false)\">Disable All Predefined</button>");
938
1047
  }
939
- lines.push("<label class=\"toggle-label\"><input type=\"checkbox\" id=\"show-disabled-toggle\" onchange=\"toggleDisabledVisibility()\"> ", "Show disabled (<span id=\"disabled-count\">" + String(visibleDisabledCount) + "</span>)</label>");
1048
+ 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>");
940
1049
  lines.push("</div>");
941
1050
  lines.push("</div>");
942
1051
  // Show channels file parse error if applicable.
@@ -1010,6 +1119,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1010
1119
  lines.push("</div>");
1011
1120
  lines.push("</form>");
1012
1121
  lines.push("</div>"); // End add-channel-form.
1122
+ // 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.
1123
+ lines.push(generateProviderFilterToolbar());
1013
1124
  // Profile reference section (hidden by default, toggled via link in profile dropdown hint).
1014
1125
  lines.push(generateProfileReference(profiles));
1015
1126
  // Channels table. Disabled predefined channels are hidden by default and revealed via the "Show disabled" toggle. The wrapper div enables horizontal scrolling on
@@ -1027,8 +1138,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1027
1138
  lines.push("</thead>");
1028
1139
  lines.push("<tbody>");
1029
1140
  // Generate rows for all channels using the shared row generator.
1030
- for (const key of channelKeys) {
1031
- const rowHtml = generateChannelRowHtml(key, profiles);
1141
+ for (const entry of listing) {
1142
+ const rowHtml = generateChannelRowHtml(entry.key, profiles);
1032
1143
  lines.push(rowHtml.displayRow);
1033
1144
  if (rowHtml.editRow) {
1034
1145
  lines.push(rowHtml.editRow);
@@ -1294,10 +1405,9 @@ export function setupConfigEndpoint(app) {
1294
1405
  LOG.info("Forced restart requested via API.");
1295
1406
  res.json({ message: "Server is restarting...", success: true });
1296
1407
  // Close the browser first to avoid orphan Chrome processes.
1297
- setTimeout(async () => {
1408
+ setTimeout(() => {
1298
1409
  LOG.info("Exiting for forced service manager restart.");
1299
- await closeBrowser();
1300
- process.exit(0);
1410
+ void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
1301
1411
  }, 500);
1302
1412
  });
1303
1413
  // GET /config/channels/export - Export user channels as JSON.
@@ -1328,7 +1438,7 @@ export function setupConfigEndpoint(app) {
1328
1438
  await saveUserChannels(validationResult.channels);
1329
1439
  const channelCount = Object.keys(validationResult.channels).length;
1330
1440
  // Send success response. Changes take effect immediately due to hot-reloading in saveUserChannels().
1331
- res.json({ message: "Imported " + channelCount + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
1441
+ res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
1332
1442
  }
1333
1443
  catch (error) {
1334
1444
  LOG.error("Failed to import channels: %s", formatError(error));
@@ -1574,6 +1684,129 @@ export function setupConfigEndpoint(app) {
1574
1684
  res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
1575
1685
  }
1576
1686
  });
1687
+ // POST /config/provider-filter - Update the provider filter (enabled provider tags).
1688
+ app.post("/config/provider-filter", async (req, res) => {
1689
+ try {
1690
+ const body = req.body;
1691
+ const tags = body.enabledProviders;
1692
+ // Validate tags is an array.
1693
+ if (!Array.isArray(tags)) {
1694
+ res.status(400).json({ error: "enabledProviders must be an array.", success: false });
1695
+ return;
1696
+ }
1697
+ // Validate all tags are known.
1698
+ const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
1699
+ for (const tag of tags) {
1700
+ if (!knownTags.has(tag)) {
1701
+ res.status(400).json({ error: "Unknown provider tag: " + tag, success: false });
1702
+ return;
1703
+ }
1704
+ }
1705
+ // Update module-level state.
1706
+ setEnabledProviders(tags);
1707
+ // Update runtime CONFIG.
1708
+ CONFIG.channels.enabledProviders = [...tags];
1709
+ // Save to config file.
1710
+ const configResult = await loadUserConfig();
1711
+ const userConfig = configResult.config;
1712
+ userConfig.channels ??= {};
1713
+ userConfig.channels.enabledProviders = tags;
1714
+ await saveUserConfig(filterDefaults(userConfig));
1715
+ LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
1716
+ res.json({ enabledProviders: tags, success: true });
1717
+ }
1718
+ catch (error) {
1719
+ LOG.error("Failed to update provider filter: %s", formatError(error));
1720
+ res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
1721
+ }
1722
+ });
1723
+ // POST /config/provider-bulk-assign - Set all channels to a specific provider.
1724
+ app.post("/config/provider-bulk-assign", async (req, res) => {
1725
+ try {
1726
+ const body = req.body;
1727
+ const providerTag = body.provider?.trim();
1728
+ // Validate provider tag.
1729
+ if (!providerTag) {
1730
+ res.status(400).json({ error: "Provider tag is required.", success: false });
1731
+ return;
1732
+ }
1733
+ let affected = 0;
1734
+ const previousSelections = {};
1735
+ const selections = {};
1736
+ // Iterate all channels and set those with a matching variant.
1737
+ const listing = getChannelListing();
1738
+ for (const entry of listing) {
1739
+ const group = getProviderGroup(entry.key);
1740
+ if (!group || (group.variants.length <= 1)) {
1741
+ continue;
1742
+ }
1743
+ // Find a variant matching the requested provider tag.
1744
+ const matchingVariant = group.variants.find((v) => (getProviderTagForChannel(v.key) === providerTag));
1745
+ if (matchingVariant) {
1746
+ // Snapshot the current selection before overwriting so the client can offer undo.
1747
+ const currentVariant = getProviderSelection(entry.key);
1748
+ previousSelections[entry.key] = currentVariant ?? null;
1749
+ setProviderSelection(entry.key, matchingVariant.key);
1750
+ affected++;
1751
+ // Collect the resolved profile name for client-side UI update.
1752
+ const resolvedChannel = getResolvedChannel(matchingVariant.key);
1753
+ selections[entry.key] = { profile: resolvedChannel?.profile ?? null, variant: matchingVariant.key };
1754
+ }
1755
+ }
1756
+ // Save to disk.
1757
+ await saveProviderSelections();
1758
+ LOG.info("Bulk assign to '%s': %d of %d channels affected.", providerTag, affected, listing.length);
1759
+ res.json({ affected, previousSelections, selections, success: true, total: listing.length });
1760
+ }
1761
+ catch (error) {
1762
+ LOG.error("Failed to bulk assign provider: %s", formatError(error));
1763
+ res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
1764
+ }
1765
+ });
1766
+ // POST /config/provider-bulk-restore - Restore previous provider selections (undo bulk assign).
1767
+ app.post("/config/provider-bulk-restore", async (req, res) => {
1768
+ try {
1769
+ const body = req.body;
1770
+ const previousSelections = body.selections;
1771
+ if (!previousSelections || (typeof previousSelections !== "object")) {
1772
+ res.status(400).json({ error: "Selections map is required.", success: false });
1773
+ return;
1774
+ }
1775
+ let restored = 0;
1776
+ const selections = {};
1777
+ for (const [key, variantKey] of Object.entries(previousSelections)) {
1778
+ const group = getProviderGroup(key);
1779
+ if (!group) {
1780
+ continue;
1781
+ }
1782
+ // A null value means the channel was using the default (canonical) selection. Restoring by setting the selection to the canonical key clears the override.
1783
+ if (variantKey === null) {
1784
+ setProviderSelection(key, key);
1785
+ }
1786
+ else {
1787
+ // Validate the variant belongs to this channel's provider group before restoring.
1788
+ const isValid = group.variants.some((v) => (v.key === variantKey));
1789
+ if (!isValid) {
1790
+ continue;
1791
+ }
1792
+ setProviderSelection(key, variantKey);
1793
+ }
1794
+ restored++;
1795
+ // Build the same selection response format as bulk assign for client-side UI updates.
1796
+ const effectiveKey = variantKey ?? key;
1797
+ const resolvedChannel = getResolvedChannel(effectiveKey);
1798
+ selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
1799
+ }
1800
+ // Save to disk.
1801
+ await saveProviderSelections();
1802
+ LOG.info("Bulk restore: %d channel(s) reverted.", restored);
1803
+ res.json({ restored, selections, success: true });
1804
+ }
1805
+ catch (error) {
1806
+ LOG.error("Failed to bulk restore providers: %s", formatError(error));
1807
+ res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
1808
+ }
1809
+ });
1577
1810
  // POST /config/channels - Handle channel add, edit, delete operations. Returns JSON response.
1578
1811
  app.post("/config/channels", async (req, res) => {
1579
1812
  try {
@@ -1597,7 +1830,7 @@ export function setupConfigEndpoint(app) {
1597
1830
  res.status(400).json({ message: "Cannot delete channel: channels file contains invalid JSON.", success: false });
1598
1831
  return;
1599
1832
  }
1600
- delete result.channels[key];
1833
+ Reflect.deleteProperty(result.channels, key);
1601
1834
  await saveUserChannels(result.channels);
1602
1835
  LOG.info("User channel '%s' deleted.", key);
1603
1836
  // 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
@@ -1612,6 +1845,11 @@ export function setupConfigEndpoint(app) {
1612
1845
  res.status(400).json({ message: "Invalid channel action.", success: false });
1613
1846
  return;
1614
1847
  }
1848
+ // Key is required for both add and edit actions.
1849
+ if (!key) {
1850
+ res.status(400).json({ message: "Channel key is required.", success: false });
1851
+ return;
1852
+ }
1615
1853
  // Validate channel fields.
1616
1854
  const formErrors = {};
1617
1855
  // Collect form values.
@@ -1640,7 +1878,7 @@ export function setupConfigEndpoint(app) {
1640
1878
  }
1641
1879
  // Validate key (only for add action, not edit).
1642
1880
  if (action === "add") {
1643
- const keyError = validateChannelKey(key ?? "", true);
1881
+ const keyError = validateChannelKey(key, true);
1644
1882
  if (keyError) {
1645
1883
  formErrors.key = keyError;
1646
1884
  }