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.
- package/README.md +37 -8
- package/dist/browser/tuning/hulu.js +114 -62
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/config/providers.d.ts +11 -3
- package/dist/config/providers.js +47 -13
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.js +2 -2
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +29 -19
- package/dist/config/userChannels.js +72 -22
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.js +1 -1
- package/dist/config/userConfig.js.map +1 -1
- package/dist/routes/config.d.ts +8 -4
- package/dist/routes/config.js +316 -114
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/root.js +341 -142
- package/dist/routes/root.js.map +1 -1
- package/dist/routes/theme.js +7 -1
- package/dist/routes/theme.js.map +1 -1
- package/dist/streaming/monitor.js +36 -3
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/showInfo.js +12 -14
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/types/index.d.ts +23 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +2 -2
package/dist/routes/config.js
CHANGED
|
@@ -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("<
|
|
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
|
|
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
|
-
//
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
405
|
+
displayLines.push("<td class=\"ch-key\">" + escapeHtml(key) + "</td>");
|
|
365
406
|
displayLines.push("<td>" + escapeHtml(channel.name ?? key) + "</td>");
|
|
366
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
462
|
+
// Predefined (no override): enable/disable toggle.
|
|
409
463
|
if (isDisabled) {
|
|
410
|
-
displayLines.push("<button type=\"button\" class=\"btn btn-enable
|
|
411
|
-
"', true)\">
|
|
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
|
|
415
|
-
"', false)\">
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
752
|
-
// (
|
|
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
|
-
|
|
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\">↻</button>");
|
|
861
|
+
"')\" title=\"Reset to default\" aria-label=\"Reset to default\">↻</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\"
|
|
1042
|
+
"<button type=\"button\" class=\"chip-close\" aria-label=\"Remove " + escapeHtml(displayName) + "\" onclick=\"removeProviderChip('" + escapeHtml(tag) +
|
|
1043
|
+
"')\">×</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.
|
|
1027
|
-
lines.push("<p class=\"description-hint\">Tip:
|
|
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 — ", "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-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
|
1899
|
-
|
|
1900
|
-
|
|
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
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
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
|
-
|
|
1961
|
-
channel.
|
|
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
|
}
|