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