prismcast 1.4.1 → 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 +537 -211
- 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 +47 -6
- package/dist/config/providers.js +211 -17
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +1 -12
- package/dist/config/sites.js +40 -7
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +41 -19
- package/dist/config/userChannels.js +152 -35
- 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/theme.js +7 -1
- package/dist/routes/theme.js.map +1 -1
- 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/showInfo.js +12 -14
- package/dist/streaming/showInfo.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 -406
- 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 +6 -4
- package/dist/routes/config.d.ts +0 -72
- package/dist/routes/config.js +0 -1987
- package/dist/routes/config.js.map +0 -1
- package/dist/routes/root.js +0 -2959
- package/dist/routes/root.js.map +0 -1
- /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
|
|
2
|
+
import { VALID_OPTIONAL_COLUMNS, generateChannelRowHtml } from "./table.js";
|
|
3
|
+
import { VALID_SORT_FIELDS, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
|
|
4
|
+
import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.js";
|
|
5
|
+
import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
|
|
6
|
+
import { CONFIG } from "../../../config/index.js";
|
|
7
|
+
import { getProfiles } from "../../../config/profiles.js";
|
|
8
|
+
// Fields that appear in the generated M3U playlist and affect Channels DVR's view of the channel. Used to decide whether the playlist reload hint is shown.
|
|
9
|
+
const M3U_FIELDS = ["channelNumber", "name", "stationId", "tvgShift"];
|
|
10
|
+
const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change.";
|
|
11
|
+
/**
|
|
12
|
+
* Checks whether a stored channel entry contains any fields that affect the M3U playlist. Used to decide whether to append the playlist reload hint when reverting
|
|
13
|
+
* or removing an override.
|
|
14
|
+
* @param stored - The stored channel data to check (may be a delta or full definition).
|
|
15
|
+
* @returns The PLAYLIST_HINT string if M3U-relevant fields are present, empty string otherwise.
|
|
16
|
+
*/
|
|
17
|
+
function playlistHintForStored(stored) {
|
|
18
|
+
return M3U_FIELDS.some((f) => f in stored) ? PLAYLIST_HINT : "";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Installs all channel-related route handlers on the Express application.
|
|
22
|
+
* @param app - The Express application.
|
|
23
|
+
*/
|
|
24
|
+
export function setupChannelRoutes(app) {
|
|
25
|
+
// GET /config/channels/export - Export user channels as JSON. Deltas are resolved to full definitions for backward compatibility with the import validator
|
|
26
|
+
// which requires url as a required field.
|
|
27
|
+
app.get("/config/channels/export", (_req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
const storedChannels = getUserChannels();
|
|
30
|
+
const resolved = {};
|
|
31
|
+
for (const [key, stored] of Object.entries(storedChannels)) {
|
|
32
|
+
resolved[key] = resolveStoredChannel(key, stored);
|
|
33
|
+
}
|
|
34
|
+
res.setHeader("Content-Type", "application/json");
|
|
35
|
+
res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
|
|
36
|
+
res.send(JSON.stringify(resolved, null, 2) + "\n");
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
LOG.error("Failed to export channels: %s.", formatError(error));
|
|
40
|
+
res.status(500).json({ error: "Failed to export channels: " + formatError(error) });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// POST /config/channels/import - Import channels from JSON, replacing all existing user channels.
|
|
44
|
+
app.post("/config/channels/import", async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const rawData = req.body;
|
|
47
|
+
// Validate the imported channels.
|
|
48
|
+
const validProfiles = getProfiles().map((p) => p.name);
|
|
49
|
+
const validationResult = validateImportedChannels(rawData, validProfiles);
|
|
50
|
+
if (!validationResult.valid) {
|
|
51
|
+
res.status(400).json({ error: "Validation errors:\n" + validationResult.errors.join("\n") });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Save the imported channels, replacing all existing user channels.
|
|
55
|
+
await saveUserChannels(validationResult.channels);
|
|
56
|
+
const channelCount = Object.keys(validationResult.channels).length;
|
|
57
|
+
// Send success response. Changes take effect immediately due to hot-reloading in saveUserChannels().
|
|
58
|
+
res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
LOG.error("Failed to import channels: %s.", formatError(error));
|
|
62
|
+
res.status(500).json({ error: "Failed to import channels: " + formatError(error) });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// POST /config/channels/import-m3u - Import channels from M3U playlist file.
|
|
66
|
+
app.post("/config/channels/import-m3u", async (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
const body = req.body;
|
|
69
|
+
const content = body.content;
|
|
70
|
+
const conflictMode = body.conflictMode ?? "skip";
|
|
71
|
+
// Validate content is provided.
|
|
72
|
+
if (!content || (typeof content !== "string") || (content.trim() === "")) {
|
|
73
|
+
res.status(400).json({ error: "No M3U content provided.", success: false });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Validate conflict mode.
|
|
77
|
+
if ((conflictMode !== "skip") && (conflictMode !== "replace")) {
|
|
78
|
+
res.status(400).json({ error: "Invalid conflict mode. Must be 'skip' or 'replace'.", success: false });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Parse the M3U content.
|
|
82
|
+
const parseResult = parseM3U(content);
|
|
83
|
+
// Check for empty result.
|
|
84
|
+
if (parseResult.channels.length === 0) {
|
|
85
|
+
res.status(400).json({
|
|
86
|
+
error: "No channels found in M3U file." + (parseResult.errors.length > 0 ? " Parse errors: " + parseResult.errors.join("; ") : ""),
|
|
87
|
+
success: false
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Load existing user channels.
|
|
92
|
+
const loadResult = await loadUserChannels();
|
|
93
|
+
const existingChannels = loadResult.parseError ? {} : loadResult.channels;
|
|
94
|
+
// Track import statistics.
|
|
95
|
+
const conflicts = [];
|
|
96
|
+
const importErrors = [];
|
|
97
|
+
const seenKeys = new Set();
|
|
98
|
+
let imported = 0;
|
|
99
|
+
let replaced = 0;
|
|
100
|
+
let skipped = 0;
|
|
101
|
+
// Process each parsed channel. Sanitize string values before validation to strip non-printable characters.
|
|
102
|
+
for (const m3uChannel of parseResult.channels) {
|
|
103
|
+
m3uChannel.name = sanitizeString(m3uChannel.name);
|
|
104
|
+
m3uChannel.url = sanitizeString(m3uChannel.url);
|
|
105
|
+
m3uChannel.stationId &&= sanitizeString(m3uChannel.stationId);
|
|
106
|
+
// Generate the channel key from the name.
|
|
107
|
+
const key = generateChannelKey(m3uChannel.name);
|
|
108
|
+
// Validate the generated key.
|
|
109
|
+
if (!key || (key.length === 0)) {
|
|
110
|
+
importErrors.push("Could not generate key for channel '" + m3uChannel.name + "'.");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Skip duplicate keys within the same M3U file (first occurrence wins).
|
|
114
|
+
if (seenKeys.has(key)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
seenKeys.add(key);
|
|
118
|
+
// Validate the URL.
|
|
119
|
+
const urlError = validateChannelUrl(m3uChannel.url);
|
|
120
|
+
if (urlError) {
|
|
121
|
+
importErrors.push("Channel '" + m3uChannel.name + "': " + urlError);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Validate the name.
|
|
125
|
+
const nameError = validateChannelName(m3uChannel.name);
|
|
126
|
+
if (nameError) {
|
|
127
|
+
importErrors.push("Channel '" + m3uChannel.name + "': " + nameError);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// Check for conflicts with existing channels.
|
|
131
|
+
if (key in existingChannels) {
|
|
132
|
+
conflicts.push(key);
|
|
133
|
+
if (conflictMode === "skip") {
|
|
134
|
+
skipped++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Replace mode - count as replaced instead of imported.
|
|
138
|
+
replaced++;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
imported++;
|
|
142
|
+
}
|
|
143
|
+
// Build the channel object.
|
|
144
|
+
const channel = {
|
|
145
|
+
name: m3uChannel.name,
|
|
146
|
+
url: m3uChannel.url
|
|
147
|
+
};
|
|
148
|
+
// Add station ID if present.
|
|
149
|
+
if (m3uChannel.stationId) {
|
|
150
|
+
channel.stationId = m3uChannel.stationId;
|
|
151
|
+
}
|
|
152
|
+
// Add to channels collection.
|
|
153
|
+
existingChannels[key] = channel;
|
|
154
|
+
}
|
|
155
|
+
// Save the updated channels.
|
|
156
|
+
await saveUserChannels(existingChannels);
|
|
157
|
+
// Log the import.
|
|
158
|
+
LOG.info("M3U import completed: %d imported, %d replaced, %d skipped.", imported, replaced, skipped);
|
|
159
|
+
// Build response.
|
|
160
|
+
res.json({
|
|
161
|
+
conflicts,
|
|
162
|
+
errors: [...parseResult.errors, ...importErrors],
|
|
163
|
+
imported,
|
|
164
|
+
replaced,
|
|
165
|
+
skipped,
|
|
166
|
+
success: true
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
LOG.error("Failed to import M3U channels: %s.", formatError(error));
|
|
171
|
+
res.status(500).json({ error: "Failed to import channels: " + formatError(error), success: false });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
// POST /config/channels/toggle-predefined - Toggle a single predefined channel's enabled/disabled state.
|
|
175
|
+
app.post("/config/channels/toggle-predefined", async (req, res) => {
|
|
176
|
+
try {
|
|
177
|
+
const body = req.body;
|
|
178
|
+
const key = body.key?.trim();
|
|
179
|
+
const enabled = body.enabled;
|
|
180
|
+
// Validate key is provided.
|
|
181
|
+
if (!key) {
|
|
182
|
+
res.status(400).json({ error: "Channel key is required.", success: false });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Validate enabled is provided.
|
|
186
|
+
if (typeof enabled !== "boolean") {
|
|
187
|
+
res.status(400).json({ error: "Enabled state (true/false) is required.", success: false });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Validate the channel exists as a predefined channel.
|
|
191
|
+
if (!isPredefinedChannel(key)) {
|
|
192
|
+
res.status(400).json({ error: "Channel '" + key + "' is not a predefined channel.", success: false });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Load current config.
|
|
196
|
+
const configResult = await loadUserConfig();
|
|
197
|
+
const userConfig = configResult.config;
|
|
198
|
+
// Initialize channels.disabledPredefined if not present.
|
|
199
|
+
userConfig.channels ??= {};
|
|
200
|
+
userConfig.channels.disabledPredefined ??= [];
|
|
201
|
+
const disabledSet = new Set(userConfig.channels.disabledPredefined);
|
|
202
|
+
if (enabled) {
|
|
203
|
+
// Enable: remove from disabled list.
|
|
204
|
+
disabledSet.delete(key);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
// Disable: add to disabled list.
|
|
208
|
+
disabledSet.add(key);
|
|
209
|
+
}
|
|
210
|
+
// Update and save config.
|
|
211
|
+
userConfig.channels.disabledPredefined = [...disabledSet].sort();
|
|
212
|
+
await saveUserConfig(userConfig);
|
|
213
|
+
// Update the runtime CONFIG to reflect the change immediately.
|
|
214
|
+
CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
|
|
215
|
+
LOG.info("Predefined channel '%s' %s.", key, enabled ? "enabled" : "disabled");
|
|
216
|
+
res.json({ enabled, key, success: true });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
LOG.error("Failed to toggle predefined channel: %s.", formatError(error));
|
|
220
|
+
res.status(500).json({ error: "Failed to toggle channel: " + formatError(error), success: false });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
// POST /config/provider - Update provider selection for a multi-provider channel.
|
|
224
|
+
app.post("/config/provider", async (req, res) => {
|
|
225
|
+
try {
|
|
226
|
+
const body = req.body;
|
|
227
|
+
const channelKey = body.channel?.trim();
|
|
228
|
+
const providerKey = body.provider?.trim();
|
|
229
|
+
// Validate channel key is provided.
|
|
230
|
+
if (!channelKey) {
|
|
231
|
+
res.status(400).json({ error: "Channel key is required.", success: false });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Validate provider key is provided.
|
|
235
|
+
if (!providerKey) {
|
|
236
|
+
res.status(400).json({ error: "Provider key is required.", success: false });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// Canonicalize the channel key to ensure selections are stored under the canonical key, not variant keys.
|
|
240
|
+
const canonicalKey = getCanonicalKey(channelKey);
|
|
241
|
+
// Validate the channel has provider options.
|
|
242
|
+
const providerGroup = getProviderGroup(canonicalKey);
|
|
243
|
+
if (!providerGroup) {
|
|
244
|
+
res.status(400).json({ error: "Channel '" + canonicalKey + "' does not have multiple providers.", success: false });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// Validate the provider key is valid for this channel.
|
|
248
|
+
const validProviderKeys = providerGroup.variants.map((v) => v.key);
|
|
249
|
+
if (!validProviderKeys.includes(providerKey)) {
|
|
250
|
+
res.status(400).json({ error: "Invalid provider '" + providerKey + "' for channel '" + canonicalKey + "'.", success: false });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Update the provider selection.
|
|
254
|
+
setProviderSelection(canonicalKey, providerKey);
|
|
255
|
+
// Save to disk.
|
|
256
|
+
await saveProviderSelections();
|
|
257
|
+
// Resolve display names for logging before generating the row HTML.
|
|
258
|
+
const canonicalChannel = getResolvedChannel(canonicalKey);
|
|
259
|
+
const variantChannel = getResolvedChannel(providerKey);
|
|
260
|
+
const channelName = canonicalChannel?.name ?? canonicalKey;
|
|
261
|
+
const providerLabel = variantChannel ? getChannelProviderLabel(variantChannel) : providerKey;
|
|
262
|
+
LOG.info("Provider for %s changed to %s.", channelName, providerLabel);
|
|
263
|
+
// Return full row HTML so the client can replace both the display and edit rows, keeping the edit form in sync with the selected provider.
|
|
264
|
+
const profiles = getProfiles();
|
|
265
|
+
const rowHtml = generateChannelRowHtml(canonicalKey, profiles);
|
|
266
|
+
res.json({ channel: canonicalKey, html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, provider: providerKey, success: true });
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
LOG.error("Failed to update provider selection: %s.", formatError(error));
|
|
270
|
+
res.status(500).json({ error: "Failed to update provider: " + formatError(error), success: false });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
// POST /config/channels/bulk-toggle-predefined - Toggle predefined channels by scope (all, pacific, east).
|
|
274
|
+
app.post("/config/channels/bulk-toggle-predefined", async (req, res) => {
|
|
275
|
+
try {
|
|
276
|
+
const body = req.body;
|
|
277
|
+
const enabled = body.enabled;
|
|
278
|
+
const scope = body.scope;
|
|
279
|
+
// Validate enabled is provided.
|
|
280
|
+
if (typeof enabled !== "boolean") {
|
|
281
|
+
res.status(400).json({ error: "Enabled state (true/false) is required.", success: false });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Validate scope is provided and recognized.
|
|
285
|
+
if ((scope !== "all") && (scope !== "pacific") && (scope !== "east")) {
|
|
286
|
+
res.status(400).json({ error: "Scope must be 'all', 'pacific', or 'east'.", success: false });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Compute the target key set based on scope.
|
|
290
|
+
let targetKeys;
|
|
291
|
+
switch (scope) {
|
|
292
|
+
case "pacific": {
|
|
293
|
+
targetKeys = getPacificPredefinedKeys();
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "east": {
|
|
297
|
+
targetKeys = getEastWithPacificPredefinedKeys();
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
default: {
|
|
301
|
+
targetKeys = Object.keys(getPredefinedChannels());
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Load current config.
|
|
306
|
+
const configResult = await loadUserConfig();
|
|
307
|
+
const userConfig = configResult.config;
|
|
308
|
+
// Initialize channels.disabledPredefined if not present.
|
|
309
|
+
userConfig.channels ??= {};
|
|
310
|
+
const targetSet = new Set(targetKeys);
|
|
311
|
+
if (enabled && (scope === "all")) {
|
|
312
|
+
// Enable all is a full reset — clear the entire disabled list.
|
|
313
|
+
userConfig.channels.disabledPredefined = [];
|
|
314
|
+
}
|
|
315
|
+
else if (enabled) {
|
|
316
|
+
// Scoped enable: remove target keys from the disabled list (subtractive — preserves other disabled channels).
|
|
317
|
+
userConfig.channels.disabledPredefined = (userConfig.channels.disabledPredefined ?? []).filter((k) => !targetSet.has(k));
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// Disable: add target keys to the disabled list (additive — preserves other disabled channels).
|
|
321
|
+
const existing = new Set(userConfig.channels.disabledPredefined ?? []);
|
|
322
|
+
for (const k of targetKeys) {
|
|
323
|
+
existing.add(k);
|
|
324
|
+
}
|
|
325
|
+
userConfig.channels.disabledPredefined = [...existing].sort();
|
|
326
|
+
}
|
|
327
|
+
await saveUserConfig(userConfig);
|
|
328
|
+
// Update the runtime CONFIG to reflect the change immediately.
|
|
329
|
+
CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
|
|
330
|
+
const affected = targetKeys.length;
|
|
331
|
+
const scopeLabel = (scope === "all") ? "All" : (scope === "pacific") ? "Pacific" : "East";
|
|
332
|
+
LOG.info("%s predefined channels %s (%d affected).", scopeLabel, enabled ? "enabled" : "disabled", affected);
|
|
333
|
+
res.json({ affected, enabled, keys: targetKeys, scope, success: true });
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
LOG.error("Failed to toggle predefined channels: %s.", formatError(error));
|
|
337
|
+
res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
// POST /config/provider-filter - Update the provider filter (enabled provider tags).
|
|
341
|
+
app.post("/config/provider-filter", async (req, res) => {
|
|
342
|
+
try {
|
|
343
|
+
const body = req.body;
|
|
344
|
+
const tags = body.enabledProviders;
|
|
345
|
+
// Validate tags is an array.
|
|
346
|
+
if (!Array.isArray(tags)) {
|
|
347
|
+
res.status(400).json({ error: "enabledProviders must be an array.", success: false });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Validate all tags are known. Tags already in enabledProviders are accepted even if no current channel or profile produces them — this allows stale tags to be
|
|
351
|
+
// removed via the UI without blocking the request.
|
|
352
|
+
const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
|
|
353
|
+
const currentTags = new Set(getEnabledProviders());
|
|
354
|
+
for (const tag of tags) {
|
|
355
|
+
if (!knownTags.has(tag) && !currentTags.has(tag)) {
|
|
356
|
+
res.status(400).json({ error: "Unknown provider tag: " + tag, success: false });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Update module-level state.
|
|
361
|
+
setEnabledProviders(tags);
|
|
362
|
+
// Update runtime CONFIG.
|
|
363
|
+
CONFIG.channels.enabledProviders = [...tags];
|
|
364
|
+
// Save to config file.
|
|
365
|
+
const configResult = await loadUserConfig();
|
|
366
|
+
const userConfig = configResult.config;
|
|
367
|
+
userConfig.channels ??= {};
|
|
368
|
+
userConfig.channels.enabledProviders = tags;
|
|
369
|
+
await saveUserConfig(filterDefaults(userConfig));
|
|
370
|
+
LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
|
|
371
|
+
res.json({ enabledProviders: tags, success: true });
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
LOG.error("Failed to update provider filter: %s.", formatError(error));
|
|
375
|
+
res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
// POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
|
|
379
|
+
app.post("/config/channels/display-prefs", async (req, res) => {
|
|
380
|
+
try {
|
|
381
|
+
const body = req.body;
|
|
382
|
+
// Validate and apply visible columns if provided.
|
|
383
|
+
if (body.visibleColumns !== undefined) {
|
|
384
|
+
if (!Array.isArray(body.visibleColumns)) {
|
|
385
|
+
res.status(400).json({ error: "visibleColumns must be an array.", success: false });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
for (const col of body.visibleColumns) {
|
|
389
|
+
if (!VALID_OPTIONAL_COLUMNS.has(col)) {
|
|
390
|
+
res.status(400).json({ error: "Unknown column: " + col, success: false });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
CONFIG.channels.visibleColumns = [...body.visibleColumns];
|
|
395
|
+
}
|
|
396
|
+
// Validate and apply sort field if provided.
|
|
397
|
+
if (body.sortField !== undefined) {
|
|
398
|
+
if (!VALID_SORT_FIELDS.has(body.sortField)) {
|
|
399
|
+
res.status(400).json({ error: "Unknown sort field: " + body.sortField, success: false });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
CONFIG.channels.channelSortField = body.sortField;
|
|
403
|
+
}
|
|
404
|
+
// Validate and apply sort direction if provided.
|
|
405
|
+
if (body.sortDirection !== undefined) {
|
|
406
|
+
if ((body.sortDirection !== "asc") && (body.sortDirection !== "desc")) {
|
|
407
|
+
res.status(400).json({ error: "sortDirection must be \"asc\" or \"desc\".", success: false });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
CONFIG.channels.channelSortDirection = body.sortDirection;
|
|
411
|
+
}
|
|
412
|
+
// Persist to config file.
|
|
413
|
+
const configResult = await loadUserConfig();
|
|
414
|
+
const userConfig = configResult.config;
|
|
415
|
+
userConfig.channels ??= {};
|
|
416
|
+
userConfig.channels.channelSortDirection = CONFIG.channels.channelSortDirection;
|
|
417
|
+
userConfig.channels.channelSortField = CONFIG.channels.channelSortField;
|
|
418
|
+
userConfig.channels.visibleColumns = CONFIG.channels.visibleColumns;
|
|
419
|
+
await saveUserConfig(filterDefaults(userConfig));
|
|
420
|
+
res.json({ success: true });
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
LOG.error("Failed to update display preferences: %s.", formatError(error));
|
|
424
|
+
res.status(500).json({ error: "Failed to update display preferences: " + formatError(error), success: false });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
// POST /config/provider-bulk-assign - Set all channels to a specific provider.
|
|
428
|
+
app.post("/config/provider-bulk-assign", async (req, res) => {
|
|
429
|
+
try {
|
|
430
|
+
const body = req.body;
|
|
431
|
+
const providerTag = body.provider?.trim();
|
|
432
|
+
// Validate provider tag.
|
|
433
|
+
if (!providerTag) {
|
|
434
|
+
res.status(400).json({ error: "Provider tag is required.", success: false });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
let affected = 0;
|
|
438
|
+
const previousSelections = {};
|
|
439
|
+
const selections = {};
|
|
440
|
+
// Iterate all channels and set those with a matching variant.
|
|
441
|
+
const listing = getChannelListing();
|
|
442
|
+
for (const entry of listing) {
|
|
443
|
+
const group = getProviderGroup(entry.key);
|
|
444
|
+
if (!group || (group.variants.length <= 1)) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
// Find a variant matching the requested provider tag.
|
|
448
|
+
const matchingVariant = group.variants.find((v) => (getProviderTagForChannel(v.key) === providerTag));
|
|
449
|
+
if (matchingVariant) {
|
|
450
|
+
// Snapshot the current selection before overwriting so the client can offer undo.
|
|
451
|
+
const currentVariant = getProviderSelection(entry.key);
|
|
452
|
+
previousSelections[entry.key] = currentVariant ?? null;
|
|
453
|
+
setProviderSelection(entry.key, matchingVariant.key);
|
|
454
|
+
affected++;
|
|
455
|
+
// Collect the resolved profile name for client-side UI update.
|
|
456
|
+
const resolvedChannel = getResolvedChannel(matchingVariant.key);
|
|
457
|
+
selections[entry.key] = { profile: resolvedChannel?.profile ?? null, variant: matchingVariant.key };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Save to disk.
|
|
461
|
+
await saveProviderSelections();
|
|
462
|
+
LOG.info("Bulk assign to '%s': %d of %d channels affected.", providerTag, affected, listing.length);
|
|
463
|
+
res.json({ affected, previousSelections, selections, success: true, total: listing.length });
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
LOG.error("Failed to bulk assign provider: %s.", formatError(error));
|
|
467
|
+
res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
// POST /config/provider-bulk-restore - Restore previous provider selections (undo bulk assign).
|
|
471
|
+
app.post("/config/provider-bulk-restore", async (req, res) => {
|
|
472
|
+
try {
|
|
473
|
+
const body = req.body;
|
|
474
|
+
const previousSelections = body.selections;
|
|
475
|
+
if (!previousSelections || (typeof previousSelections !== "object")) {
|
|
476
|
+
res.status(400).json({ error: "Selections map is required.", success: false });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
let restored = 0;
|
|
480
|
+
const selections = {};
|
|
481
|
+
for (const [key, variantKey] of Object.entries(previousSelections)) {
|
|
482
|
+
const group = getProviderGroup(key);
|
|
483
|
+
if (!group) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// A null value means the channel was using the default (canonical) selection. Restoring by setting the selection to the canonical key clears the override.
|
|
487
|
+
if (variantKey === null) {
|
|
488
|
+
setProviderSelection(key, key);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
// Validate the variant belongs to this channel's provider group before restoring.
|
|
492
|
+
const isValid = group.variants.some((v) => (v.key === variantKey));
|
|
493
|
+
if (!isValid) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
setProviderSelection(key, variantKey);
|
|
497
|
+
}
|
|
498
|
+
restored++;
|
|
499
|
+
// Build the same selection response format as bulk assign for client-side UI updates.
|
|
500
|
+
const effectiveKey = variantKey ?? key;
|
|
501
|
+
const resolvedChannel = getResolvedChannel(effectiveKey);
|
|
502
|
+
selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
|
|
503
|
+
}
|
|
504
|
+
// Save to disk.
|
|
505
|
+
await saveProviderSelections();
|
|
506
|
+
LOG.info("Bulk restore: %d channel(s) reverted.", restored);
|
|
507
|
+
res.json({ restored, selections, success: true });
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
LOG.error("Failed to bulk restore providers: %s.", formatError(error));
|
|
511
|
+
res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
// POST /config/channels - Handle channel add, edit, delete, and revert operations. Returns JSON response.
|
|
515
|
+
app.post("/config/channels", async (req, res) => {
|
|
516
|
+
try {
|
|
517
|
+
const body = req.body;
|
|
518
|
+
const action = body.action;
|
|
519
|
+
const key = body.key?.trim();
|
|
520
|
+
const profiles = getProfiles();
|
|
521
|
+
// Handle revert action — remove override of a predefined channel, restoring it to defaults.
|
|
522
|
+
if (action === "revert") {
|
|
523
|
+
if (!key) {
|
|
524
|
+
res.status(400).json({ message: "Channel key is required for revert.", success: false });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!isPredefinedChannel(key)) {
|
|
528
|
+
res.status(400).json({ message: "Cannot revert '" + key + "': it is not a predefined channel.", success: false });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!isUserChannel(key)) {
|
|
532
|
+
res.status(400).json({ message: "Cannot revert '" + key + "': no override exists.", success: false });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// Remove the override.
|
|
536
|
+
const result = await loadUserChannels();
|
|
537
|
+
if (result.parseError) {
|
|
538
|
+
res.status(400).json({ message: "Cannot revert channel: channels file contains invalid JSON.", success: false });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// Check if the override contains M3U-relevant fields before removing it.
|
|
542
|
+
const revertHint = playlistHintForStored(result.channels[key]);
|
|
543
|
+
Reflect.deleteProperty(result.channels, key);
|
|
544
|
+
await saveUserChannels(result.channels);
|
|
545
|
+
LOG.info("Channel '%s' reverted to predefined defaults.", key);
|
|
546
|
+
// Generate the predefined row HTML so the client can replace the override row.
|
|
547
|
+
const rowHtml = generateChannelRowHtml(key, profiles);
|
|
548
|
+
res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
|
|
549
|
+
message: "Channel '" + key + "' reverted to defaults." + revertHint, success: true });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Handle delete action.
|
|
553
|
+
if (action === "delete") {
|
|
554
|
+
if (!key) {
|
|
555
|
+
res.status(400).json({ message: "Channel key is required for delete.", success: false });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (!isUserChannel(key)) {
|
|
559
|
+
res.status(400).json({ message: "Cannot delete '" + key + "': it is not a user-defined channel.", success: false });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// Delete the channel.
|
|
563
|
+
const result = await loadUserChannels();
|
|
564
|
+
if (result.parseError) {
|
|
565
|
+
res.status(400).json({ message: "Cannot delete channel: channels file contains invalid JSON.", success: false });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
Reflect.deleteProperty(result.channels, key);
|
|
569
|
+
await saveUserChannels(result.channels);
|
|
570
|
+
LOG.info("User channel '%s' deleted.", key);
|
|
571
|
+
// 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
|
|
572
|
+
// just removing it. Without this, deleting a user override of a predefined channel would leave the predefined channel invisible until a page refresh.
|
|
573
|
+
const predefined = isPredefinedChannel(key) ? generateChannelRowHtml(key, profiles) : undefined;
|
|
574
|
+
// Return success response with key for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
|
|
575
|
+
res.json({ html: predefined, key,
|
|
576
|
+
message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT, success: true });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
// Handle add and edit actions.
|
|
580
|
+
if ((action !== "add") && (action !== "edit")) {
|
|
581
|
+
res.status(400).json({ message: "Invalid channel action.", success: false });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// Key is required for both add and edit actions.
|
|
585
|
+
if (!key) {
|
|
586
|
+
res.status(400).json({ message: "Channel key is required.", success: false });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// Validate channel fields.
|
|
590
|
+
const formErrors = {};
|
|
591
|
+
// Collect and sanitize form values. sanitizeString() strips non-printable characters and trims whitespace.
|
|
592
|
+
const name = sanitizeString(body.name ?? "");
|
|
593
|
+
const url = sanitizeString(body.url ?? "");
|
|
594
|
+
const profile = sanitizeString(body.profile ?? "");
|
|
595
|
+
const stationId = sanitizeString(body.stationId ?? "");
|
|
596
|
+
const channelSelector = sanitizeString(body.channelSelector ?? "");
|
|
597
|
+
const channelNumberStr = sanitizeString(body.channelNumber ?? "");
|
|
598
|
+
// Validate channel number if provided.
|
|
599
|
+
if (channelNumberStr) {
|
|
600
|
+
const num = parseInt(channelNumberStr, 10);
|
|
601
|
+
if (Number.isNaN(num) || (num < 1) || (num > 99999)) {
|
|
602
|
+
formErrors.channelNumber = "Channel number must be between 1 and 99999.";
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
// Check for duplicate channel numbers across all resolved channels.
|
|
606
|
+
for (const entry of getChannelListing()) {
|
|
607
|
+
if ((entry.channel.channelNumber === num) && (entry.key !== key)) {
|
|
608
|
+
formErrors.channelNumber = "Channel number " + String(num) + " is already used by '" + entry.key + "'.";
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Validate key (only for add action, not edit).
|
|
615
|
+
if (action === "add") {
|
|
616
|
+
const keyError = validateChannelKey(key, true);
|
|
617
|
+
if (keyError) {
|
|
618
|
+
formErrors.key = keyError;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// Validate name.
|
|
622
|
+
const nameError = validateChannelName(name);
|
|
623
|
+
if (nameError) {
|
|
624
|
+
formErrors.name = nameError;
|
|
625
|
+
}
|
|
626
|
+
// Validate URL.
|
|
627
|
+
const urlError = validateChannelUrl(url);
|
|
628
|
+
if (urlError) {
|
|
629
|
+
formErrors.url = urlError;
|
|
630
|
+
}
|
|
631
|
+
// Validate profile (if specified).
|
|
632
|
+
const profileError = validateChannelProfile(profile, profiles.map((p) => p.name));
|
|
633
|
+
if (profileError) {
|
|
634
|
+
formErrors.profile = profileError;
|
|
635
|
+
}
|
|
636
|
+
// If validation errors, return them as JSON.
|
|
637
|
+
if (Object.keys(formErrors).length > 0) {
|
|
638
|
+
res.status(400).json({ errors: formErrors, success: false });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Load existing user channels.
|
|
642
|
+
const result = await loadUserChannels();
|
|
643
|
+
if (result.parseError) {
|
|
644
|
+
// If channels file is corrupt, start fresh on add (which will create a valid file).
|
|
645
|
+
if (action === "add") {
|
|
646
|
+
result.channels = {};
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
res.status(400).json({ message: "Cannot edit channel: channels file contains invalid JSON.", success: false });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
let playlistChanged = false;
|
|
654
|
+
// For predefined channels being edited, compute a delta of only the changed fields. For user-defined channels and adds, build a full channel object.
|
|
655
|
+
const predefinedBase = getPredefinedChannel(key);
|
|
656
|
+
if ((action === "edit") && predefinedBase) {
|
|
657
|
+
// Build a record of submitted form values keyed by channel property name. This record drives both the displayChannel comparison and the predefined delta
|
|
658
|
+
// computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined.
|
|
659
|
+
const formValues = {
|
|
660
|
+
channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
|
|
661
|
+
channelSelector,
|
|
662
|
+
name,
|
|
663
|
+
profile,
|
|
664
|
+
stationId,
|
|
665
|
+
url
|
|
666
|
+
};
|
|
667
|
+
// Helper to read a comparable value from a Channel object. String fields default to "" when undefined so they match the form's empty-string representation.
|
|
668
|
+
// channelNumber stays as number | undefined since the form value uses the same representation.
|
|
669
|
+
const channelValue = (ch, field) => {
|
|
670
|
+
const val = ch[field];
|
|
671
|
+
return (field === "channelNumber") ? val : val ?? "";
|
|
672
|
+
};
|
|
673
|
+
// First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
|
|
674
|
+
// may differ from the canonical predefined base when a variant is selected (e.g., the Hulu variant has a different URL and channelSelector). If the submitted
|
|
675
|
+
// values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
|
|
676
|
+
const resolvedKey = resolveProviderKey(key);
|
|
677
|
+
const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
|
|
678
|
+
if (Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field))) {
|
|
679
|
+
res.json({ key, message: "No changes to save.", success: true });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
|
|
683
|
+
// revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
|
|
684
|
+
const delta = {};
|
|
685
|
+
let hasChanges = false;
|
|
686
|
+
for (const field of Object.keys(formValues)) {
|
|
687
|
+
if (formValues[field] !== channelValue(predefinedBase, field)) {
|
|
688
|
+
const formVal = formValues[field];
|
|
689
|
+
delta[field] = ((formVal === "") || (formVal === undefined)) ? null : formVal;
|
|
690
|
+
hasChanges = true;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Helper to check if form values match a given channel's properties.
|
|
694
|
+
const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field));
|
|
695
|
+
if (!hasChanges) {
|
|
696
|
+
// The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away — treat it as an
|
|
697
|
+
// implicit revert by removing the override and returning the predefined row HTML.
|
|
698
|
+
if (isUserChannel(key)) {
|
|
699
|
+
const implicitRevertHint = playlistHintForStored(result.channels[key]);
|
|
700
|
+
Reflect.deleteProperty(result.channels, key);
|
|
701
|
+
await saveUserChannels(result.channels);
|
|
702
|
+
LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
|
|
703
|
+
const rowHtml = generateChannelRowHtml(key, profiles);
|
|
704
|
+
res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
|
|
705
|
+
message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint, success: true });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
res.json({ key, message: "No changes to save.", success: true });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
// The delta has changes vs the canonical predefined. Before storing a custom override, check if the form values match any provider variant's predefined
|
|
712
|
+
// definition. This handles the case where a user edits from a variant (e.g., Hulu), makes a change, saves (creating a custom override), then edits again
|
|
713
|
+
// and reverts the change. The URL and channelSelector still differ from the canonical predefined but match the variant — that's a revert to the variant,
|
|
714
|
+
// not a new customization. We resolve each variant against pure PREDEFINED data (not the user-overridden channelsRef) to avoid contamination from the
|
|
715
|
+
// current override.
|
|
716
|
+
const providerGroup = getProviderGroup(key);
|
|
717
|
+
let matchedVariantKey;
|
|
718
|
+
if (providerGroup && isUserChannel(key)) {
|
|
719
|
+
for (const variant of providerGroup.variants) {
|
|
720
|
+
// Skip the canonical entry (already handled by the !hasChanges check above) and :predefined entries (synthetic entries for override UI).
|
|
721
|
+
if ((variant.key === key) || variant.key.includes(":")) {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
// Resolve this variant against pure predefined data (no user override contamination).
|
|
725
|
+
const resolvedVariant = resolvePredefinedVariant(variant.key);
|
|
726
|
+
if (resolvedVariant && formMatchesChannel(resolvedVariant)) {
|
|
727
|
+
matchedVariantKey = variant.key;
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (matchedVariantKey) {
|
|
733
|
+
// Form values match a known variant — revert the override and switch back to that variant.
|
|
734
|
+
const variantRevertHint = playlistHintForStored(result.channels[key]);
|
|
735
|
+
Reflect.deleteProperty(result.channels, key);
|
|
736
|
+
setProviderSelection(key, matchedVariantKey);
|
|
737
|
+
await saveUserChannels(result.channels);
|
|
738
|
+
LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
|
|
739
|
+
const rowHtml = generateChannelRowHtml(key, profiles);
|
|
740
|
+
res.json({ html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, key,
|
|
741
|
+
message: "Channel '" + key + "' reverted to defaults." + variantRevertHint, success: true });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// No variant match — store the delta and switch the provider selection to the canonical key (the custom override). This ensures the provider dropdown shows
|
|
745
|
+
// "Custom" after saving, which is the expected behavior when a user customizes a predefined channel.
|
|
746
|
+
setProviderSelection(key, key);
|
|
747
|
+
result.channels[key] = delta;
|
|
748
|
+
playlistChanged = M3U_FIELDS.some((f) => f in delta);
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
// User-defined channel or add: build a full channel object. For edits, snapshot the old channel to detect M3U-relevant changes.
|
|
752
|
+
const oldChannel = ((action === "edit") && (key in result.channels)) ? result.channels[key] : undefined;
|
|
753
|
+
const channel = {
|
|
754
|
+
name,
|
|
755
|
+
url
|
|
756
|
+
};
|
|
757
|
+
if (profile) {
|
|
758
|
+
channel.profile = profile;
|
|
759
|
+
}
|
|
760
|
+
if (stationId) {
|
|
761
|
+
channel.stationId = stationId;
|
|
762
|
+
}
|
|
763
|
+
if (channelSelector) {
|
|
764
|
+
channel.channelSelector = channelSelector;
|
|
765
|
+
}
|
|
766
|
+
if (channelNumberStr) {
|
|
767
|
+
channel.channelNumber = parseInt(channelNumberStr, 10);
|
|
768
|
+
}
|
|
769
|
+
result.channels[key] = channel;
|
|
770
|
+
// For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
|
|
771
|
+
playlistChanged = (action === "add") ||
|
|
772
|
+
((oldChannel !== undefined) &&
|
|
773
|
+
M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
|
|
774
|
+
}
|
|
775
|
+
await saveUserChannels(result.channels);
|
|
776
|
+
const actionLabel = (action === "add") ? "added" : "updated";
|
|
777
|
+
LOG.info("User channel '%s' %s.", key, actionLabel);
|
|
778
|
+
// Generate HTML for the channel row so the client can update the DOM without a full page reload.
|
|
779
|
+
const rowHtml = generateChannelRowHtml(key, profiles);
|
|
780
|
+
// Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
|
|
781
|
+
const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
|
|
782
|
+
// Return success response with HTML for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
|
|
783
|
+
res.json({
|
|
784
|
+
html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow },
|
|
785
|
+
isNew: action === "add",
|
|
786
|
+
key,
|
|
787
|
+
message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
|
|
788
|
+
success: true
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
LOG.error("Failed to save channel: %s.", formatError(error));
|
|
793
|
+
res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=routes.js.map
|