prismcast 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +18 -1
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +2 -1
- package/dist/browser/cdp.js +44 -23
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +45 -1
- package/dist/browser/channelSelection.js +164 -27
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +0 -4
- package/dist/browser/display.js +0 -7
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +5 -0
- package/dist/browser/index.js +14 -0
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/precaching.d.ts +5 -0
- package/dist/browser/precaching.js +112 -0
- package/dist/browser/precaching.js.map +1 -0
- package/dist/browser/tuning/directv.d.ts +2 -0
- package/dist/browser/tuning/directv.js +723 -0
- package/dist/browser/tuning/directv.js.map +1 -0
- package/dist/browser/tuning/fox.d.ts +2 -2
- package/dist/browser/tuning/fox.js +141 -22
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.d.ts +2 -2
- package/dist/browser/tuning/hbo.js +110 -41
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.d.ts +2 -2
- package/dist/browser/tuning/hulu.js +442 -168
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.d.ts +2 -2
- package/dist/browser/tuning/sling.js +200 -56
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.d.ts +2 -2
- package/dist/browser/tuning/youtubeTv.js +243 -57
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +1 -1
- package/dist/browser/video.js +4 -2
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.js +280 -34
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +78 -0
- package/dist/config/health.js +202 -0
- package/dist/config/health.js.map +1 -0
- package/dist/config/paths.d.ts +10 -0
- package/dist/config/paths.js +14 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/profiles.d.ts +6 -4
- package/dist/config/profiles.js +65 -14
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerPacks.d.ts +46 -0
- package/dist/config/providerPacks.js +200 -0
- package/dist/config/providerPacks.js.map +1 -0
- package/dist/config/providers.d.ts +36 -3
- package/dist/config/providers.js +168 -8
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +1 -12
- package/dist/config/sites.js +38 -5
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +12 -0
- package/dist/config/userChannels.js +81 -14
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +5 -1
- package/dist/config/userConfig.js +58 -6
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +81 -0
- package/dist/config/userProfiles.js +424 -0
- package/dist/config/userProfiles.js.map +1 -0
- package/dist/routes/config/channels/index.d.ts +3 -0
- package/dist/routes/config/channels/index.js +3 -0
- package/dist/routes/config/channels/index.js.map +1 -0
- package/dist/routes/config/channels/routes.d.ts +6 -0
- package/dist/routes/config/channels/routes.js +797 -0
- package/dist/routes/config/channels/routes.js.map +1 -0
- package/dist/routes/config/channels/table.d.ts +46 -0
- package/dist/routes/config/channels/table.js +811 -0
- package/dist/routes/config/channels/table.js.map +1 -0
- package/dist/routes/config/index.d.ts +37 -0
- package/dist/routes/config/index.js +81 -0
- package/dist/routes/config/index.js.map +1 -0
- package/dist/routes/config/providers.d.ts +18 -0
- package/dist/routes/config/providers.js +573 -0
- package/dist/routes/config/providers.js.map +1 -0
- package/dist/routes/config/settings.d.ts +39 -0
- package/dist/routes/config/settings.js +820 -0
- package/dist/routes/config/settings.js.map +1 -0
- package/dist/routes/index.d.ts +3 -2
- package/dist/routes/index.js +7 -4
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/playlist.d.ts +5 -1
- package/dist/routes/playlist.js +32 -7
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/providers.d.ts +6 -0
- package/dist/routes/providers.js +166 -0
- package/dist/routes/providers.js.map +1 -0
- package/dist/routes/root/content.d.ts +32 -0
- package/dist/routes/root/content.js +857 -0
- package/dist/routes/root/content.js.map +1 -0
- package/dist/routes/root/index.js +197 -0
- package/dist/routes/root/index.js.map +1 -0
- package/dist/routes/root/scripts/channels.d.ts +1 -0
- package/dist/routes/root/scripts/channels.js +745 -0
- package/dist/routes/root/scripts/channels.js.map +1 -0
- package/dist/routes/root/scripts/config.d.ts +1 -0
- package/dist/routes/root/scripts/config.js +1662 -0
- package/dist/routes/root/scripts/config.js.map +1 -0
- package/dist/routes/root/scripts/index.d.ts +3 -0
- package/dist/routes/root/scripts/index.js +8 -0
- package/dist/routes/root/scripts/index.js.map +1 -0
- package/dist/routes/root/scripts/status.d.ts +1 -0
- package/dist/routes/root/scripts/status.js +436 -0
- package/dist/routes/root/scripts/status.js.map +1 -0
- package/dist/routes/root/styles.d.ts +5 -0
- package/dist/routes/root/styles.js +447 -0
- package/dist/routes/root/styles.js.map +1 -0
- package/dist/routes/ui.js +8 -3
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.d.ts +5 -3
- package/dist/service/commands.js +117 -33
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +27 -0
- package/dist/service/generators.js +97 -6
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/hls.js +18 -1
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/lifecycle.js +1 -1
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -38
- package/dist/streaming/monitor.js +2 -289
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/recovery.d.ts +143 -0
- package/dist/streaming/recovery.js +286 -0
- package/dist/streaming/recovery.js.map +1 -0
- package/dist/streaming/registry.d.ts +1 -1
- package/dist/streaming/setup.d.ts +1 -1
- package/dist/streaming/setup.js +3 -1
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -2
- package/dist/streaming/statusEmitter.js +5 -0
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +68 -0
- package/dist/types/channels.js +2 -0
- package/dist/types/channels.js.map +1 -0
- package/dist/types/config.d.ts +127 -0
- package/dist/types/config.js +2 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/index.d.ts +7 -429
- package/dist/types/index.js +1 -5
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +141 -0
- package/dist/types/profiles.js +2 -0
- package/dist/types/profiles.js.map +1 -0
- package/dist/types/selection.d.ts +101 -0
- package/dist/types/selection.js +6 -0
- package/dist/types/selection.js.map +1 -0
- package/dist/types/shared.d.ts +7 -0
- package/dist/types/shared.js +6 -0
- package/dist/types/shared.js.map +1 -0
- package/dist/types/streaming.d.ts +96 -0
- package/dist/types/streaming.js +2 -0
- package/dist/types/streaming.js.map +1 -0
- package/dist/utils/debugFilter.js +3 -1
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/format.d.ts +7 -0
- package/dist/utils/format.js +22 -0
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/sanitize.d.ts +14 -0
- package/dist/utils/sanitize.js +37 -0
- package/dist/utils/sanitize.js.map +1 -0
- package/package.json +5 -3
- package/dist/routes/config.d.ts +0 -76
- package/dist/routes/config.js +0 -2187
- package/dist/routes/config.js.map +0 -1
- package/dist/routes/root.js +0 -3158
- package/dist/routes/root.js.map +0 -1
- /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { CONFIG, getDefaults, validatePositiveInt, validatePositiveNumber } from "../../config/index.js";
|
|
2
|
+
import { CONFIG_METADATA, filterDefaults, getAdvancedSections, getEnvOverrides, getNestedValue, getSettingsTabSections, getUITabs, isEqualToDefault, loadUserConfig, saveUserConfig, setNestedValue } from "../../config/userConfig.js";
|
|
3
|
+
import { LOG, escapeHtml, formatError, isRunningAsService } from "../../utils/index.js";
|
|
4
|
+
import { getProviderModuleInfo, getProviderSlugs } from "../../browser/channelSelection.js";
|
|
5
|
+
import { getConfigFilePath } from "../../config/paths.js";
|
|
6
|
+
import { getPresetOptionsWithDegradation } from "../../config/presets.js";
|
|
7
|
+
import { scheduleServerRestart } from "./index.js";
|
|
8
|
+
/**
|
|
9
|
+
* Formats a value for display, converting numbers to human-readable strings where appropriate.
|
|
10
|
+
* @param value - The value to format.
|
|
11
|
+
* @returns Formatted string for display.
|
|
12
|
+
*/
|
|
13
|
+
function formatValueForDisplay(value, settingType) {
|
|
14
|
+
if ((value === null) || (value === undefined)) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === "number") {
|
|
18
|
+
// Format large numbers with commas for readability, except for port numbers where commas would be confusing.
|
|
19
|
+
if ((value >= 1000) && (settingType !== "port")) {
|
|
20
|
+
return value.toLocaleString();
|
|
21
|
+
}
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "string") {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
// Config values are always primitives (string, number, boolean). Numbers and strings are handled above.
|
|
28
|
+
return String(value);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Converts a stored value to a display value using the setting's displayDivisor.
|
|
32
|
+
* @param value - The stored value.
|
|
33
|
+
* @param setting - The setting metadata.
|
|
34
|
+
* @returns The display value.
|
|
35
|
+
*/
|
|
36
|
+
function toDisplayValue(value, setting) {
|
|
37
|
+
if ((value === null) || (value === undefined)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// Array values (e.g., checkboxList) are serialized as JSON for the hidden input's value attribute.
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
if ((typeof value === "number") && setting.displayDivisor) {
|
|
45
|
+
const displayValue = value / setting.displayDivisor;
|
|
46
|
+
// Determine precision: explicit displayPrecision, or 2 for floats, or 1 for integers with displayDivisor (to handle values like 1500ms → 1.5s).
|
|
47
|
+
const precision = setting.displayPrecision ?? ((setting.type === "float") ? 2 : 1);
|
|
48
|
+
return Number(displayValue.toFixed(precision));
|
|
49
|
+
}
|
|
50
|
+
// Boolean values pass through as strings for display.
|
|
51
|
+
if (typeof value === "boolean") {
|
|
52
|
+
return String(value);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Gets the effective unit to display for a setting.
|
|
58
|
+
* @param setting - The setting metadata.
|
|
59
|
+
* @returns The unit string to display.
|
|
60
|
+
*/
|
|
61
|
+
function getDisplayUnit(setting) {
|
|
62
|
+
return setting.displayUnit ?? setting.unit;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Mapping of units that require pluralization to their singular and plural forms. Abbreviations like "ms", "kbps", "fps" do not need pluralization and are not
|
|
66
|
+
* included here. Uses Partial<Record> to indicate that not all string keys have values.
|
|
67
|
+
*/
|
|
68
|
+
const UNIT_PLURALIZATION = {
|
|
69
|
+
minutes: { plural: "minutes", singular: "minute" },
|
|
70
|
+
seconds: { plural: "seconds", singular: "second" }
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* 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
|
|
74
|
+
* mapping (abbreviations) pass through unchanged.
|
|
75
|
+
* @param value - The numeric value to check for pluralization.
|
|
76
|
+
* @param unit - The unit string to format.
|
|
77
|
+
* @returns The correctly pluralized unit string.
|
|
78
|
+
*/
|
|
79
|
+
function formatUnitForValue(value, unit) {
|
|
80
|
+
const forms = UNIT_PLURALIZATION[unit];
|
|
81
|
+
if (!forms) {
|
|
82
|
+
return unit;
|
|
83
|
+
}
|
|
84
|
+
return (value === 1) ? forms.singular : forms.plural;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Gets the effective min value for display (converted if displayDivisor is set).
|
|
88
|
+
* @param setting - The setting metadata.
|
|
89
|
+
* @returns The min value for the input field.
|
|
90
|
+
*/
|
|
91
|
+
function getDisplayMin(setting) {
|
|
92
|
+
if ((setting.min === undefined) || !setting.displayDivisor) {
|
|
93
|
+
return setting.min;
|
|
94
|
+
}
|
|
95
|
+
return setting.min / setting.displayDivisor;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Gets the effective max value for display (converted if displayDivisor is set).
|
|
99
|
+
* @param setting - The setting metadata.
|
|
100
|
+
* @returns The max value for the input field.
|
|
101
|
+
*/
|
|
102
|
+
function getDisplayMax(setting) {
|
|
103
|
+
if ((setting.max === undefined) || !setting.displayDivisor) {
|
|
104
|
+
return setting.max;
|
|
105
|
+
}
|
|
106
|
+
return setting.max / setting.displayDivisor;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 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
|
|
110
|
+
* to the actual displayed content rather than raw stored values, accounting for displayDivisor conversion.
|
|
111
|
+
* @param setting - The setting metadata.
|
|
112
|
+
* @returns CSS class name for field width (field-narrow, field-medium, or field-wide).
|
|
113
|
+
*/
|
|
114
|
+
function getFieldWidthClass(setting) {
|
|
115
|
+
// Ports always get narrow (max 5 digits: 65535).
|
|
116
|
+
if (setting.type === "port") {
|
|
117
|
+
return "field-narrow";
|
|
118
|
+
}
|
|
119
|
+
// For selects (settings with validValues), determine width based on content.
|
|
120
|
+
if (setting.validValues && (setting.validValues.length > 0)) {
|
|
121
|
+
// Quality preset dropdown needs wide width because it displays dynamic degradation text like "1080p (limited to 720p High)" which is much longer than the
|
|
122
|
+
// static validValues entries.
|
|
123
|
+
if (setting.path === "streaming.qualityPreset") {
|
|
124
|
+
return "field-wide";
|
|
125
|
+
}
|
|
126
|
+
const maxLength = Math.max(...setting.validValues.map((v) => v.length));
|
|
127
|
+
// Short options (e.g., "none", "all", "errors") get narrow width.
|
|
128
|
+
if (maxLength <= 8) {
|
|
129
|
+
return "field-narrow";
|
|
130
|
+
}
|
|
131
|
+
// Medium options (e.g., "filtered") get medium width.
|
|
132
|
+
if (maxLength <= 12) {
|
|
133
|
+
return "field-medium";
|
|
134
|
+
}
|
|
135
|
+
// Long options get wide width.
|
|
136
|
+
return "field-wide";
|
|
137
|
+
}
|
|
138
|
+
// For numeric types, calculate displayed digit count to determine width.
|
|
139
|
+
if ((setting.type === "integer") || (setting.type === "float")) {
|
|
140
|
+
// Calculate the displayed max value, accounting for displayDivisor conversion.
|
|
141
|
+
let displayMax = setting.max;
|
|
142
|
+
if ((displayMax !== undefined) && setting.displayDivisor) {
|
|
143
|
+
displayMax = displayMax / setting.displayDivisor;
|
|
144
|
+
}
|
|
145
|
+
// If no max is defined, default to medium width as a safe middle ground.
|
|
146
|
+
if (displayMax === undefined) {
|
|
147
|
+
return "field-medium";
|
|
148
|
+
}
|
|
149
|
+
// Count digits needed for the displayed max value. For floats, add characters for decimal point and fractional digits.
|
|
150
|
+
let digitCount = Math.max(1, Math.floor(Math.log10(Math.abs(displayMax))) + 1);
|
|
151
|
+
if (setting.type === "float") {
|
|
152
|
+
digitCount = digitCount + 3;
|
|
153
|
+
}
|
|
154
|
+
// 1-4 digits get narrow (e.g., port, small counts, converted timeouts like "30" seconds).
|
|
155
|
+
if (digitCount <= 4) {
|
|
156
|
+
return "field-narrow";
|
|
157
|
+
}
|
|
158
|
+
// 5-7 digits get medium (e.g., larger bitrates).
|
|
159
|
+
if (digitCount <= 7) {
|
|
160
|
+
return "field-medium";
|
|
161
|
+
}
|
|
162
|
+
// 8+ digits get wide.
|
|
163
|
+
return "field-wide";
|
|
164
|
+
}
|
|
165
|
+
// Hosts and paths get wide width. Hosts can be IP addresses like "192.168.100.100" (15 chars) or hostnames.
|
|
166
|
+
if ((setting.type === "host") || (setting.type === "path")) {
|
|
167
|
+
return "field-wide";
|
|
168
|
+
}
|
|
169
|
+
// Generic strings get wide width.
|
|
170
|
+
return "field-wide";
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Generates HTML for a single setting form field. Supports text inputs, number inputs, and select dropdowns based on the setting type and validValues.
|
|
174
|
+
* @param setting - The setting metadata.
|
|
175
|
+
* @param currentValue - The current effective value (in storage units).
|
|
176
|
+
* @param defaultValue - The default value (in storage units).
|
|
177
|
+
* @param envOverride - The environment variable value if overridden, undefined otherwise.
|
|
178
|
+
* @param validationError - Validation error message if any.
|
|
179
|
+
* @returns HTML string for the form field.
|
|
180
|
+
*/
|
|
181
|
+
function generateSettingField(setting, currentValue, defaultValue, envOverride, validationError) {
|
|
182
|
+
const isDisabled = (envOverride !== undefined) || (setting.disabledReason !== undefined);
|
|
183
|
+
const inputId = setting.path.replace(/\./g, "-");
|
|
184
|
+
const hasError = validationError !== undefined;
|
|
185
|
+
const isModified = !isDisabled && !isEqualToDefault(currentValue, defaultValue);
|
|
186
|
+
// Convert values for display.
|
|
187
|
+
const displayValue = toDisplayValue(currentValue, setting);
|
|
188
|
+
const displayDefault = toDisplayValue(defaultValue, setting);
|
|
189
|
+
const displayUnit = getDisplayUnit(setting);
|
|
190
|
+
const displayMin = getDisplayMin(setting);
|
|
191
|
+
const displayMax = getDisplayMax(setting);
|
|
192
|
+
// Determine if this should be a select dropdown.
|
|
193
|
+
const hasValidValues = setting.validValues && (setting.validValues.length > 0);
|
|
194
|
+
// Check if this setting depends on a boolean toggle that is currently disabled. The depends-disabled class applies a visual grey-out without actually
|
|
195
|
+
// disabling the inputs, so values are still submitted during save.
|
|
196
|
+
const dependsOnId = setting.dependsOn ? setting.dependsOn.replace(/\./g, "-") : undefined;
|
|
197
|
+
const isDependencyDisabled = setting.dependsOn ? !getNestedValue(CONFIG, setting.dependsOn) : false;
|
|
198
|
+
// Build CSS classes for the form group.
|
|
199
|
+
const groupClasses = ["form-group"];
|
|
200
|
+
if (isDisabled) {
|
|
201
|
+
groupClasses.push("disabled");
|
|
202
|
+
}
|
|
203
|
+
if (isModified) {
|
|
204
|
+
groupClasses.push("modified");
|
|
205
|
+
}
|
|
206
|
+
if (isDependencyDisabled) {
|
|
207
|
+
groupClasses.push("depends-disabled");
|
|
208
|
+
}
|
|
209
|
+
// Build the opening div with optional data-depends-on attribute for client-side toggle behavior.
|
|
210
|
+
const dependsAttr = dependsOnId ? " data-depends-on=\"" + dependsOnId + "\"" : "";
|
|
211
|
+
const lines = [
|
|
212
|
+
"<div class=\"" + groupClasses.join(" ") + "\"" + dependsAttr + ">",
|
|
213
|
+
"<div class=\"form-row\">",
|
|
214
|
+
"<label class=\"form-label\" for=\"" + inputId + "\">"
|
|
215
|
+
];
|
|
216
|
+
// Add modified indicator before label text.
|
|
217
|
+
if (isModified) {
|
|
218
|
+
lines.push("<span class=\"modified-dot\" title=\"Modified from default\"></span>");
|
|
219
|
+
}
|
|
220
|
+
lines.push(escapeHtml(setting.label));
|
|
221
|
+
if (envOverride !== undefined) {
|
|
222
|
+
lines.push("<span class=\"env-badge\">ENV</span>");
|
|
223
|
+
}
|
|
224
|
+
lines.push("</label>");
|
|
225
|
+
// Track if the selected preset is degraded (used for inline message).
|
|
226
|
+
let selectedPresetDegradedTo = null;
|
|
227
|
+
// Block-level content that must appear outside the form-row flex container. Type branches that produce content too large for the inline flex layout (grids,
|
|
228
|
+
// editors, lists) push their HTML here. Emitted after the description div.
|
|
229
|
+
const postDescription = [];
|
|
230
|
+
if (hasValidValues) {
|
|
231
|
+
// Render as select dropdown.
|
|
232
|
+
const selectAttrs = [
|
|
233
|
+
"class=\"form-select " + getFieldWidthClass(setting) + (hasError ? " error" : "") + "\"",
|
|
234
|
+
"id=\"" + inputId + "\"",
|
|
235
|
+
"name=\"" + setting.path + "\"",
|
|
236
|
+
"data-default=\"" + escapeHtml(String(displayDefault ?? "")) + "\""
|
|
237
|
+
];
|
|
238
|
+
if (isDisabled) {
|
|
239
|
+
selectAttrs.push("disabled");
|
|
240
|
+
}
|
|
241
|
+
if (isDependencyDisabled) {
|
|
242
|
+
selectAttrs.push("tabindex=\"-1\"");
|
|
243
|
+
}
|
|
244
|
+
lines.push("<select " + selectAttrs.join(" ") + ">");
|
|
245
|
+
// Special handling for quality preset dropdown to show degradation info.
|
|
246
|
+
if (setting.path === "streaming.qualityPreset") {
|
|
247
|
+
const presetOptions = getPresetOptionsWithDegradation();
|
|
248
|
+
for (const option of presetOptions.options) {
|
|
249
|
+
const presetId = option.preset.id;
|
|
250
|
+
const isSelected = presetId === currentValue;
|
|
251
|
+
const selected = isSelected ? " selected" : "";
|
|
252
|
+
// Build the display label with degradation annotation if applicable.
|
|
253
|
+
let label = option.preset.name;
|
|
254
|
+
if (option.degradedTo) {
|
|
255
|
+
label = label + " (limited to " + option.degradedTo.name + ")";
|
|
256
|
+
// Track if the selected preset is degraded.
|
|
257
|
+
if (isSelected) {
|
|
258
|
+
selectedPresetDegradedTo = option.degradedTo.name;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
lines.push("<option value=\"" + escapeHtml(presetId) + "\"" + selected + ">" + escapeHtml(label) + "</option>");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// Standard dropdown for non-preset fields.
|
|
266
|
+
for (const validValue of setting.validValues ?? []) {
|
|
267
|
+
// For boolean types, compare string validValue with stringified currentValue to handle boolean-to-string comparison.
|
|
268
|
+
const isSelected = (setting.type === "boolean") ?
|
|
269
|
+
(validValue === String(currentValue)) :
|
|
270
|
+
(validValue === currentValue);
|
|
271
|
+
const selected = isSelected ? " selected" : "";
|
|
272
|
+
lines.push("<option value=\"" + escapeHtml(validValue) + "\"" + selected + ">" + escapeHtml(validValue) + "</option>");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
lines.push("</select>");
|
|
276
|
+
}
|
|
277
|
+
else if (setting.type === "boolean") {
|
|
278
|
+
// Render boolean as a checkbox. A hidden input with value "false" precedes the checkbox so that unchecking submits "false" rather than omitting the field
|
|
279
|
+
// entirely (which would cause the server to skip it and fall back to the default).
|
|
280
|
+
const isChecked = (currentValue === true) || (currentValue === "true");
|
|
281
|
+
const defaultStr = defaultValue ? "true" : "false";
|
|
282
|
+
lines.push("<input type=\"hidden\" name=\"" + setting.path + "\" value=\"false\">");
|
|
283
|
+
const checkboxAttrs = [
|
|
284
|
+
"class=\"form-checkbox\"",
|
|
285
|
+
"type=\"checkbox\"",
|
|
286
|
+
"id=\"" + inputId + "\"",
|
|
287
|
+
"name=\"" + setting.path + "\"",
|
|
288
|
+
"value=\"true\"",
|
|
289
|
+
"data-default=\"" + escapeHtml(defaultStr) + "\""
|
|
290
|
+
];
|
|
291
|
+
if (isChecked) {
|
|
292
|
+
checkboxAttrs.push("checked");
|
|
293
|
+
}
|
|
294
|
+
if (isDisabled) {
|
|
295
|
+
checkboxAttrs.push("disabled");
|
|
296
|
+
}
|
|
297
|
+
if (isDependencyDisabled) {
|
|
298
|
+
checkboxAttrs.push("tabindex=\"-1\"");
|
|
299
|
+
}
|
|
300
|
+
lines.push("<input " + checkboxAttrs.join(" ") + ">");
|
|
301
|
+
}
|
|
302
|
+
else if (setting.type === "checkboxList") {
|
|
303
|
+
// Render as a grid of checkboxes backed by a hidden input that holds the JSON array value. The hidden input goes inside the form-row (invisible, takes no
|
|
304
|
+
// space). The visible checkbox grid is pushed to postDescription for emission after the description, keeping it outside the form-row flex container.
|
|
305
|
+
const currentArray = Array.isArray(currentValue) ? currentValue : [];
|
|
306
|
+
const defaultArray = Array.isArray(defaultValue) ? defaultValue : [];
|
|
307
|
+
const hiddenValue = escapeHtml(JSON.stringify(currentArray));
|
|
308
|
+
const hiddenDefault = escapeHtml(JSON.stringify(defaultArray));
|
|
309
|
+
lines.push("<input type=\"hidden\" id=\"" + inputId + "\" name=\"" + setting.path + "\" value=\"" + hiddenValue +
|
|
310
|
+
"\" data-default=\"" + hiddenDefault + "\" data-checkbox-list>");
|
|
311
|
+
// Build the checkbox grid from provider module info. Pushed to postDescription for emission after the description, outside the form-row flex container.
|
|
312
|
+
const providers = getProviderModuleInfo();
|
|
313
|
+
postDescription.push("<div class=\"checkbox-list-grid\" style=\"display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); " +
|
|
314
|
+
"gap: 0.5rem; margin-top: 10px;\">");
|
|
315
|
+
for (const provider of providers) {
|
|
316
|
+
const checked = currentArray.includes(provider.slug) ? " checked" : "";
|
|
317
|
+
postDescription.push("<label style=\"display: flex; align-items: center; gap: 0.5rem; cursor: pointer;\">");
|
|
318
|
+
postDescription.push("<input type=\"checkbox\" value=\"" + escapeHtml(provider.slug) + "\"" + checked +
|
|
319
|
+
" onchange=\"updateCheckboxList(this)\"> " + escapeHtml(provider.label));
|
|
320
|
+
postDescription.push("</label>");
|
|
321
|
+
}
|
|
322
|
+
postDescription.push("</div>");
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
// Render as input field.
|
|
326
|
+
const inputType = (setting.type === "float") ? "number" : (((setting.type === "integer") || (setting.type === "port")) ? "number" : "text");
|
|
327
|
+
// Calculate step for arrow key increments. Auto-derived from min/displayDivisor: when the min in display units is between 0 and 1 (exclusive), use it as the
|
|
328
|
+
// step (e.g., 500ms → 0.5s step); otherwise step is 1 whole display unit. This gives meaningful arrow increments and constrains input to a sensible value grid
|
|
329
|
+
// (e.g., 0.5, 1.0, 1.5, ... for half-second steps) rather than the old 1/displayDivisor approach which produced unusably small increments.
|
|
330
|
+
let step = "1";
|
|
331
|
+
if (setting.displayDivisor && (setting.min !== undefined)) {
|
|
332
|
+
const displayMin = setting.min / setting.displayDivisor;
|
|
333
|
+
step = (displayMin > 0) && (displayMin < 1) ? String(displayMin) : "1";
|
|
334
|
+
}
|
|
335
|
+
else if (setting.type === "float") {
|
|
336
|
+
step = "0.01";
|
|
337
|
+
}
|
|
338
|
+
const inputAttrs = [
|
|
339
|
+
"class=\"form-input " + getFieldWidthClass(setting) + (hasError ? " error" : "") + "\"",
|
|
340
|
+
"type=\"" + inputType + "\"",
|
|
341
|
+
"id=\"" + inputId + "\"",
|
|
342
|
+
"name=\"" + setting.path + "\"",
|
|
343
|
+
"data-default=\"" + escapeHtml(String(displayDefault ?? "")) + "\""
|
|
344
|
+
];
|
|
345
|
+
// Add value.
|
|
346
|
+
if (displayValue !== null) {
|
|
347
|
+
inputAttrs.push("value=\"" + escapeHtml(String(displayValue)) + "\"");
|
|
348
|
+
}
|
|
349
|
+
// Add step for numbers.
|
|
350
|
+
if (inputType === "number") {
|
|
351
|
+
inputAttrs.push("step=\"" + step + "\"");
|
|
352
|
+
}
|
|
353
|
+
// Add min/max if specified (using display values).
|
|
354
|
+
if (displayMin !== undefined) {
|
|
355
|
+
inputAttrs.push("min=\"" + String(displayMin) + "\"");
|
|
356
|
+
}
|
|
357
|
+
if (displayMax !== undefined) {
|
|
358
|
+
inputAttrs.push("max=\"" + String(displayMax) + "\"");
|
|
359
|
+
}
|
|
360
|
+
// Disable if overridden by env var.
|
|
361
|
+
if (isDisabled) {
|
|
362
|
+
inputAttrs.push("disabled");
|
|
363
|
+
}
|
|
364
|
+
if (isDependencyDisabled) {
|
|
365
|
+
inputAttrs.push("tabindex=\"-1\"");
|
|
366
|
+
}
|
|
367
|
+
lines.push("<input " + inputAttrs.join(" ") + ">");
|
|
368
|
+
}
|
|
369
|
+
// Add unit label if present.
|
|
370
|
+
if (displayUnit) {
|
|
371
|
+
lines.push("<span class=\"form-unit\">" + escapeHtml(displayUnit) + "</span>");
|
|
372
|
+
}
|
|
373
|
+
// Add reset button for modified settings.
|
|
374
|
+
if (isModified) {
|
|
375
|
+
lines.push("<button type=\"button\" class=\"btn-reset\" onclick=\"resetSetting('" + escapeHtml(setting.path) +
|
|
376
|
+
"')\" title=\"Reset to default\" aria-label=\"Reset to default\">↻</button>");
|
|
377
|
+
}
|
|
378
|
+
lines.push("</div>");
|
|
379
|
+
// Add description.
|
|
380
|
+
lines.push("<div class=\"form-description\">" + escapeHtml(setting.description) + "</div>");
|
|
381
|
+
// Emit block-level content that type branches deferred to outside the form-row flex container.
|
|
382
|
+
for (const content of postDescription) {
|
|
383
|
+
lines.push(content);
|
|
384
|
+
}
|
|
385
|
+
// Add disabled reason warning when a setting is locked out due to an upstream issue.
|
|
386
|
+
if (setting.disabledReason) {
|
|
387
|
+
lines.push("<div class=\"form-warning\">" + escapeHtml(setting.disabledReason) + "</div>");
|
|
388
|
+
}
|
|
389
|
+
// Add inline message for degraded preset.
|
|
390
|
+
if (selectedPresetDegradedTo) {
|
|
391
|
+
lines.push("<div class=\"form-warning\">Your display cannot support this resolution. Streams will use " +
|
|
392
|
+
escapeHtml(selectedPresetDegradedTo) + " instead.</div>");
|
|
393
|
+
}
|
|
394
|
+
// Add default value hint with properly pluralized unit.
|
|
395
|
+
let defaultDisplay;
|
|
396
|
+
if (setting.type === "checkboxList") {
|
|
397
|
+
// For checkbox lists, show "none" instead of the raw JSON "[]".
|
|
398
|
+
defaultDisplay = "none";
|
|
399
|
+
}
|
|
400
|
+
else if (displayDefault === null) {
|
|
401
|
+
defaultDisplay = "autodetect";
|
|
402
|
+
}
|
|
403
|
+
else if (typeof displayDefault === "number") {
|
|
404
|
+
defaultDisplay = formatValueForDisplay(displayDefault, setting.type);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
defaultDisplay = displayDefault;
|
|
408
|
+
}
|
|
409
|
+
// Format the unit with correct pluralization based on the default value.
|
|
410
|
+
let formattedUnit = "";
|
|
411
|
+
if (displayUnit && (typeof displayDefault === "number")) {
|
|
412
|
+
formattedUnit = " " + formatUnitForValue(displayDefault, displayUnit);
|
|
413
|
+
}
|
|
414
|
+
else if (displayUnit) {
|
|
415
|
+
formattedUnit = " " + displayUnit;
|
|
416
|
+
}
|
|
417
|
+
lines.push("<div class=\"form-default\">Default: " + escapeHtml(defaultDisplay) + formattedUnit + "</div>");
|
|
418
|
+
// Add env var override notice if applicable.
|
|
419
|
+
if (isDisabled && setting.envVar && envOverride) {
|
|
420
|
+
lines.push("<div class=\"form-env\">Overridden by environment variable: <code>" + escapeHtml(setting.envVar) + "=" +
|
|
421
|
+
escapeHtml(envOverride) + "</code></div>");
|
|
422
|
+
}
|
|
423
|
+
// Add validation error if present.
|
|
424
|
+
if (hasError) {
|
|
425
|
+
lines.push("<div class=\"form-error\">" + escapeHtml(validationError) + "</div>");
|
|
426
|
+
}
|
|
427
|
+
lines.push("</div>");
|
|
428
|
+
return lines.join("\n");
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Validates a single setting value (in storage units, after conversion from display units).
|
|
432
|
+
* @param setting - The setting metadata.
|
|
433
|
+
* @param value - The value to validate (in storage units).
|
|
434
|
+
* @returns Validation error message if invalid, undefined if valid.
|
|
435
|
+
*/
|
|
436
|
+
function validateSettingValue(setting, value) {
|
|
437
|
+
// Allow empty string for path type (means null/autodetect).
|
|
438
|
+
if ((setting.type === "path") && ((value === "") || (value === null))) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
// Validate string type with validValues.
|
|
442
|
+
if ((setting.type === "string") && setting.validValues && (setting.validValues.length > 0)) {
|
|
443
|
+
if (!setting.validValues.includes(value)) {
|
|
444
|
+
return setting.label + " must be one of: " + setting.validValues.join(", ");
|
|
445
|
+
}
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
// Validate based on type.
|
|
449
|
+
switch (setting.type) {
|
|
450
|
+
case "boolean": {
|
|
451
|
+
// After parseFormValue, value should be a boolean. No additional validation needed since the dropdown constrains input.
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
case "checkboxList": {
|
|
455
|
+
if (!Array.isArray(value)) {
|
|
456
|
+
return "Must be an array.";
|
|
457
|
+
}
|
|
458
|
+
const knownSlugs = new Set(getProviderSlugs());
|
|
459
|
+
for (const slug of value) {
|
|
460
|
+
if (!knownSlugs.has(slug)) {
|
|
461
|
+
return "Unknown provider: " + slug + ".";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
case "integer":
|
|
467
|
+
case "port": {
|
|
468
|
+
const numValue = Number(value);
|
|
469
|
+
const error = validatePositiveInt(setting.label, numValue, setting.min, setting.max);
|
|
470
|
+
return error ?? undefined;
|
|
471
|
+
}
|
|
472
|
+
case "float": {
|
|
473
|
+
const numValue = Number(value);
|
|
474
|
+
const error = validatePositiveNumber(setting.label, numValue, setting.min, setting.max);
|
|
475
|
+
return error ?? undefined;
|
|
476
|
+
}
|
|
477
|
+
case "host": {
|
|
478
|
+
if ((typeof value !== "string") || (value.trim() === "")) {
|
|
479
|
+
return setting.label + " must be a non-empty string";
|
|
480
|
+
}
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
case "path": {
|
|
484
|
+
// Path can be any string or empty.
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
case "string": {
|
|
488
|
+
// String without validValues - no validation needed.
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
default: {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Parses a form value into the appropriate type for a setting, converting from display units to storage units if necessary.
|
|
498
|
+
* @param setting - The setting metadata.
|
|
499
|
+
* @param value - The raw string value from the form (in display units).
|
|
500
|
+
* @returns The parsed value (in storage units).
|
|
501
|
+
*/
|
|
502
|
+
function parseFormValue(setting, value) {
|
|
503
|
+
// Handle empty values for path type.
|
|
504
|
+
if ((setting.type === "path") && (value.trim() === "")) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
switch (setting.type) {
|
|
508
|
+
case "boolean": {
|
|
509
|
+
// Convert string "true" to boolean true, anything else to false.
|
|
510
|
+
return value === "true";
|
|
511
|
+
}
|
|
512
|
+
case "checkboxList": {
|
|
513
|
+
// The hidden input holds a JSON-encoded array of strings.
|
|
514
|
+
return JSON.parse(value);
|
|
515
|
+
}
|
|
516
|
+
case "integer":
|
|
517
|
+
case "port": {
|
|
518
|
+
const displayValue = parseFloat(value);
|
|
519
|
+
// Convert from display units to storage units if displayDivisor is set.
|
|
520
|
+
if (setting.displayDivisor) {
|
|
521
|
+
return Math.round(displayValue * setting.displayDivisor);
|
|
522
|
+
}
|
|
523
|
+
return parseInt(value, 10);
|
|
524
|
+
}
|
|
525
|
+
case "float": {
|
|
526
|
+
const displayValue = parseFloat(value);
|
|
527
|
+
// Convert from display units to storage units if displayDivisor is set.
|
|
528
|
+
if (setting.displayDivisor) {
|
|
529
|
+
return displayValue * setting.displayDivisor;
|
|
530
|
+
}
|
|
531
|
+
return displayValue;
|
|
532
|
+
}
|
|
533
|
+
case "host":
|
|
534
|
+
case "path":
|
|
535
|
+
case "string": {
|
|
536
|
+
return value;
|
|
537
|
+
}
|
|
538
|
+
default: {
|
|
539
|
+
return value;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Generates the content for the Settings tab with non-collapsible section headers.
|
|
545
|
+
* @param validationErrors - Map of setting path to validation error message.
|
|
546
|
+
* @param formValues - Map of setting path to submitted form value.
|
|
547
|
+
* @returns HTML string for the Settings tab content.
|
|
548
|
+
*/
|
|
549
|
+
export function generateSettingsTabContent(validationErrors, formValues) {
|
|
550
|
+
const sections = getSettingsTabSections();
|
|
551
|
+
const tabs = getUITabs();
|
|
552
|
+
const settingsTab = tabs.find((t) => t.id === "settings");
|
|
553
|
+
const defaults = getDefaults();
|
|
554
|
+
const envOverrides = getEnvOverrides();
|
|
555
|
+
const lines = [];
|
|
556
|
+
// Panel header with description and reset button.
|
|
557
|
+
lines.push("<div class=\"panel-header\">");
|
|
558
|
+
lines.push("<p class=\"settings-panel-description\">" + escapeHtml(settingsTab?.description ?? "Configure common options.") + "</p>");
|
|
559
|
+
lines.push("<a href=\"#\" class=\"panel-reset\" onclick=\"resetTabToDefaults('settings'); return false;\">Reset to Defaults</a>");
|
|
560
|
+
lines.push("</div>");
|
|
561
|
+
// Generate each section with a header.
|
|
562
|
+
for (const section of sections) {
|
|
563
|
+
lines.push("<div class=\"settings-section\">");
|
|
564
|
+
lines.push("<div class=\"settings-section-header\">" + escapeHtml(section.displayName) + "</div>");
|
|
565
|
+
// Generate setting fields for this section.
|
|
566
|
+
for (const setting of section.settings) {
|
|
567
|
+
const currentValue = formValues?.get(setting.path) ?? getNestedValue(CONFIG, setting.path);
|
|
568
|
+
const defaultValue = getNestedValue(defaults, setting.path);
|
|
569
|
+
const envOverride = envOverrides.get(setting.path);
|
|
570
|
+
const validationError = validationErrors?.get(setting.path);
|
|
571
|
+
lines.push(generateSettingField(setting, currentValue, defaultValue, envOverride, validationError));
|
|
572
|
+
}
|
|
573
|
+
lines.push("</div>");
|
|
574
|
+
}
|
|
575
|
+
return lines.join("\n");
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Generates the content for a collapsible section within the Advanced tab.
|
|
579
|
+
* @param section - The section definition.
|
|
580
|
+
* @param validationErrors - Map of setting path to validation error message.
|
|
581
|
+
* @param formValues - Map of setting path to submitted form value.
|
|
582
|
+
* @returns HTML string for the section.
|
|
583
|
+
*/
|
|
584
|
+
export function generateCollapsibleSection(section, validationErrors, formValues) {
|
|
585
|
+
const defaults = getDefaults();
|
|
586
|
+
const envOverrides = getEnvOverrides();
|
|
587
|
+
const lines = [];
|
|
588
|
+
const settingCount = section.settings.length;
|
|
589
|
+
// Section container.
|
|
590
|
+
lines.push("<div class=\"advanced-section\" data-section=\"" + escapeHtml(section.id) + "\">");
|
|
591
|
+
// Section header with chevron, title, and count.
|
|
592
|
+
lines.push("<div class=\"section-header\" onclick=\"toggleSection('" + escapeHtml(section.id) + "')\">");
|
|
593
|
+
lines.push("<span class=\"section-chevron\">▶</span>");
|
|
594
|
+
lines.push("<span class=\"section-title\">" + escapeHtml(section.displayName) + "</span>");
|
|
595
|
+
lines.push("<span class=\"section-count\">(" + String(settingCount) + " setting" + (settingCount === 1 ? "" : "s") + ")</span>");
|
|
596
|
+
lines.push("</div>");
|
|
597
|
+
// Section content (collapsed by default).
|
|
598
|
+
lines.push("<div class=\"section-content\">");
|
|
599
|
+
// Generate setting fields for this section.
|
|
600
|
+
for (const setting of section.settings) {
|
|
601
|
+
const currentValue = formValues?.get(setting.path) ?? getNestedValue(CONFIG, setting.path);
|
|
602
|
+
const defaultValue = getNestedValue(defaults, setting.path);
|
|
603
|
+
const envOverride = envOverrides.get(setting.path);
|
|
604
|
+
const validationError = validationErrors?.get(setting.path);
|
|
605
|
+
lines.push(generateSettingField(setting, currentValue, defaultValue, envOverride, validationError));
|
|
606
|
+
}
|
|
607
|
+
lines.push("</div>"); // End section-content.
|
|
608
|
+
lines.push("</div>"); // End advanced-section.
|
|
609
|
+
return lines.join("\n");
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Generates the content for the Advanced tab with collapsible sections.
|
|
613
|
+
* @param validationErrors - Map of setting path to validation error message.
|
|
614
|
+
* @param formValues - Map of setting path to submitted form value.
|
|
615
|
+
* @returns HTML string for the Advanced tab content.
|
|
616
|
+
*/
|
|
617
|
+
export function generateAdvancedTabContent(validationErrors, formValues) {
|
|
618
|
+
const sections = getAdvancedSections();
|
|
619
|
+
const tabs = getUITabs();
|
|
620
|
+
const advancedTab = tabs.find((t) => t.id === "advanced");
|
|
621
|
+
const lines = [];
|
|
622
|
+
// Panel header with description and reset button.
|
|
623
|
+
lines.push("<div class=\"panel-header\">");
|
|
624
|
+
lines.push("<p class=\"settings-panel-description\">" + escapeHtml(advancedTab?.description ?? "Expert tuning options.") + "</p>");
|
|
625
|
+
lines.push("<a href=\"#\" class=\"panel-reset\" onclick=\"resetTabToDefaults('advanced'); return false;\">Reset All to Defaults</a>");
|
|
626
|
+
lines.push("</div>");
|
|
627
|
+
// Generate each collapsible section.
|
|
628
|
+
for (const section of sections) {
|
|
629
|
+
lines.push(generateCollapsibleSection(section, validationErrors, formValues));
|
|
630
|
+
}
|
|
631
|
+
return lines.join("\n");
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Generates the config path display for settings.
|
|
635
|
+
* @returns HTML string with config path.
|
|
636
|
+
*/
|
|
637
|
+
export function generateSettingsFormFooter() {
|
|
638
|
+
return "<div class=\"config-path\">Configuration file: <code>" + escapeHtml(getConfigFilePath()) + "</code></div>";
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Checks if there are any environment variable overrides for configuration settings.
|
|
642
|
+
* @returns True if any settings are overridden by environment variables.
|
|
643
|
+
*/
|
|
644
|
+
export function hasEnvOverrides() {
|
|
645
|
+
return getEnvOverrides().size > 0;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Installs all settings-related route handlers on the Express application.
|
|
649
|
+
* @param app - The Express application.
|
|
650
|
+
*/
|
|
651
|
+
export function setupSettingsRoutes(app) {
|
|
652
|
+
// POST /config - Save configuration and restart. Returns JSON response.
|
|
653
|
+
app.post("/config", async (req, res) => {
|
|
654
|
+
try {
|
|
655
|
+
const envOverrides = getEnvOverrides();
|
|
656
|
+
const validationErrors = {};
|
|
657
|
+
const newConfig = {};
|
|
658
|
+
// Process each setting from the nested JSON structure.
|
|
659
|
+
for (const settings of Object.values(CONFIG_METADATA)) {
|
|
660
|
+
for (const setting of settings) {
|
|
661
|
+
// Skip settings overridden by environment variables.
|
|
662
|
+
if (envOverrides.has(setting.path)) {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
// Get the value from the nested JSON body using the setting path.
|
|
666
|
+
const rawValue = getNestedValue(req.body, setting.path);
|
|
667
|
+
// Skip undefined values (not submitted).
|
|
668
|
+
if (rawValue === undefined) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
// Parse the value (convert from display units to storage units if needed).
|
|
672
|
+
const parsedValue = parseFormValue(setting, String(rawValue));
|
|
673
|
+
// Validate the value.
|
|
674
|
+
const validationError = validateSettingValue(setting, parsedValue);
|
|
675
|
+
if (validationError) {
|
|
676
|
+
validationErrors[setting.path] = validationError;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
// Add to new config.
|
|
680
|
+
setNestedValue(newConfig, setting.path, parsedValue);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// If there are validation errors, return them as JSON.
|
|
684
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
685
|
+
res.status(400).json({ errors: validationErrors, success: false });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// The settings form submits CONFIG_METADATA values (scalars and checkboxLists). The config file also stores fields managed by their own endpoints: disabled
|
|
689
|
+
// channel list, enabled provider filter, and the auto-generated HDHomeRun device ID. We must preserve these from the existing file, otherwise saving wipes them.
|
|
690
|
+
const existingResult = await loadUserConfig();
|
|
691
|
+
const existingConfig = existingResult.config;
|
|
692
|
+
if (Array.isArray(existingConfig.channels?.disabledPredefined) && (existingConfig.channels.disabledPredefined.length > 0)) {
|
|
693
|
+
newConfig.channels ??= {};
|
|
694
|
+
newConfig.channels.disabledPredefined = existingConfig.channels.disabledPredefined;
|
|
695
|
+
}
|
|
696
|
+
if (Array.isArray(existingConfig.channels?.enabledProviders) && (existingConfig.channels.enabledProviders.length > 0)) {
|
|
697
|
+
newConfig.channels ??= {};
|
|
698
|
+
newConfig.channels.enabledProviders = existingConfig.channels.enabledProviders;
|
|
699
|
+
}
|
|
700
|
+
if ((typeof existingConfig.hdhr?.deviceId === "string") && (existingConfig.hdhr.deviceId.length > 0)) {
|
|
701
|
+
newConfig.hdhr ??= {};
|
|
702
|
+
newConfig.hdhr.deviceId = existingConfig.hdhr.deviceId;
|
|
703
|
+
}
|
|
704
|
+
if ((typeof existingConfig.logging?.debugFilter === "string") && (existingConfig.logging.debugFilter.length > 0)) {
|
|
705
|
+
newConfig.logging ??= {};
|
|
706
|
+
newConfig.logging.debugFilter = existingConfig.logging.debugFilter;
|
|
707
|
+
}
|
|
708
|
+
// Filter out values that match defaults to keep the config file clean.
|
|
709
|
+
const filteredConfig = filterDefaults(newConfig);
|
|
710
|
+
// Save the configuration.
|
|
711
|
+
await saveUserConfig(filteredConfig);
|
|
712
|
+
// Schedule restart after response is sent and return success response with restart info.
|
|
713
|
+
const restartResult = scheduleServerRestart("to apply configuration changes");
|
|
714
|
+
res.json({
|
|
715
|
+
activeStreams: restartResult.activeStreams,
|
|
716
|
+
deferred: restartResult.deferred,
|
|
717
|
+
message: restartResult.message,
|
|
718
|
+
success: true,
|
|
719
|
+
willRestart: restartResult.willRestart
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
LOG.error("Failed to save configuration: %s.", formatError(error));
|
|
724
|
+
res.status(500).json({ message: "Failed to save configuration: " + formatError(error), success: false });
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
// GET /config/export - Export current configuration as JSON.
|
|
728
|
+
app.get("/config/export", async (_req, res) => {
|
|
729
|
+
try {
|
|
730
|
+
const result = await loadUserConfig();
|
|
731
|
+
res.setHeader("Content-Type", "application/json");
|
|
732
|
+
res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-config.json\"");
|
|
733
|
+
res.send(JSON.stringify(result.config, null, 2) + "\n");
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
LOG.error("Failed to export configuration: %s.", formatError(error));
|
|
737
|
+
res.status(500).json({ error: "Failed to export configuration: " + formatError(error) });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
// POST /config/import - Import configuration from JSON.
|
|
741
|
+
app.post("/config/import", async (req, res) => {
|
|
742
|
+
try {
|
|
743
|
+
// Cast to unknown first for runtime validation, then to UserConfig after validation.
|
|
744
|
+
const rawConfig = req.body;
|
|
745
|
+
// Basic validation - ensure it's an object.
|
|
746
|
+
if ((typeof rawConfig !== "object") || (rawConfig === null) || Array.isArray(rawConfig)) {
|
|
747
|
+
res.status(400).json({ error: "Invalid configuration format: expected an object." });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const importedConfig = rawConfig;
|
|
751
|
+
// Validate each setting in the imported config.
|
|
752
|
+
const validationErrors = [];
|
|
753
|
+
for (const [category, settings] of Object.entries(CONFIG_METADATA)) {
|
|
754
|
+
const categoryConfig = importedConfig[category];
|
|
755
|
+
if (categoryConfig === undefined) {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if ((typeof categoryConfig !== "object") || (categoryConfig === null)) {
|
|
759
|
+
validationErrors.push("Invalid " + category + " configuration: expected an object.");
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
for (const setting of settings) {
|
|
763
|
+
const pathParts = setting.path.split(".");
|
|
764
|
+
let value = importedConfig;
|
|
765
|
+
for (const part of pathParts) {
|
|
766
|
+
if ((value === null) || (value === undefined) || (typeof value !== "object")) {
|
|
767
|
+
value = undefined;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
value = value[part];
|
|
771
|
+
}
|
|
772
|
+
if (value === undefined) {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
// Validate the value.
|
|
776
|
+
const error = validateSettingValue(setting, value);
|
|
777
|
+
if (error) {
|
|
778
|
+
validationErrors.push(setting.label + ": " + error);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (validationErrors.length > 0) {
|
|
783
|
+
res.status(400).json({ error: "Validation errors:\n" + validationErrors.join("\n") });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
// Filter out values that match defaults to keep the config file clean.
|
|
787
|
+
const filteredConfig = filterDefaults(importedConfig);
|
|
788
|
+
// Save the imported configuration.
|
|
789
|
+
await saveUserConfig(filteredConfig);
|
|
790
|
+
// Schedule restart after response is sent and return success response with restart info.
|
|
791
|
+
const restartResult = scheduleServerRestart("after configuration import");
|
|
792
|
+
res.json({
|
|
793
|
+
activeStreams: restartResult.activeStreams,
|
|
794
|
+
deferred: restartResult.deferred,
|
|
795
|
+
message: restartResult.message,
|
|
796
|
+
success: true,
|
|
797
|
+
willRestart: restartResult.willRestart
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
LOG.error("Failed to import configuration: %s.", formatError(error));
|
|
802
|
+
res.status(500).json({ error: "Failed to import configuration: " + formatError(error) });
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
// POST /config/restart-now - Force immediate server restart regardless of active streams.
|
|
806
|
+
app.post("/config/restart-now", (_req, res) => {
|
|
807
|
+
if (!isRunningAsService()) {
|
|
808
|
+
res.status(400).json({ message: "Cannot restart: not running as a service.", success: false });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
LOG.info("Forced restart requested via API.");
|
|
812
|
+
res.json({ message: "Server is restarting...", success: true });
|
|
813
|
+
// Close the browser first to avoid orphan Chrome processes.
|
|
814
|
+
setTimeout(() => {
|
|
815
|
+
LOG.info("Exiting for forced service manager restart.");
|
|
816
|
+
void import("../../browser/index.js").then(async (mod) => mod.closeBrowser()).then(() => { process.exit(0); }).catch(() => { process.exit(1); });
|
|
817
|
+
}, 500);
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
//# sourceMappingURL=settings.js.map
|