prismcast 1.4.0 → 1.4.2

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.
@@ -1,14 +1,52 @@
1
1
  import { CONFIG, getDefaults, validatePositiveInt, validatePositiveNumber } from "../config/index.js";
2
2
  import { CONFIG_METADATA, filterDefaults, getAdvancedSections, 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 { 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";
4
+ import { getAllProviderTags, getCanonicalKey, getChannelProviderTags, getEnabledProviders, getProviderDisplayName, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../config/providers.js";
5
+ import { getChannelListing, getChannelsParseErrorMessage, getDisabledPredefinedChannels, getPredefinedChannel, getPredefinedChannels, getUserChannels, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../config/userChannels.js";
6
6
  import { PREDEFINED_CHANNELS } from "../channels/index.js";
7
7
  import { closeBrowser } from "../browser/index.js";
8
8
  import { getConfigFilePath } from "../config/paths.js";
9
9
  import { getPresetOptionsWithDegradation } from "../config/presets.js";
10
10
  import { getProfiles } from "../config/profiles.js";
11
11
  import { getStreamCount } from "../streaming/registry.js";
12
+ /* The /config endpoint provides a user-friendly web interface for editing PrismCast settings. Users can adjust values, see defaults, and understand what each
13
+ * setting does without editing JSON files directly. Changes require a server restart to take effect.
14
+ *
15
+ * The UI shows:
16
+ * - Settings grouped by category (Server, Browser, Streaming, etc.)
17
+ * - Current value, default value, and description for each setting
18
+ * - Visual indicator for settings overridden by environment variables (disabled with explanation)
19
+ * - Reset buttons for individual categories
20
+ * - Save & Restart button that applies changes and restarts the server
21
+ */
22
+ // SVG icon constants for channel action buttons. Each icon is 14x14px with a 16x16 viewBox, stroke-based with currentColor, and uses round line caps/joins.
23
+ const ICON_EDIT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
24
+ "stroke-linejoin=\"round\"><path d=\"M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z\"/></svg>";
25
+ const ICON_LOGIN = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
26
+ "stroke-linejoin=\"round\"><path d=\"M6.5 2H3.5a1 1 0 00-1 1v10a1 1 0 001 1h3\"/><path d=\"M10.5 11l3-3-3-3\"/><path d=\"M13.5 8H6.5\"/></svg>";
27
+ const ICON_DELETE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
28
+ "stroke-linejoin=\"round\"><path d=\"M2 4h12\"/><path d=\"M5 4V2.5a.5.5 0 01.5-.5h5a.5.5 0 01.5.5V4\"/><path d=\"M12.5 4l-.5 9.5a1 1 0 01-1 .5H5a1 1 0 " +
29
+ "01-1-.5L3.5 4\"/></svg>";
30
+ const ICON_ENABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
31
+ "stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>";
32
+ const ICON_DISABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
33
+ "stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>";
34
+ const ICON_REVERT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
35
+ "stroke-linejoin=\"round\"><path d=\"M3 8a5 5 0 1 1 1.5 3.5\"/><path d=\"M3 4v4h4\"/></svg>";
36
+ const ICON_COPY = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
37
+ "stroke-linejoin=\"round\"><rect x=\"5\" y=\"5\" width=\"9\" height=\"9\" rx=\"1\"/><path d=\"M5 11H3a1 1 0 01-1-1V3a1 1 0 011-1h7a1 1 0 011 1v2\"/></svg>";
38
+ // Fields that appear in the generated M3U playlist and affect Channels DVR's view of the channel. Used to decide whether the playlist reload hint is shown.
39
+ const M3U_FIELDS = ["channelNumber", "name", "stationId"];
40
+ const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change.";
41
+ /**
42
+ * Checks whether a stored channel entry contains any fields that affect the M3U playlist. Used to decide whether to append the playlist reload hint when reverting
43
+ * or removing an override.
44
+ * @param stored - The stored channel data to check (may be a delta or full definition).
45
+ * @returns The PLAYLIST_HINT string if M3U-relevant fields are present, empty string otherwise.
46
+ */
47
+ function playlistHintForStored(stored) {
48
+ return M3U_FIELDS.some((f) => f in stored) ? PLAYLIST_HINT : "";
49
+ }
12
50
  /**
13
51
  * Schedules a server restart after a brief delay to allow the response to be sent. This is used after configuration changes that require a restart to take effect.
14
52
  * Returns information about whether the server will auto-restart (depends on whether running as a service). If streams are active and running as a service, the restart
@@ -171,7 +209,7 @@ function generateProfileReference(profiles) {
171
209
  lines.push("<div id=\"profile-reference\" class=\"profile-reference\" style=\"display: none;\">");
172
210
  lines.push("<div class=\"profile-reference-header\">");
173
211
  lines.push("<h3>Profile Reference</h3>");
174
- lines.push("<a href=\"#\" class=\"profile-reference-close\" onclick=\"toggleProfileReference(); return false;\">\u2715</a>");
212
+ lines.push("<button type=\"button\" class=\"profile-reference-close\" aria-label=\"Close\" onclick=\"toggleProfileReference()\">\u2715</button>");
175
213
  lines.push("</div>");
176
214
  lines.push("<p class=\"reference-intro\">Profiles configure how PrismCast interacts with different video players. Autodetect uses predefined ");
177
215
  lines.push("profiles for known sites. If video doesn't play or fullscreen fails, use this reference to experiment with different profiles.</p>");
@@ -320,27 +358,30 @@ function generateChannelSelectorData() {
320
358
  return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";";
321
359
  }
322
360
  /**
323
- * Generates the HTML for a single channel's table rows (display row and optional edit form row).
361
+ * Generates the HTML for a single channel's table rows (display row and edit form row). All channels — predefined, override, and user-defined — get both rows.
362
+ * The edit form is pre-populated with the effective (resolved) values so users see what they're changing. When called from generateChannelsPanel() which already
363
+ * has the listing entry, pass it via the entry parameter to avoid redundant getChannelListing() calls. POST handlers that generate a single row omit the
364
+ * parameter to trigger an internal lookup.
324
365
  * @param key - The channel key.
325
366
  * @param profiles - List of available profiles with descriptions for the dropdown.
367
+ * @param entry - Optional pre-resolved listing entry. When omitted, looked up from getChannelListing().
326
368
  * @returns Object with displayRow and editRow HTML strings.
327
369
  */
328
- export function generateChannelRowHtml(key, profiles) {
329
- // Look up channel from user channels first (they override predefined), then predefined channels.
330
- const userChannels = getUserChannels();
331
- const predefinedChannels = getPredefinedChannels();
332
- const channel = userChannels[key] ?? predefinedChannels[key];
333
- // If channel doesn't exist, return empty rows (shouldn't happen in normal use).
334
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
335
- if (!channel) {
336
- return { displayRow: "", editRow: null };
337
- }
338
- // Resolve the selected provider's channel data for display purposes (profile column). This ensures the profile shown reflects the currently selected provider.
370
+ export function generateChannelRowHtml(key, profiles, entry) {
371
+ // Resolve the effective channel. Use the provided entry if available, otherwise look it up from the listing.
372
+ const listing = entry ?? getChannelListing().find((e) => e.key === key);
373
+ // If channel doesn't exist in the listing, return empty rows (shouldn't happen in normal use).
374
+ if (!listing) {
375
+ return { displayRow: "", editRow: "" };
376
+ }
377
+ const channel = listing.channel;
378
+ // Resolve the selected provider's channel data for display purposes (edit form pre-population). This ensures the values shown reflect the currently selected provider.
339
379
  const resolvedKey = resolveProviderKey(key);
340
380
  const resolvedChannel = getResolvedChannel(resolvedKey);
341
381
  const displayChannel = resolvedChannel ?? channel;
342
382
  const isUser = isUserChannel(key);
343
383
  const isPredefined = isPredefinedChannel(key);
384
+ const isOverride = isPredefined && isUser;
344
385
  const isDisabled = isPredefinedChannelDisabled(key);
345
386
  const isAvailableByProvider = isChannelAvailableByProvider(key);
346
387
  // Check if this channel has multiple providers.
@@ -361,10 +402,10 @@ export function generateChannelRowHtml(key, profiles) {
361
402
  }
362
403
  const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
363
404
  displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) + "\">");
364
- displayLines.push("<td><code>" + escapeHtml(key) + "</code></td>");
405
+ displayLines.push("<td class=\"ch-key\">" + escapeHtml(key) + "</td>");
365
406
  displayLines.push("<td>" + escapeHtml(channel.name ?? key) + "</td>");
366
- // Source column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers" label
367
- // alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
407
+ // Provider column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers"
408
+ // label alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
368
409
  displayLines.push("<td>");
369
410
  const labelHidden = isAvailableByProvider ? " style=\"display:none\"" : "";
370
411
  const contentHidden = isAvailableByProvider ? "" : " style=\"display:none\"";
@@ -391,71 +432,91 @@ export function generateChannelRowHtml(key, profiles) {
391
432
  escapeHtml(channel.provider ?? getProviderDisplayName(channel.url)) + "</span>");
392
433
  }
393
434
  displayLines.push("</td>");
394
- displayLines.push("<td>" + (displayChannel.profile ? escapeHtml(displayChannel.profile) : "<em>auto</em>") + "</td>");
395
- // Actions column.
435
+ // Actions column with icon buttons. Four positions per row: Edit (always), Login/placeholder (middle), a third action that varies by row type, and Copy URL.
396
436
  displayLines.push("<td>");
397
437
  displayLines.push("<div class=\"btn-group\">");
398
- // Login button appears for enabled channels only.
438
+ const escapedKey = escapeHtml(key);
439
+ // Position 1: Edit (all channels).
440
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
441
+ "')\">" + ICON_EDIT + "</button>");
442
+ // Position 2: Login for enabled channels, placeholder for disabled predefined.
399
443
  if (!isDisabled) {
400
- displayLines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" onclick=\"startChannelLogin('" + escapeHtml(key) + "')\">Login</button>");
444
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-login\" title=\"Login\" aria-label=\"Login\" onclick=\"startChannelLogin('" +
445
+ escapedKey + "')\">" + ICON_LOGIN + "</button>");
401
446
  }
402
- if (isUser) {
403
- // User channels: Edit/Delete buttons.
404
- displayLines.push("<button type=\"button\" class=\"btn btn-edit btn-sm\" onclick=\"showEditForm('" + escapeHtml(key) + "')\">Edit</button>");
405
- displayLines.push("<button type=\"button\" class=\"btn btn-delete btn-sm\" onclick=\"deleteChannel('" + escapeHtml(key) + "')\">Delete</button>");
447
+ else {
448
+ displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
449
+ }
450
+ // Position 3: varies by row type.
451
+ if (isOverride) {
452
+ // Override: revert to predefined defaults.
453
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-revert\" title=\"Revert to defaults\" aria-label=\"Revert to defaults\"" +
454
+ " onclick=\"revertChannel('" + escapedKey + "')\">" + ICON_REVERT + "</button>");
455
+ }
456
+ else if (isUser && !isPredefined) {
457
+ // User-defined (not an override): delete.
458
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
459
+ escapedKey + "')\">" + ICON_DELETE + "</button>");
406
460
  }
407
461
  else if (isPredefined) {
408
- // Predefined channels: Enable/Disable button.
462
+ // Predefined (no override): enable/disable toggle.
409
463
  if (isDisabled) {
410
- displayLines.push("<button type=\"button\" class=\"btn btn-enable btn-sm\" onclick=\"togglePredefinedChannel('" + escapeHtml(key) +
411
- "', true)\">Enable</button>");
464
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
465
+ escapedKey + "', true)\">" + ICON_ENABLE + "</button>");
412
466
  }
413
467
  else {
414
- displayLines.push("<button type=\"button\" class=\"btn btn-disable btn-sm\" onclick=\"togglePredefinedChannel('" + escapeHtml(key) +
415
- "', false)\">Disable</button>");
468
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-disable\" title=\"Disable\" aria-label=\"Disable\" onclick=\"togglePredefinedChannel('" +
469
+ escapedKey + "', false)\">" + ICON_DISABLE + "</button>");
416
470
  }
417
471
  }
472
+ // Position 4: Copy URL dropdown (all channels).
473
+ displayLines.push("<div class=\"dropdown copy-dropdown\">");
474
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-copy\" title=\"Copy stream URL\" aria-label=\"Copy stream URL\" " +
475
+ "onclick=\"toggleDropdown(this)\">" + ICON_COPY + "</button>");
476
+ displayLines.push("<div class=\"dropdown-menu copy-url-menu\">");
477
+ displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('hls', '" + escapedKey + "')\">Copy HLS URL</div>");
478
+ displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('mpegts', '" + escapedKey + "')\">Copy MPEG-TS URL</div>");
479
+ displayLines.push("</div>");
480
+ displayLines.push("</div>");
418
481
  displayLines.push("</div>");
419
482
  displayLines.push("</td>");
420
483
  displayLines.push("</tr>");
421
484
  const displayRow = displayLines.join("\n");
422
- // Generate edit form row for user channels.
423
- let editRow = null;
424
- if (isUser) {
425
- const editLines = [];
426
- editLines.push("<tr id=\"edit-row-" + escapeHtml(key) + "\" style=\"display: none;\">");
427
- editLines.push("<td colspan=\"5\">");
428
- editLines.push("<div class=\"channel-form\" style=\"margin: 0;\">");
429
- editLines.push("<h3>Edit Channel: " + escapeHtml(key) + "</h3>");
430
- editLines.push("<form id=\"edit-channel-form-" + escapeHtml(key) + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
431
- editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
432
- editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapeHtml(key) + "\">");
433
- // Channel name.
434
- editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", channel.name ?? key, {
435
- hint: "Friendly name shown in the playlist and UI.",
436
- required: true
437
- }));
438
- // Channel URL.
439
- editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", channel.url, {
440
- hint: "The URL of the streaming page to capture.",
441
- required: true,
442
- type: "url"
443
- }));
444
- // Profile dropdown.
445
- editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles));
446
- // Advanced fields.
447
- editLines.push(...generateAdvancedFields("edit-" + key, channel.stationId ?? "", channel.channelSelector ?? "", channel.channelNumber ? String(channel.channelNumber) : ""));
448
- // Form buttons.
449
- editLines.push("<div class=\"form-buttons\">");
450
- editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
451
- editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapeHtml(key) + "')\">Cancel</button>");
452
- editLines.push("</div>");
453
- editLines.push("</form>");
454
- editLines.push("</div>");
455
- editLines.push("</td>");
456
- editLines.push("</tr>");
457
- editRow = editLines.join("\n");
458
- }
485
+ // Generate edit form row for all channels. Pre-populate with the currently selected provider's values so the user sees what they're actually streaming, not the
486
+ // canonical definition which may differ when a provider variant is selected.
487
+ const editLines = [];
488
+ editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
489
+ editLines.push("<td colspan=\"4\">");
490
+ editLines.push("<div class=\"channel-form\" style=\"margin: 0;\">");
491
+ editLines.push("<h3>Edit Channel: " + escapedKey + "</h3>");
492
+ editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
493
+ editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
494
+ editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
495
+ // Channel name.
496
+ editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", displayChannel.name ?? key, {
497
+ hint: "Friendly name shown in the playlist and UI.",
498
+ required: true
499
+ }));
500
+ // Channel URL.
501
+ editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", displayChannel.url, {
502
+ hint: "The URL of the streaming page to capture.",
503
+ required: true,
504
+ type: "url"
505
+ }));
506
+ // Profile dropdown.
507
+ editLines.push(...generateProfileDropdown("edit-profile-" + key, displayChannel.profile ?? "", profiles));
508
+ // Advanced fields.
509
+ editLines.push(...generateAdvancedFields("edit-" + key, displayChannel.stationId ?? "", displayChannel.channelSelector ?? "", displayChannel.channelNumber ? String(displayChannel.channelNumber) : ""));
510
+ // Form buttons.
511
+ editLines.push("<div class=\"form-buttons\">");
512
+ editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
513
+ editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapedKey + "')\">Cancel</button>");
514
+ editLines.push("</div>");
515
+ editLines.push("</form>");
516
+ editLines.push("</div>");
517
+ editLines.push("</td>");
518
+ editLines.push("</tr>");
519
+ const editRow = editLines.join("\n");
459
520
  return { displayRow, editRow };
460
521
  }
461
522
  /**
@@ -748,11 +809,13 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
748
809
  else {
749
810
  // Render as input field.
750
811
  const inputType = (setting.type === "float") ? "number" : (((setting.type === "integer") || (setting.type === "port")) ? "number" : "text");
751
- // Calculate step based on type and displayDivisor. When displayDivisor is set, step must match the storage granularity to ensure HTML5 validation passes
752
- // (the check is: (value - min) % step === 0). For example, ms→seconds with divisor 1000 needs step 0.001 so any millisecond value is valid.
812
+ // Calculate step for arrow key increments. Auto-derived from min/displayDivisor: when the min in display units is between 0 and 1 (exclusive), use it as the
813
+ // step (e.g., 500ms 0.5s step); otherwise step is 1 whole display unit. This gives meaningful arrow increments and constrains input to a sensible value grid
814
+ // (e.g., 0.5, 1.0, 1.5, ... for half-second steps) rather than the old 1/displayDivisor approach which produced unusably small increments.
753
815
  let step = "1";
754
- if (setting.displayDivisor) {
755
- step = String(1 / setting.displayDivisor);
816
+ if (setting.displayDivisor && (setting.min !== undefined)) {
817
+ const displayMin = setting.min / setting.displayDivisor;
818
+ step = (displayMin > 0) && (displayMin < 1) ? String(displayMin) : "1";
756
819
  }
757
820
  else if (setting.type === "float") {
758
821
  step = "0.01";
@@ -795,7 +858,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
795
858
  // Add reset button for modified settings.
796
859
  if (isModified) {
797
860
  lines.push("<button type=\"button\" class=\"btn-reset\" onclick=\"resetSetting('" + escapeHtml(setting.path) +
798
- "')\" title=\"Reset to default\">&#8635;</button>");
861
+ "')\" title=\"Reset to default\" aria-label=\"Reset to default\">&#8635;</button>");
799
862
  }
800
863
  lines.push("</div>");
801
864
  // Add description.
@@ -976,7 +1039,8 @@ export function generateProviderFilterToolbar() {
976
1039
  }
977
1040
  const displayName = allTags.find((t) => t.tag === tag)?.displayName ?? tag;
978
1041
  lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" + escapeHtml(displayName) +
979
- "<button type=\"button\" class=\"chip-close\" onclick=\"removeProviderChip('" + escapeHtml(tag) + "')\">&times;</button></span>");
1042
+ "<button type=\"button\" class=\"chip-close\" aria-label=\"Remove " + escapeHtml(displayName) + "\" onclick=\"removeProviderChip('" + escapeHtml(tag) +
1043
+ "')\">&times;</button></span>");
980
1044
  }
981
1045
  }
982
1046
  lines.push("</div>");
@@ -1023,8 +1087,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1023
1087
  const lines = [];
1024
1088
  // Panel description.
1025
1089
  lines.push("<div class=\"settings-panel-description\">");
1026
- lines.push("<p>Define and manage streaming channels for the playlist. Your custom channels are highlighted.</p>");
1027
- lines.push("<p class=\"description-hint\">Tip: To override a predefined channel, add a custom channel with the same key. When adding or editing a channel, ", "select a profile to see the Profile Reference with site-specific guidance for known providers.</p>");
1090
+ lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
1091
+ lines.push("<p class=\"description-hint\">Tip: Use the <strong>provider filter</strong> above to show only channels from services you subscribe to &mdash; ", "this also controls which channels Channels DVR sees in the playlist. Use the <strong>provider dropdown</strong> on any multi-provider channel to choose ", "which streaming service delivers it (e.g., Comedy Central via Hulu vs Sling). Click the <strong>edit icon</strong> to customize any channel's name, ", "Gracenote station ID, URL, or other properties.</p>");
1028
1092
  lines.push("</div>");
1029
1093
  // Toolbar with channel operations and display controls.
1030
1094
  lines.push("<div class=\"channel-toolbar\">");
@@ -1139,19 +1203,16 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1139
1203
  lines.push("<tr>");
1140
1204
  lines.push("<th class=\"col-key\">Key</th>");
1141
1205
  lines.push("<th class=\"col-name\">Name</th>");
1142
- lines.push("<th class=\"col-source\">Source</th>");
1143
- lines.push("<th class=\"col-profile\">Profile</th>");
1206
+ lines.push("<th class=\"col-provider\">Provider</th>");
1144
1207
  lines.push("<th class=\"col-actions\">Actions</th>");
1145
1208
  lines.push("</tr>");
1146
1209
  lines.push("</thead>");
1147
1210
  lines.push("<tbody>");
1148
1211
  // Generate rows for all channels using the shared row generator.
1149
1212
  for (const entry of listing) {
1150
- const rowHtml = generateChannelRowHtml(entry.key, profiles);
1213
+ const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
1151
1214
  lines.push(rowHtml.displayRow);
1152
- if (rowHtml.editRow) {
1153
- lines.push(rowHtml.editRow);
1154
- }
1215
+ lines.push(rowHtml.editRow);
1155
1216
  }
1156
1217
  lines.push("</tbody>");
1157
1218
  lines.push("</table>");
@@ -1438,13 +1499,18 @@ export function setupConfigEndpoint(app) {
1438
1499
  void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
1439
1500
  }, 500);
1440
1501
  });
1441
- // GET /config/channels/export - Export user channels as JSON.
1502
+ // GET /config/channels/export - Export user channels as JSON. Deltas are resolved to full definitions for backward compatibility with the import validator
1503
+ // which requires url as a required field.
1442
1504
  app.get("/config/channels/export", (_req, res) => {
1443
1505
  try {
1444
- const userChannels = getUserChannels();
1506
+ const storedChannels = getUserChannels();
1507
+ const resolved = {};
1508
+ for (const [key, stored] of Object.entries(storedChannels)) {
1509
+ resolved[key] = resolveStoredChannel(key, stored);
1510
+ }
1445
1511
  res.setHeader("Content-Type", "application/json");
1446
1512
  res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
1447
- res.send(JSON.stringify(userChannels, null, 2) + "\n");
1513
+ res.send(JSON.stringify(resolved, null, 2) + "\n");
1448
1514
  }
1449
1515
  catch (error) {
1450
1516
  LOG.error("Failed to export channels: %s", formatError(error));
@@ -1662,11 +1728,11 @@ export function setupConfigEndpoint(app) {
1662
1728
  setProviderSelection(canonicalKey, providerKey);
1663
1729
  // Save to disk.
1664
1730
  await saveProviderSelections();
1665
- // Get the resolved channel to return its profile for UI update.
1666
- const resolvedChannel = getResolvedChannel(providerKey);
1667
- const profile = resolvedChannel?.profile ?? null;
1668
1731
  LOG.info("Provider selection for '%s' changed to '%s'.", canonicalKey, providerKey);
1669
- res.json({ channel: canonicalKey, profile, provider: providerKey, success: true });
1732
+ // Return full row HTML so the client can replace both the display and edit rows, keeping the edit form in sync with the selected provider.
1733
+ const profiles = getProfiles();
1734
+ const rowHtml = generateChannelRowHtml(canonicalKey, profiles);
1735
+ res.json({ channel: canonicalKey, html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, provider: providerKey, success: true });
1670
1736
  }
1671
1737
  catch (error) {
1672
1738
  LOG.error("Failed to update provider selection: %s", formatError(error));
@@ -1835,13 +1901,44 @@ export function setupConfigEndpoint(app) {
1835
1901
  res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
1836
1902
  }
1837
1903
  });
1838
- // POST /config/channels - Handle channel add, edit, delete operations. Returns JSON response.
1904
+ // POST /config/channels - Handle channel add, edit, delete, and revert operations. Returns JSON response.
1839
1905
  app.post("/config/channels", async (req, res) => {
1840
1906
  try {
1841
1907
  const body = req.body;
1842
1908
  const action = body.action;
1843
1909
  const key = body.key?.trim();
1844
1910
  const profiles = getProfiles();
1911
+ // Handle revert action — remove override of a predefined channel, restoring it to defaults.
1912
+ if (action === "revert") {
1913
+ if (!key) {
1914
+ res.status(400).json({ message: "Channel key is required for revert.", success: false });
1915
+ return;
1916
+ }
1917
+ if (!isPredefinedChannel(key)) {
1918
+ res.status(400).json({ message: "Cannot revert '" + key + "': it is not a predefined channel.", success: false });
1919
+ return;
1920
+ }
1921
+ if (!isUserChannel(key)) {
1922
+ res.status(400).json({ message: "Cannot revert '" + key + "': no override exists.", success: false });
1923
+ return;
1924
+ }
1925
+ // Remove the override.
1926
+ const result = await loadUserChannels();
1927
+ if (result.parseError) {
1928
+ res.status(400).json({ message: "Cannot revert channel: channels file contains invalid JSON.", success: false });
1929
+ return;
1930
+ }
1931
+ // Check if the override contains M3U-relevant fields before removing it.
1932
+ const revertHint = playlistHintForStored(result.channels[key]);
1933
+ Reflect.deleteProperty(result.channels, key);
1934
+ await saveUserChannels(result.channels);
1935
+ LOG.info("Channel '%s' reverted to predefined defaults.", key);
1936
+ // Generate the predefined row HTML so the client can replace the override row.
1937
+ const rowHtml = generateChannelRowHtml(key, profiles);
1938
+ res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
1939
+ message: "Channel '" + key + "' reverted to defaults." + revertHint, success: true });
1940
+ return;
1941
+ }
1845
1942
  // Handle delete action.
1846
1943
  if (action === "delete") {
1847
1944
  if (!key) {
@@ -1865,7 +1962,8 @@ export function setupConfigEndpoint(app) {
1865
1962
  // just removing it. Without this, deleting a user override of a predefined channel would leave the predefined channel invisible until a page refresh.
1866
1963
  const predefined = isPredefinedChannel(key) ? generateChannelRowHtml(key, profiles) : undefined;
1867
1964
  // Return success response with key for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
1868
- res.json({ html: predefined, key, message: "Channel '" + key + "' deleted successfully.", success: true });
1965
+ res.json({ html: predefined, key,
1966
+ message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT, success: true });
1869
1967
  return;
1870
1968
  }
1871
1969
  // Handle add and edit actions.
@@ -1894,11 +1992,10 @@ export function setupConfigEndpoint(app) {
1894
1992
  formErrors.channelNumber = "Channel number must be between 1 and 99999.";
1895
1993
  }
1896
1994
  else {
1897
- // Check for duplicate channel numbers across all channels.
1898
- const allChannels = { ...getPredefinedChannels(), ...getUserChannels() };
1899
- for (const [existingKey, existingChannel] of Object.entries(allChannels)) {
1900
- if ((existingChannel.channelNumber === num) && (existingKey !== key)) {
1901
- formErrors.channelNumber = "Channel number " + String(num) + " is already used by '" + existingKey + "'.";
1995
+ // Check for duplicate channel numbers across all resolved channels.
1996
+ for (const entry of getChannelListing()) {
1997
+ if ((entry.channel.channelNumber === num) && (entry.key !== key)) {
1998
+ formErrors.channelNumber = "Channel number " + String(num) + " is already used by '" + entry.key + "'.";
1902
1999
  break;
1903
2000
  }
1904
2001
  }
@@ -1943,36 +2040,141 @@ export function setupConfigEndpoint(app) {
1943
2040
  return;
1944
2041
  }
1945
2042
  }
1946
- // Build the channel object.
1947
- const channel = {
1948
- name,
1949
- url
1950
- };
1951
- if (profile) {
1952
- channel.profile = profile;
1953
- }
1954
- if (stationId) {
1955
- channel.stationId = stationId;
1956
- }
1957
- if (channelSelector) {
1958
- channel.channelSelector = channelSelector;
2043
+ let playlistChanged = false;
2044
+ // For predefined channels being edited, compute a delta of only the changed fields. For user-defined channels and adds, build a full channel object.
2045
+ const predefinedBase = getPredefinedChannel(key);
2046
+ if ((action === "edit") && predefinedBase) {
2047
+ // Build a record of submitted form values keyed by channel property name. This record drives both the displayChannel comparison and the predefined delta
2048
+ // computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined.
2049
+ const formValues = {
2050
+ channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
2051
+ channelSelector,
2052
+ name,
2053
+ profile,
2054
+ stationId,
2055
+ url
2056
+ };
2057
+ // Helper to read a comparable value from a Channel object. String fields default to "" when undefined so they match the form's empty-string representation.
2058
+ // channelNumber stays as number | undefined since the form value uses the same representation.
2059
+ const channelValue = (ch, field) => {
2060
+ const val = ch[field];
2061
+ return (field === "channelNumber") ? val : val ?? "";
2062
+ };
2063
+ // First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
2064
+ // may differ from the canonical predefined base when a variant is selected (e.g., the Hulu variant has a different URL and channelSelector). If the submitted
2065
+ // values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
2066
+ const resolvedKey = resolveProviderKey(key);
2067
+ const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
2068
+ if (Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field))) {
2069
+ res.json({ key, message: "No changes to save.", success: true });
2070
+ return;
2071
+ }
2072
+ // Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
2073
+ // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
2074
+ const delta = {};
2075
+ let hasChanges = false;
2076
+ for (const field of Object.keys(formValues)) {
2077
+ if (formValues[field] !== channelValue(predefinedBase, field)) {
2078
+ const formVal = formValues[field];
2079
+ delta[field] = ((formVal === "") || (formVal === undefined)) ? null : formVal;
2080
+ hasChanges = true;
2081
+ }
2082
+ }
2083
+ // Helper to check if form values match a given channel's properties.
2084
+ const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field));
2085
+ if (!hasChanges) {
2086
+ // The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away — treat it as an
2087
+ // implicit revert by removing the override and returning the predefined row HTML.
2088
+ if (isUserChannel(key)) {
2089
+ const implicitRevertHint = playlistHintForStored(result.channels[key]);
2090
+ Reflect.deleteProperty(result.channels, key);
2091
+ await saveUserChannels(result.channels);
2092
+ LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
2093
+ const rowHtml = generateChannelRowHtml(key, profiles);
2094
+ res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
2095
+ message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint, success: true });
2096
+ return;
2097
+ }
2098
+ res.json({ key, message: "No changes to save.", success: true });
2099
+ return;
2100
+ }
2101
+ // The delta has changes vs the canonical predefined. Before storing a custom override, check if the form values match any provider variant's predefined
2102
+ // definition. This handles the case where a user edits from a variant (e.g., Hulu), makes a change, saves (creating a custom override), then edits again
2103
+ // and reverts the change. The URL and channelSelector still differ from the canonical predefined but match the variant — that's a revert to the variant,
2104
+ // not a new customization. We resolve each variant against pure PREDEFINED data (not the user-overridden channelsRef) to avoid contamination from the
2105
+ // current override.
2106
+ const providerGroup = getProviderGroup(key);
2107
+ let matchedVariantKey;
2108
+ if (providerGroup && isUserChannel(key)) {
2109
+ for (const variant of providerGroup.variants) {
2110
+ // Skip the canonical entry (already handled by the !hasChanges check above) and :predefined entries (synthetic entries for override UI).
2111
+ if ((variant.key === key) || variant.key.includes(":")) {
2112
+ continue;
2113
+ }
2114
+ // Resolve this variant against pure predefined data (no user override contamination).
2115
+ const resolvedVariant = resolvePredefinedVariant(variant.key);
2116
+ if (resolvedVariant && formMatchesChannel(resolvedVariant)) {
2117
+ matchedVariantKey = variant.key;
2118
+ break;
2119
+ }
2120
+ }
2121
+ }
2122
+ if (matchedVariantKey) {
2123
+ // Form values match a known variant — revert the override and switch back to that variant.
2124
+ const variantRevertHint = playlistHintForStored(result.channels[key]);
2125
+ Reflect.deleteProperty(result.channels, key);
2126
+ setProviderSelection(key, matchedVariantKey);
2127
+ await saveUserChannels(result.channels);
2128
+ LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
2129
+ const rowHtml = generateChannelRowHtml(key, profiles);
2130
+ res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
2131
+ message: "Channel '" + key + "' reverted to defaults." + variantRevertHint, success: true });
2132
+ return;
2133
+ }
2134
+ // No variant match — store the delta and switch the provider selection to the canonical key (the custom override). This ensures the provider dropdown shows
2135
+ // "Custom" after saving, which is the expected behavior when a user customizes a predefined channel.
2136
+ setProviderSelection(key, key);
2137
+ result.channels[key] = delta;
2138
+ playlistChanged = M3U_FIELDS.some((f) => f in delta);
1959
2139
  }
1960
- if (channelNumberStr) {
1961
- channel.channelNumber = parseInt(channelNumberStr, 10);
2140
+ else {
2141
+ // User-defined channel or add: build a full channel object. For edits, snapshot the old channel to detect M3U-relevant changes.
2142
+ const oldChannel = ((action === "edit") && (key in result.channels)) ? result.channels[key] : undefined;
2143
+ const channel = {
2144
+ name,
2145
+ url
2146
+ };
2147
+ if (profile) {
2148
+ channel.profile = profile;
2149
+ }
2150
+ if (stationId) {
2151
+ channel.stationId = stationId;
2152
+ }
2153
+ if (channelSelector) {
2154
+ channel.channelSelector = channelSelector;
2155
+ }
2156
+ if (channelNumberStr) {
2157
+ channel.channelNumber = parseInt(channelNumberStr, 10);
2158
+ }
2159
+ result.channels[key] = channel;
2160
+ // For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
2161
+ playlistChanged = (action === "add") ||
2162
+ ((oldChannel !== undefined) &&
2163
+ M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
1962
2164
  }
1963
- // Add or update the channel.
1964
- result.channels[key] = channel;
1965
2165
  await saveUserChannels(result.channels);
1966
2166
  const actionLabel = (action === "add") ? "added" : "updated";
1967
2167
  LOG.info("User channel '%s' %s.", key, actionLabel);
1968
2168
  // Generate HTML for the channel row so the client can update the DOM without a full page reload.
1969
2169
  const rowHtml = generateChannelRowHtml(key, profiles);
2170
+ // Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
2171
+ const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
1970
2172
  // Return success response with HTML for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
1971
2173
  res.json({
1972
2174
  html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow },
1973
2175
  isNew: action === "add",
1974
2176
  key,
1975
- message: "Channel '" + key + "' " + actionLabel + " successfully.",
2177
+ message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
1976
2178
  success: true
1977
2179
  });
1978
2180
  }