prismcast 1.8.0 → 1.9.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/README.md +6 -6
- package/dist/app.js +15 -5
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +4 -6
- package/dist/browser/cdp.js +13 -17
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/index.js +3 -3
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/precaching.d.ts +1 -1
- package/dist/browser/precaching.js +23 -23
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/directv.js +4 -7
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +1 -1
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +3 -3
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.js +2 -2
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/index.js +416 -416
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +3 -3
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/persistence.d.ts +59 -0
- package/dist/config/persistence.js +127 -0
- package/dist/config/persistence.js.map +1 -0
- package/dist/config/servicePacks.d.ts +46 -0
- package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
- package/dist/config/servicePacks.js.map +1 -0
- package/dist/config/services.d.ts +202 -0
- package/dist/config/{providers.js → services.js} +277 -210
- package/dist/config/services.js.map +1 -0
- package/dist/config/sites.js +61 -61
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +36 -27
- package/dist/config/userChannels.js +300 -212
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +11 -9
- package/dist/config/userConfig.js +53 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +139 -96
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -7
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.js +3 -3
- package/dist/native/index.js.map +1 -1
- package/dist/native/intercept.js +86 -89
- package/dist/native/intercept.js.map +1 -1
- package/dist/native/probe.js +2 -2
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +3 -3
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/auth.js +3 -3
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +1 -1
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +488 -460
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +4 -4
- package/dist/routes/config/channels/table.js +206 -147
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +2 -2
- package/dist/routes/config/index.js +3 -3
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/{providers.js → services.js} +44 -39
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +47 -29
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +5 -7
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +4 -4
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/playlist.d.ts +4 -4
- package/dist/routes/playlist.js +56 -44
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +58 -58
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +529 -572
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +838 -992
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +431 -197
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +247 -196
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +12 -4
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/services.d.ts +6 -0
- package/dist/routes/{providers.js → services.js} +43 -43
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/ui.js +2 -0
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +5 -8
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +1 -1
- package/dist/service/generators.js +143 -41
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +24 -23
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +1 -1
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/monitor.d.ts +2 -2
- package/dist/streaming/monitor.js +19 -13
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/preroll.js +1 -1
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +2 -2
- package/dist/streaming/setup.js +23 -22
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +8 -10
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -3
- package/dist/streaming/statusEmitter.js +3 -3
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +15 -11
- package/dist/types/config.d.ts +2 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/profiles.d.ts +9 -9
- package/dist/types/selection.d.ts +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/debugFilter.js +3 -3
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/fileLogger.js +4 -3
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +7 -1
- package/dist/utils/format.js +21 -1
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/logger.js +17 -27
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/morganStream.js +2 -2
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/platform.js +3 -2
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/streamContext.d.ts +7 -0
- package/dist/utils/streamContext.js +10 -1
- package/dist/utils/streamContext.js.map +1 -1
- package/dist/utils/version.js +4 -4
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -7
- package/dist/config/providerPacks.d.ts +0 -46
- package/dist/config/providerPacks.js.map +0 -1
- package/dist/config/providers.d.ts +0 -175
- package/dist/config/providers.js.map +0 -1
- package/dist/routes/config/providers.js.map +0 -1
- package/dist/routes/providers.d.ts +0 -6
- package/dist/routes/providers.js.map +0 -1
- /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
|
@@ -1,15 +1,48 @@
|
|
|
1
1
|
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString, stringifySorted } from "../../../utils/index.js";
|
|
2
2
|
import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../../../channels/index.js";
|
|
3
3
|
import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState, generateTagFilterContent, generateTagManagerBody } from "./table.js";
|
|
4
|
-
import { VALID_SORT_FIELDS, compareChannelSort,
|
|
5
|
-
import {
|
|
6
|
-
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getTagRegistry, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveTagRegistry, saveUserChannels, setTagRegistry, transformChannelTags, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
|
|
4
|
+
import { VALID_SORT_FIELDS, compareChannelSort, getAllServiceTags, getCanonicalKey, getChannelServiceLabel, getEnabledServices, getResolvedChannel, getServiceDisplayName, getServiceGroup, getServiceSelection, getServiceTagForChannel, isServiceTagEnabled, resolvePredefinedVariant, resolveServiceKey, setEnabledServices, setServiceSelection } from "../../../config/services.js";
|
|
5
|
+
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getTagRegistry, getUserChannels, isPredefinedChannel, isUserChannel, mutateChannels, resolveStoredChannel, saveServiceSelections, saveTagRegistry, setTagRegistry, tagsMatch, transformChannelTags, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
|
|
7
6
|
import { CONFIG } from "../../../config/index.js";
|
|
7
|
+
import { FileStoreParseError } from "../../../config/persistence.js";
|
|
8
|
+
import { getDomainConfig } from "../../../config/sites.js";
|
|
8
9
|
import { getProfiles } from "../../../config/profiles.js";
|
|
10
|
+
import { mutateConfig } from "../../../config/userConfig.js";
|
|
9
11
|
import { updateChannelLogo } from "../../../streaming/showInfo.js";
|
|
12
|
+
/**
|
|
13
|
+
* Sends an appropriate error response for a caught error. FileStoreParseError (corrupt JSON file) produces a 400 with the parse error details. All other errors
|
|
14
|
+
* produce a 500 with the formatted error message. This is the single error response path for all channel route handlers.
|
|
15
|
+
* @param res - The Express response object.
|
|
16
|
+
* @param error - The caught error.
|
|
17
|
+
* @param action - Human-readable description of the failed action for the log message (e.g., "import channels", "toggle channel").
|
|
18
|
+
*/
|
|
19
|
+
function sendErrorResponse(res, error, action) {
|
|
20
|
+
if (error instanceof FileStoreParseError) {
|
|
21
|
+
res.status(400).json({ error: error.message, success: false });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
LOG.error("Failed to %s: %s.", action, formatError(error));
|
|
25
|
+
res.status(500).json({ error: "Failed to " + action + ": " + formatError(error), success: false });
|
|
26
|
+
}
|
|
10
27
|
// 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.
|
|
11
|
-
const M3U_FIELDS = ["channelNumber", "name", "stationId", "tvgShift"];
|
|
28
|
+
const M3U_FIELDS = ["channelNumber", "guideTitle", "logoUrl", "name", "stationId", "tvgShift"];
|
|
12
29
|
const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change.";
|
|
30
|
+
/**
|
|
31
|
+
* Builds a service filter warning when a URL's service tag is not in the active filter. Returns undefined when no filter is active, the tag is "direct", or the
|
|
32
|
+
* tag is already enabled. The client shows the returned warning as a toast with a one-click enable action.
|
|
33
|
+
* @param url - The channel URL to derive the service tag from.
|
|
34
|
+
* @returns The warning with service tag and display label, or undefined if no warning is needed.
|
|
35
|
+
*/
|
|
36
|
+
function buildServiceFilterWarning(url) {
|
|
37
|
+
if (getEnabledServices().length === 0) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const tag = getDomainConfig(url)?.serviceTag;
|
|
41
|
+
if (tag && (tag !== "direct") && !isServiceTagEnabled(tag)) {
|
|
42
|
+
return { serviceLabel: getServiceDisplayName(url), serviceTag: tag };
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
13
46
|
/**
|
|
14
47
|
* 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
|
|
15
48
|
* or removing an override.
|
|
@@ -28,17 +61,18 @@ async function disablePredefinedChannels(keys) {
|
|
|
28
61
|
if (keys.length === 0) {
|
|
29
62
|
return;
|
|
30
63
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
let updatedList = [];
|
|
65
|
+
await mutateConfig((config) => {
|
|
66
|
+
config.channels ??= {};
|
|
67
|
+
config.channels.disabledPredefined ??= [];
|
|
68
|
+
const disabledSet = new Set(config.channels.disabledPredefined);
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
disabledSet.add(key);
|
|
71
|
+
}
|
|
72
|
+
config.channels.disabledPredefined = [...disabledSet].toSorted();
|
|
73
|
+
updatedList = config.channels.disabledPredefined;
|
|
74
|
+
});
|
|
75
|
+
CONFIG.channels.disabledPredefined = updatedList;
|
|
42
76
|
}
|
|
43
77
|
/**
|
|
44
78
|
* Enables one or more predefined channels by removing their keys from the disabledPredefined list in user config. Updates both the config file and the
|
|
@@ -49,17 +83,18 @@ async function enablePredefinedChannels(keys) {
|
|
|
49
83
|
if (keys.length === 0) {
|
|
50
84
|
return;
|
|
51
85
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
86
|
+
let updatedList = [];
|
|
87
|
+
await mutateConfig((config) => {
|
|
88
|
+
config.channels ??= {};
|
|
89
|
+
config.channels.disabledPredefined ??= [];
|
|
90
|
+
const disabledSet = new Set(config.channels.disabledPredefined);
|
|
91
|
+
for (const key of keys) {
|
|
92
|
+
disabledSet.delete(key);
|
|
93
|
+
}
|
|
94
|
+
config.channels.disabledPredefined = [...disabledSet].toSorted();
|
|
95
|
+
updatedList = config.channels.disabledPredefined;
|
|
96
|
+
});
|
|
97
|
+
CONFIG.channels.disabledPredefined = updatedList;
|
|
63
98
|
}
|
|
64
99
|
/**
|
|
65
100
|
* Installs all channel-related route handlers on the Express application.
|
|
@@ -80,8 +115,7 @@ export function setupChannelRoutes(app) {
|
|
|
80
115
|
res.send(stringifySorted(resolved) + "\n");
|
|
81
116
|
}
|
|
82
117
|
catch (error) {
|
|
83
|
-
|
|
84
|
-
res.status(500).json({ error: "Failed to export channels: " + formatError(error) });
|
|
118
|
+
sendErrorResponse(res, error, "export channels");
|
|
85
119
|
}
|
|
86
120
|
});
|
|
87
121
|
// POST /config/channels/import - Import channels from JSON, replacing all existing user channels.
|
|
@@ -95,8 +129,13 @@ export function setupChannelRoutes(app) {
|
|
|
95
129
|
res.status(400).json({ error: "Validation errors:\n" + validationResult.errors.join("\n") });
|
|
96
130
|
return;
|
|
97
131
|
}
|
|
98
|
-
// Save the imported channels, replacing all existing user channels.
|
|
99
|
-
await
|
|
132
|
+
// Save the imported channels, replacing all existing user channels. Clear the current map and assign the imported entries.
|
|
133
|
+
await mutateChannels((channels) => {
|
|
134
|
+
for (const key of Object.keys(channels)) {
|
|
135
|
+
Reflect.deleteProperty(channels, key);
|
|
136
|
+
}
|
|
137
|
+
Object.assign(channels, validationResult.channels);
|
|
138
|
+
});
|
|
100
139
|
const channelCount = Object.keys(validationResult.channels).length;
|
|
101
140
|
// Return a full table patch since a bulk JSON import replaces all user channels — every row in the listing may have changed.
|
|
102
141
|
const allKeys = getChannelListing().map((entry) => entry.key);
|
|
@@ -104,8 +143,7 @@ export function setupChannelRoutes(app) {
|
|
|
104
143
|
patch: buildChannelTablePatch(allKeys, getProfiles()), success: true });
|
|
105
144
|
}
|
|
106
145
|
catch (error) {
|
|
107
|
-
|
|
108
|
-
res.status(500).json({ error: "Failed to import channels: " + formatError(error) });
|
|
146
|
+
sendErrorResponse(res, error, "import channels");
|
|
109
147
|
}
|
|
110
148
|
});
|
|
111
149
|
// POST /config/channels/import-m3u - Import channels from M3U playlist file.
|
|
@@ -134,9 +172,6 @@ export function setupChannelRoutes(app) {
|
|
|
134
172
|
});
|
|
135
173
|
return;
|
|
136
174
|
}
|
|
137
|
-
// Load existing user channels.
|
|
138
|
-
const loadResult = await loadUserChannels();
|
|
139
|
-
const existingChannels = loadResult.parseError ? {} : loadResult.channels;
|
|
140
175
|
// Track import statistics.
|
|
141
176
|
const affectedKeys = [];
|
|
142
177
|
const conflicts = [];
|
|
@@ -145,7 +180,8 @@ export function setupChannelRoutes(app) {
|
|
|
145
180
|
let imported = 0;
|
|
146
181
|
let replaced = 0;
|
|
147
182
|
let skipped = 0;
|
|
148
|
-
//
|
|
183
|
+
// Pre-validate and sanitize each parsed channel before entering the mutation. This keeps pure validation outside the serialized write lock.
|
|
184
|
+
const validEntries = [];
|
|
149
185
|
for (const m3uChannel of parseResult.channels) {
|
|
150
186
|
m3uChannel.name = sanitizeString(m3uChannel.name);
|
|
151
187
|
m3uChannel.url = sanitizeString(m3uChannel.url);
|
|
@@ -174,19 +210,6 @@ export function setupChannelRoutes(app) {
|
|
|
174
210
|
importErrors.push("Channel '" + m3uChannel.name + "': " + nameError);
|
|
175
211
|
continue;
|
|
176
212
|
}
|
|
177
|
-
// Check for conflicts with existing channels.
|
|
178
|
-
if (key in existingChannels) {
|
|
179
|
-
conflicts.push(key);
|
|
180
|
-
if (conflictMode === "skip") {
|
|
181
|
-
skipped++;
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
// Replace mode - count as replaced instead of imported.
|
|
185
|
-
replaced++;
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
imported++;
|
|
189
|
-
}
|
|
190
213
|
// Build the channel object.
|
|
191
214
|
const channel = {
|
|
192
215
|
name: m3uChannel.name,
|
|
@@ -196,12 +219,29 @@ export function setupChannelRoutes(app) {
|
|
|
196
219
|
if (m3uChannel.stationId) {
|
|
197
220
|
channel.stationId = m3uChannel.stationId;
|
|
198
221
|
}
|
|
199
|
-
|
|
200
|
-
existingChannels[key] = channel;
|
|
201
|
-
affectedKeys.push(key);
|
|
222
|
+
validEntries.push({ channel, key });
|
|
202
223
|
}
|
|
203
|
-
//
|
|
204
|
-
await
|
|
224
|
+
// Apply the validated entries inside the transactional mutation. Conflict detection happens against the current stored channels.
|
|
225
|
+
await mutateChannels((channels) => {
|
|
226
|
+
for (const { channel, key } of validEntries) {
|
|
227
|
+
// Check for conflicts with existing channels.
|
|
228
|
+
if (key in channels) {
|
|
229
|
+
conflicts.push(key);
|
|
230
|
+
if (conflictMode === "skip") {
|
|
231
|
+
skipped++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Replace mode - count as replaced instead of imported.
|
|
235
|
+
replaced++;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
imported++;
|
|
239
|
+
}
|
|
240
|
+
// Add to channels collection.
|
|
241
|
+
channels[key] = channel;
|
|
242
|
+
affectedKeys.push(key);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
205
245
|
// Log the import.
|
|
206
246
|
LOG.info("M3U import completed: %d imported, %d replaced, %d skipped.", imported, replaced, skipped);
|
|
207
247
|
// Build response with patch for in-place UI update.
|
|
@@ -216,13 +256,12 @@ export function setupChannelRoutes(app) {
|
|
|
216
256
|
});
|
|
217
257
|
}
|
|
218
258
|
catch (error) {
|
|
219
|
-
|
|
220
|
-
res.status(500).json({ error: "Failed to import channels: " + formatError(error), success: false });
|
|
259
|
+
sendErrorResponse(res, error, "import channels");
|
|
221
260
|
}
|
|
222
261
|
});
|
|
223
262
|
// POST /config/channels/modify - Apply channel modifications from the browse modal. Handles four action types: 'add' creates new user channels, 'enable'
|
|
224
|
-
// re-enables a disabled predefined channel and sets its
|
|
225
|
-
// channel to its canonical
|
|
263
|
+
// re-enables a disabled predefined channel and sets its service, 'switch' changes the service selection for an existing channel, and 'remove' reverts a
|
|
264
|
+
// channel to its canonical service (disabling it if no alternative service is available). Channel writes and service selection changes are batched into
|
|
226
265
|
// single saves to avoid redundant file I/O.
|
|
227
266
|
app.post("/config/channels/modify", async (req, res) => {
|
|
228
267
|
try {
|
|
@@ -233,21 +272,17 @@ export function setupChannelRoutes(app) {
|
|
|
233
272
|
res.status(400).json({ error: "No channels provided.", success: false });
|
|
234
273
|
return;
|
|
235
274
|
}
|
|
236
|
-
// Load existing user channels.
|
|
237
|
-
const loadResult = await loadUserChannels();
|
|
238
|
-
const existingChannels = loadResult.parseError ? {} : loadResult.channels;
|
|
239
|
-
// Build a set of all existing channel keys (predefined + user) for deduplication.
|
|
240
|
-
const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(existingChannels)]);
|
|
241
275
|
const affectedKeys = new Set();
|
|
242
276
|
const disablePredefined = new Set();
|
|
243
277
|
const errors = [];
|
|
244
278
|
let added = 0;
|
|
245
279
|
let removed = 0;
|
|
246
280
|
let switched = 0;
|
|
247
|
-
|
|
248
|
-
|
|
281
|
+
// Mutable flags tracking whether channels or selections were modified. Declared as a state object rather than bare booleans so that TypeScript's
|
|
282
|
+
// control-flow narrowing does not produce false positives when reading the flags after the mutateChannels callback (which mutates them via closure).
|
|
283
|
+
const modified = { channels: false, selections: false };
|
|
249
284
|
// Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic. When
|
|
250
|
-
// canonicalKey is provided, the channel is a
|
|
285
|
+
// canonicalKey is provided, the channel is a service variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
|
|
251
286
|
// at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
|
|
252
287
|
function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
|
|
253
288
|
const channel = {
|
|
@@ -260,113 +295,117 @@ export function setupChannelRoutes(app) {
|
|
|
260
295
|
}
|
|
261
296
|
return channel;
|
|
262
297
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
298
|
+
// Process all entries inside a single transactional mutation to avoid TOCTOU races between load and save.
|
|
299
|
+
await mutateChannels((existingChannels) => {
|
|
300
|
+
// Build a set of all existing channel keys (predefined + user) for deduplication.
|
|
301
|
+
const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(existingChannels)]);
|
|
302
|
+
for (const entry of channels) {
|
|
303
|
+
const action = entry.action ?? "add";
|
|
304
|
+
const name = sanitizeString(entry.name?.trim() ?? "");
|
|
305
|
+
const serviceSlug = entry.serviceSlug?.trim() ?? "";
|
|
306
|
+
// Handle enable and switch. Enable re-enables a disabled predefined channel, then falls through to the switch logic to set the service selection.
|
|
307
|
+
// Switch changes the service selection for an existing channel to point to the browsed service's variant, creating the variant if needed.
|
|
308
|
+
if ((action === "switch") || (action === "enable")) {
|
|
309
|
+
const canonicalKey = entry.canonicalKey?.trim() ?? "";
|
|
310
|
+
if (!canonicalKey) {
|
|
311
|
+
errors.push("Entry for '" + name + "' missing canonical key.");
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const variantKey = canonicalKey + "-" + serviceSlug;
|
|
315
|
+
// If no variant exists for this service, create one as a user channel. The canonicalKey parameter tells buildUserChannel to produce a variant
|
|
316
|
+
// (service-specific fields only, no identity fields).
|
|
317
|
+
if (!allKeys.has(variantKey)) {
|
|
318
|
+
existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""), canonicalKey);
|
|
319
|
+
allKeys.add(variantKey);
|
|
320
|
+
modified.channels = true;
|
|
321
|
+
}
|
|
322
|
+
setServiceSelection(canonicalKey, variantKey);
|
|
323
|
+
modified.selections = true;
|
|
324
|
+
switched++;
|
|
325
|
+
affectedKeys.add(canonicalKey);
|
|
273
326
|
continue;
|
|
274
327
|
}
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
328
|
+
// Handle service removal. Reverts the channel away from this service using a three-tier fallback: (1) clear the selection and let
|
|
329
|
+
// resolveServiceKey find the next enabled variant or canonical, (2) if the resolved service is still this service (no alternative exists),
|
|
330
|
+
// disable the predefined channel or delete the user channel.
|
|
331
|
+
if (action === "remove") {
|
|
332
|
+
const canonicalKey = entry.canonicalKey?.trim() ?? "";
|
|
333
|
+
if (!canonicalKey) {
|
|
334
|
+
errors.push("Remove entry for '" + name + "' missing canonical key.");
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
// Clear the service selection. resolveServiceKey will now return the canonical or the first enabled alternative variant.
|
|
338
|
+
setServiceSelection(canonicalKey, canonicalKey);
|
|
339
|
+
modified.selections = true;
|
|
340
|
+
// Check if the resolved service is still this service. If so, there is no alternative — disable or delete the channel.
|
|
341
|
+
const resolvedKey = resolveServiceKey(canonicalKey);
|
|
342
|
+
const resolvedTag = getServiceTagForChannel(resolvedKey);
|
|
343
|
+
if (resolvedTag === serviceSlug) {
|
|
344
|
+
// No alternative service exists. Disable predefined channels or delete user channels.
|
|
345
|
+
if (isPredefinedChannel(canonicalKey)) {
|
|
346
|
+
disablePredefined.add(canonicalKey);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
Reflect.deleteProperty(existingChannels, canonicalKey);
|
|
350
|
+
modified.channels = true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
removed++;
|
|
354
|
+
affectedKeys.add(canonicalKey);
|
|
355
|
+
continue;
|
|
280
356
|
}
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
357
|
+
// Handle add. Creates a new user channel for channels not yet in the lineup. The browse modal sends 'add' only for genuinely new channels (no
|
|
358
|
+
// existing canonical). Channels that match existing canonicals appear as 'switch' state in the modal.
|
|
359
|
+
const url = sanitizeString(entry.url?.trim() ?? "");
|
|
360
|
+
const channelSelector = sanitizeString(entry.channelSelector?.trim() ?? "");
|
|
361
|
+
if (!name) {
|
|
362
|
+
errors.push("Channel entry missing name.");
|
|
363
|
+
continue;
|
|
288
364
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
switched++;
|
|
292
|
-
affectedKeys.add(canonicalKey);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
// Handle provider removal. Reverts the channel away from this provider using a three-tier fallback: (1) clear the selection and let
|
|
296
|
-
// resolveProviderKey find the next enabled variant or canonical, (2) if the resolved provider is still this provider (no alternative exists),
|
|
297
|
-
// disable the predefined channel or delete the user channel.
|
|
298
|
-
if (action === "remove") {
|
|
299
|
-
const canonicalKey = entry.canonicalKey?.trim() ?? "";
|
|
300
|
-
if (!canonicalKey) {
|
|
301
|
-
errors.push("Remove entry for '" + name + "' missing canonical key.");
|
|
365
|
+
if (!url) {
|
|
366
|
+
errors.push("Channel '" + name + "' missing URL.");
|
|
302
367
|
continue;
|
|
303
368
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const resolvedKey = resolveProviderKey(canonicalKey);
|
|
309
|
-
const resolvedTag = getProviderTagForChannel(resolvedKey);
|
|
310
|
-
if (resolvedTag === providerSlug) {
|
|
311
|
-
// No alternative provider exists. Disable predefined channels or delete user channels.
|
|
312
|
-
if (isPredefinedChannel(canonicalKey)) {
|
|
313
|
-
disablePredefined.add(canonicalKey);
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
Reflect.deleteProperty(existingChannels, canonicalKey);
|
|
317
|
-
channelsChanged = true;
|
|
318
|
-
}
|
|
369
|
+
const urlError = validateChannelUrl(url);
|
|
370
|
+
if (urlError) {
|
|
371
|
+
errors.push("Channel '" + name + "': " + urlError);
|
|
372
|
+
continue;
|
|
319
373
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
const baseKey = generateChannelKey(name);
|
|
342
|
-
if (!baseKey) {
|
|
343
|
-
errors.push("Could not generate key for channel '" + name + "'.");
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
const canonicalExists = allKeys.has(baseKey);
|
|
347
|
-
const key = (canonicalExists && providerSlug) ? baseKey + "-" + providerSlug : baseKey;
|
|
348
|
-
// Skip channels that already exist in the lineup.
|
|
349
|
-
if (allKeys.has(key)) {
|
|
350
|
-
continue;
|
|
374
|
+
const baseKey = generateChannelKey(name);
|
|
375
|
+
if (!baseKey) {
|
|
376
|
+
errors.push("Could not generate key for channel '" + name + "'.");
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const canonicalExists = allKeys.has(baseKey);
|
|
380
|
+
const key = (canonicalExists && serviceSlug) ? baseKey + "-" + serviceSlug : baseKey;
|
|
381
|
+
// Skip channels that already exist in the lineup.
|
|
382
|
+
if (allKeys.has(key)) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// When the key differs from baseKey, this is a service variant — pass baseKey as canonicalKey so buildUserChannel produces a variant with only
|
|
386
|
+
// service-specific fields. Standalone channels (key === baseKey) get the full channel object with identity fields.
|
|
387
|
+
const newChannel = buildUserChannel(entry, name, url, channelSelector, (key !== baseKey) ? baseKey : undefined);
|
|
388
|
+
existingChannels[key] = newChannel;
|
|
389
|
+
allKeys.add(key);
|
|
390
|
+
modified.channels = true;
|
|
391
|
+
added++;
|
|
392
|
+
affectedKeys.add(canonicalExists ? baseKey : key);
|
|
351
393
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
// Save
|
|
362
|
-
//
|
|
363
|
-
if (
|
|
364
|
-
await
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
await saveProviderSelections();
|
|
368
|
-
}
|
|
369
|
-
// Disable predefined channels that had no alternative provider after removal.
|
|
394
|
+
});
|
|
395
|
+
// Enable predefined channels that had the "enable" action. This must happen after the mutation completes since the config write makes the channel
|
|
396
|
+
// visible in the lineup.
|
|
397
|
+
const enableKeys = channels
|
|
398
|
+
.filter((e) => (e.action === "enable") && Boolean(e.canonicalKey?.trim()))
|
|
399
|
+
.map((e) => e.canonicalKey.trim());
|
|
400
|
+
if (enableKeys.length > 0) {
|
|
401
|
+
await enablePredefinedChannels(enableKeys);
|
|
402
|
+
}
|
|
403
|
+
// Save service selections when selections changed but no channel data was written. When channels were modified, the mutation already persisted
|
|
404
|
+
// selections as part of the channel write.
|
|
405
|
+
if (!modified.channels && modified.selections) {
|
|
406
|
+
await saveServiceSelections();
|
|
407
|
+
}
|
|
408
|
+
// Disable predefined channels that had no alternative service after removal.
|
|
370
409
|
await disablePredefinedChannels([...disablePredefined]);
|
|
371
410
|
for (const disabledKey of disablePredefined) {
|
|
372
411
|
affectedKeys.add(disabledKey);
|
|
@@ -384,11 +423,14 @@ export function setupChannelRoutes(app) {
|
|
|
384
423
|
}
|
|
385
424
|
const message = (parts.length > 0 ? parts.join(" ") : "No changes made.") + PLAYLIST_HINT;
|
|
386
425
|
LOG.info("Browse channels completed: %d added, %d switched, %d removed.", added, switched, removed);
|
|
387
|
-
|
|
426
|
+
// Check if the browsed service isn't in the active filter. All channels in a browse batch share the same service — derive the tag from the first add entry.
|
|
427
|
+
const firstAddUrl = (added > 0) ? channels.find((e) => (e.action === "add"))?.url : undefined;
|
|
428
|
+
const serviceWarning = firstAddUrl ? buildServiceFilterWarning(firstAddUrl) : undefined;
|
|
429
|
+
res.json({ added, errors, message, patch: buildChannelTablePatch([...affectedKeys], getProfiles()), removed, serviceWarning, success: true,
|
|
430
|
+
switched });
|
|
388
431
|
}
|
|
389
432
|
catch (error) {
|
|
390
|
-
|
|
391
|
-
res.status(500).json({ error: "Failed to apply changes: " + formatError(error), success: false });
|
|
433
|
+
sendErrorResponse(res, error, "apply changes");
|
|
392
434
|
}
|
|
393
435
|
});
|
|
394
436
|
// POST /config/channels/toggle-predefined - Toggle a single predefined channel's enabled/disabled state.
|
|
@@ -423,57 +465,55 @@ export function setupChannelRoutes(app) {
|
|
|
423
465
|
res.json({ enabled, key, patch: buildChannelTablePatch([key], getProfiles()), success: true });
|
|
424
466
|
}
|
|
425
467
|
catch (error) {
|
|
426
|
-
|
|
427
|
-
res.status(500).json({ error: "Failed to toggle channel: " + formatError(error), success: false });
|
|
468
|
+
sendErrorResponse(res, error, "toggle channel");
|
|
428
469
|
}
|
|
429
470
|
});
|
|
430
|
-
// POST /config/
|
|
431
|
-
app.post("/config/
|
|
471
|
+
// POST /config/service - Update service selection for a multi-service channel.
|
|
472
|
+
app.post("/config/service", async (req, res) => {
|
|
432
473
|
try {
|
|
433
474
|
const body = req.body;
|
|
434
475
|
const channelKey = body.channel?.trim();
|
|
435
|
-
const
|
|
476
|
+
const serviceKey = body.service?.trim();
|
|
436
477
|
// Validate channel key is provided.
|
|
437
478
|
if (!channelKey) {
|
|
438
479
|
res.status(400).json({ error: "Channel key is required.", success: false });
|
|
439
480
|
return;
|
|
440
481
|
}
|
|
441
|
-
// Validate
|
|
442
|
-
if (!
|
|
443
|
-
res.status(400).json({ error: "
|
|
482
|
+
// Validate service key is provided.
|
|
483
|
+
if (!serviceKey) {
|
|
484
|
+
res.status(400).json({ error: "Service key is required.", success: false });
|
|
444
485
|
return;
|
|
445
486
|
}
|
|
446
487
|
// Canonicalize the channel key to ensure selections are stored under the canonical key, not variant keys.
|
|
447
488
|
const canonicalKey = getCanonicalKey(channelKey);
|
|
448
|
-
// Validate the channel has
|
|
449
|
-
const
|
|
450
|
-
if (!
|
|
451
|
-
res.status(400).json({ error: "Channel '" + canonicalKey + "' does not have multiple
|
|
489
|
+
// Validate the channel has service options.
|
|
490
|
+
const serviceGroup = getServiceGroup(canonicalKey);
|
|
491
|
+
if (!serviceGroup) {
|
|
492
|
+
res.status(400).json({ error: "Channel '" + canonicalKey + "' does not have multiple services.", success: false });
|
|
452
493
|
return;
|
|
453
494
|
}
|
|
454
|
-
// Validate the
|
|
455
|
-
const
|
|
456
|
-
if (!
|
|
457
|
-
res.status(400).json({ error: "Invalid
|
|
495
|
+
// Validate the service key is valid for this channel.
|
|
496
|
+
const validServiceKeys = serviceGroup.variants.map((v) => v.key);
|
|
497
|
+
if (!validServiceKeys.includes(serviceKey)) {
|
|
498
|
+
res.status(400).json({ error: "Invalid service '" + serviceKey + "' for channel '" + canonicalKey + "'.", success: false });
|
|
458
499
|
return;
|
|
459
500
|
}
|
|
460
|
-
// Update the
|
|
461
|
-
|
|
501
|
+
// Update the service selection.
|
|
502
|
+
setServiceSelection(canonicalKey, serviceKey);
|
|
462
503
|
// Save to disk.
|
|
463
|
-
await
|
|
504
|
+
await saveServiceSelections();
|
|
464
505
|
// Resolve display names for logging before generating the row HTML.
|
|
465
506
|
const canonicalChannel = getResolvedChannel(canonicalKey);
|
|
466
|
-
const variantChannel = getResolvedChannel(
|
|
507
|
+
const variantChannel = getResolvedChannel(serviceKey);
|
|
467
508
|
const channelName = canonicalChannel?.name ?? canonicalKey;
|
|
468
|
-
const
|
|
469
|
-
LOG.info("
|
|
509
|
+
const serviceLabel = variantChannel ? getChannelServiceLabel(variantChannel) : serviceKey;
|
|
510
|
+
LOG.info("Service for %s changed to %s.", channelName, serviceLabel);
|
|
470
511
|
// Return a channel table patch so the client can update the row and summary counts in place.
|
|
471
512
|
const profiles = getProfiles();
|
|
472
|
-
res.json({ channel: canonicalKey, patch: buildChannelTablePatch([canonicalKey], profiles),
|
|
513
|
+
res.json({ channel: canonicalKey, patch: buildChannelTablePatch([canonicalKey], profiles), service: serviceKey, success: true });
|
|
473
514
|
}
|
|
474
515
|
catch (error) {
|
|
475
|
-
|
|
476
|
-
res.status(500).json({ error: "Failed to update provider: " + formatError(error), success: false });
|
|
516
|
+
sendErrorResponse(res, error, "update service");
|
|
477
517
|
}
|
|
478
518
|
});
|
|
479
519
|
// POST /config/channels/bulk-toggle-predefined - Toggle predefined channels by scope (all, pacific, east).
|
|
@@ -508,43 +548,41 @@ export function setupChannelRoutes(app) {
|
|
|
508
548
|
break;
|
|
509
549
|
}
|
|
510
550
|
}
|
|
511
|
-
|
|
512
|
-
const configResult = await loadUserConfig();
|
|
513
|
-
const userConfig = configResult.config;
|
|
514
|
-
// Initialize channels.disabledPredefined if not present.
|
|
515
|
-
userConfig.channels ??= {};
|
|
551
|
+
let updatedList = [];
|
|
516
552
|
const targetSet = new Set(targetKeys);
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// Scoped enable: remove target keys from the disabled list (subtractive — preserves other disabled channels).
|
|
523
|
-
userConfig.channels.disabledPredefined = (userConfig.channels.disabledPredefined ?? []).filter((k) => !targetSet.has(k));
|
|
524
|
-
}
|
|
525
|
-
else {
|
|
526
|
-
// Disable: add target keys to the disabled list (additive — preserves other disabled channels).
|
|
527
|
-
const existing = new Set(userConfig.channels.disabledPredefined ?? []);
|
|
528
|
-
for (const k of targetKeys) {
|
|
529
|
-
existing.add(k);
|
|
553
|
+
await mutateConfig((config) => {
|
|
554
|
+
config.channels ??= {};
|
|
555
|
+
if (enabled && (scope === "all")) {
|
|
556
|
+
// Enable all is a full reset - clear the entire disabled list.
|
|
557
|
+
config.channels.disabledPredefined = [];
|
|
530
558
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
559
|
+
else if (enabled) {
|
|
560
|
+
// Scoped enable: remove target keys from the disabled list (subtractive - preserves other disabled channels).
|
|
561
|
+
config.channels.disabledPredefined = (config.channels.disabledPredefined ?? []).filter((k) => !targetSet.has(k));
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// Disable: add target keys to the disabled list (additive - preserves other disabled channels).
|
|
565
|
+
const existing = new Set(config.channels.disabledPredefined ?? []);
|
|
566
|
+
for (const k of targetKeys) {
|
|
567
|
+
existing.add(k);
|
|
568
|
+
}
|
|
569
|
+
config.channels.disabledPredefined = [...existing].toSorted();
|
|
570
|
+
}
|
|
571
|
+
updatedList = config.channels.disabledPredefined;
|
|
572
|
+
});
|
|
534
573
|
// Update the runtime CONFIG to reflect the change immediately.
|
|
535
|
-
CONFIG.channels.disabledPredefined =
|
|
574
|
+
CONFIG.channels.disabledPredefined = updatedList;
|
|
536
575
|
const affected = targetKeys.length;
|
|
537
576
|
const scopeLabel = (scope === "all") ? "All" : (scope === "pacific") ? "Pacific" : "East";
|
|
538
577
|
LOG.info("%s predefined channels %s (%d affected).", scopeLabel, enabled ? "enabled" : "disabled", affected);
|
|
539
578
|
res.json({ affected, enabled, keys: targetKeys, patch: buildChannelTablePatch(targetKeys, getProfiles()), scope, success: true });
|
|
540
579
|
}
|
|
541
580
|
catch (error) {
|
|
542
|
-
|
|
543
|
-
res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
|
|
581
|
+
sendErrorResponse(res, error, "toggle channels");
|
|
544
582
|
}
|
|
545
583
|
});
|
|
546
584
|
// POST /config/channels/auto-number - Assign sequential channel numbers to visible channels in the current sort order, or clear all channel numbers when
|
|
547
|
-
// start is 0. Overwrites existing channel numbers for affected channels. Only channels that are enabled and available by
|
|
585
|
+
// start is 0. Overwrites existing channel numbers for affected channels. Only channels that are enabled and available by service filter are affected.
|
|
548
586
|
app.post("/config/channels/auto-number", async (req, res) => {
|
|
549
587
|
try {
|
|
550
588
|
const body = req.body;
|
|
@@ -560,40 +598,35 @@ export function setupChannelRoutes(app) {
|
|
|
560
598
|
res.status(400).json({ error: "Invalid sort field.", success: false });
|
|
561
599
|
return;
|
|
562
600
|
}
|
|
563
|
-
// Get visible channels (enabled + available by
|
|
564
|
-
const listing = getChannelListing().filter((entry) => entry.enabled && entry.
|
|
601
|
+
// Get visible channels (enabled + available by service filter) sorted by the user's current sort order.
|
|
602
|
+
const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByService);
|
|
565
603
|
listing.sort((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
|
|
566
|
-
// Load user channels for modification.
|
|
567
|
-
const result = await loadUserChannels();
|
|
568
|
-
if (result.parseError) {
|
|
569
|
-
res.status(400).json({ error: "Cannot auto-number: channels file contains invalid JSON.", success: false });
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
604
|
const affectedKeys = [];
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
605
|
+
await mutateChannels((channels) => {
|
|
606
|
+
if (clearMode) {
|
|
607
|
+
// Clear channel numbers from all visible channels. Null signals "clear this field" - the normalizer handles the storage conventions.
|
|
608
|
+
for (const entry of listing) {
|
|
609
|
+
const existing = channels[entry.key] ?? {};
|
|
610
|
+
existing.channelNumber = null;
|
|
611
|
+
channels[entry.key] = existing;
|
|
612
|
+
affectedKeys.push(entry.key);
|
|
613
|
+
}
|
|
580
614
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
615
|
+
else {
|
|
616
|
+
// Assign sequential numbers starting from the requested start value.
|
|
617
|
+
for (let i = 0; i < listing.length; i++) {
|
|
618
|
+
const entry = listing[i];
|
|
619
|
+
const num = start + i;
|
|
620
|
+
if (num > 99999) {
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
const existing = channels[entry.key] ?? {};
|
|
624
|
+
existing.channelNumber = num;
|
|
625
|
+
channels[entry.key] = existing;
|
|
626
|
+
affectedKeys.push(entry.key);
|
|
589
627
|
}
|
|
590
|
-
const existing = result.channels[entry.key] ?? {};
|
|
591
|
-
existing.channelNumber = num;
|
|
592
|
-
result.channels[entry.key] = existing;
|
|
593
|
-
affectedKeys.push(entry.key);
|
|
594
628
|
}
|
|
595
|
-
}
|
|
596
|
-
await saveUserChannels(result.channels);
|
|
629
|
+
});
|
|
597
630
|
const action = clearMode ? "Cleared" : "Numbered";
|
|
598
631
|
LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
|
|
599
632
|
const profiles = getProfiles();
|
|
@@ -603,8 +636,7 @@ export function setupChannelRoutes(app) {
|
|
|
603
636
|
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
604
637
|
}
|
|
605
638
|
catch (error) {
|
|
606
|
-
|
|
607
|
-
res.status(500).json({ error: "Failed to auto-number channels: " + formatError(error), success: false });
|
|
639
|
+
sendErrorResponse(res, error, "auto-number channels");
|
|
608
640
|
}
|
|
609
641
|
});
|
|
610
642
|
// POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
|
|
@@ -613,31 +645,29 @@ export function setupChannelRoutes(app) {
|
|
|
613
645
|
try {
|
|
614
646
|
const body = req.body;
|
|
615
647
|
const enable = body.enable === true;
|
|
616
|
-
// Get visible channels (enabled + available by
|
|
617
|
-
const listing = getChannelListing().filter((entry) => entry.enabled && entry.
|
|
618
|
-
// Load user channels for modification.
|
|
619
|
-
const result = await loadUserChannels();
|
|
620
|
-
if (result.parseError) {
|
|
621
|
-
res.status(400).json({ error: "Cannot update HDHR settings: channels file contains invalid JSON.", success: false });
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
648
|
+
// Get visible channels (enabled + available by service filter).
|
|
649
|
+
const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByService);
|
|
624
650
|
const affectedKeys = [];
|
|
651
|
+
// Pre-compute which channels need updating. The listing snapshot is consistent since no concurrent mutation can change it before we enter the lock.
|
|
625
652
|
for (const entry of listing) {
|
|
626
653
|
const current = entry.channel.hdhrEnabled !== false;
|
|
627
654
|
// Skip channels that already match the target state.
|
|
628
655
|
if (current === enable) {
|
|
629
656
|
continue;
|
|
630
657
|
}
|
|
631
|
-
const existing = result.channels[entry.key] ?? {};
|
|
632
|
-
existing.hdhrEnabled = enable ? null : false;
|
|
633
|
-
result.channels[entry.key] = existing;
|
|
634
658
|
affectedKeys.push(entry.key);
|
|
635
659
|
}
|
|
636
660
|
if (affectedKeys.length === 0) {
|
|
637
661
|
res.json({ affected: 0, message: "No changes needed.", success: true });
|
|
638
662
|
return;
|
|
639
663
|
}
|
|
640
|
-
await
|
|
664
|
+
await mutateChannels((channels) => {
|
|
665
|
+
for (const key of affectedKeys) {
|
|
666
|
+
const existing = channels[key] ?? {};
|
|
667
|
+
existing.hdhrEnabled = enable ? null : false;
|
|
668
|
+
channels[key] = existing;
|
|
669
|
+
}
|
|
670
|
+
});
|
|
641
671
|
const action = enable ? "included in" : "excluded from";
|
|
642
672
|
LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
|
|
643
673
|
const profiles = getProfiles();
|
|
@@ -645,18 +675,17 @@ export function setupChannelRoutes(app) {
|
|
|
645
675
|
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
646
676
|
}
|
|
647
677
|
catch (error) {
|
|
648
|
-
|
|
649
|
-
res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
|
|
678
|
+
sendErrorResponse(res, error, "toggle HDHR settings");
|
|
650
679
|
}
|
|
651
680
|
});
|
|
652
|
-
// POST /config/channels/bulk-tags - Add or remove a tag on all enabled,
|
|
681
|
+
// POST /config/channels/bulk-tags - Add or remove a tag on all enabled, service-available channels. Operates on the same channel set as other bulk actions.
|
|
653
682
|
// transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
|
|
654
683
|
// modal body.
|
|
655
684
|
app.post("/config/channels/bulk-tags", async (req, res) => {
|
|
656
685
|
try {
|
|
657
686
|
const body = req.body;
|
|
658
687
|
const action = body.action;
|
|
659
|
-
const tag = typeof body.tag === "string" ? body.tag.trim()
|
|
688
|
+
const tag = typeof body.tag === "string" ? body.tag.trim() : "";
|
|
660
689
|
if ((action !== "add") && (action !== "remove")) {
|
|
661
690
|
res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
|
|
662
691
|
return;
|
|
@@ -665,13 +694,13 @@ export function setupChannelRoutes(app) {
|
|
|
665
694
|
res.status(400).json({ error: "Tag is required.", success: false });
|
|
666
695
|
return;
|
|
667
696
|
}
|
|
668
|
-
// Validate the tag is in the active vocabulary.
|
|
669
|
-
const
|
|
670
|
-
if (!
|
|
697
|
+
// Validate the tag is in the active vocabulary (case-insensitive).
|
|
698
|
+
const vocabularyLower = new Set(getActiveTagVocabulary().map((t) => t.toLowerCase()));
|
|
699
|
+
if (!vocabularyLower.has(tag.toLowerCase())) {
|
|
671
700
|
res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
|
|
672
701
|
return;
|
|
673
702
|
}
|
|
674
|
-
const { affectedKeys, error } = await transformChannelTags((entry) => entry.enabled && entry.
|
|
703
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.enabled && entry.availableByService, (tags) => (action === "add") ? (tags.includes(tag) ? tags : [...tags, tag]) : tags.filter((t) => t !== tag));
|
|
675
704
|
if (error) {
|
|
676
705
|
res.status(400).json({ error, success: false });
|
|
677
706
|
return;
|
|
@@ -694,66 +723,62 @@ export function setupChannelRoutes(app) {
|
|
|
694
723
|
});
|
|
695
724
|
}
|
|
696
725
|
catch (error) {
|
|
697
|
-
|
|
698
|
-
res.status(500).json({ error: "Failed to update tags: " + formatError(error), success: false });
|
|
726
|
+
sendErrorResponse(res, error, "update tags");
|
|
699
727
|
}
|
|
700
728
|
});
|
|
701
|
-
// POST /config/
|
|
702
|
-
app.post("/config/
|
|
729
|
+
// POST /config/service-filter - Update the service filter (enabled service tags).
|
|
730
|
+
app.post("/config/service-filter", async (req, res) => {
|
|
703
731
|
try {
|
|
704
732
|
const body = req.body;
|
|
705
|
-
const tags = body.
|
|
733
|
+
const tags = body.enabledServices;
|
|
706
734
|
// Validate tags is an array.
|
|
707
735
|
if (!Array.isArray(tags)) {
|
|
708
|
-
res.status(400).json({ error: "
|
|
736
|
+
res.status(400).json({ error: "enabledServices must be an array.", success: false });
|
|
709
737
|
return;
|
|
710
738
|
}
|
|
711
|
-
// Validate all tags are known. Tags already in
|
|
739
|
+
// Validate all tags are known. Tags already in enabledServices are accepted even if no current channel or profile produces them — this allows stale tags to be
|
|
712
740
|
// removed via the UI without blocking the request.
|
|
713
|
-
const knownTags = new Set(
|
|
714
|
-
const currentTags = new Set(
|
|
741
|
+
const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
|
|
742
|
+
const currentTags = new Set(getEnabledServices());
|
|
715
743
|
for (const tag of tags) {
|
|
716
744
|
if (!knownTags.has(tag) && !currentTags.has(tag)) {
|
|
717
|
-
res.status(400).json({ error: "Unknown
|
|
745
|
+
res.status(400).json({ error: "Unknown service tag: " + tag, success: false });
|
|
718
746
|
return;
|
|
719
747
|
}
|
|
720
748
|
}
|
|
721
749
|
// Update module-level state.
|
|
722
|
-
|
|
750
|
+
setEnabledServices(tags);
|
|
723
751
|
// Update runtime CONFIG.
|
|
724
|
-
CONFIG.channels.
|
|
752
|
+
CONFIG.channels.enabledServices = [...tags];
|
|
725
753
|
// Save to config file.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
|
|
754
|
+
await mutateConfig((config) => {
|
|
755
|
+
config.channels ??= {};
|
|
756
|
+
config.channels.enabledServices = tags;
|
|
757
|
+
});
|
|
758
|
+
LOG.info("Service filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all services");
|
|
732
759
|
// Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
|
|
733
760
|
const { counts, scopeCounts } = buildChannelTableState();
|
|
734
|
-
res.json({
|
|
761
|
+
res.json({ enabledServices: tags, patch: { counts, rows: [], scopeCounts }, success: true });
|
|
735
762
|
}
|
|
736
763
|
catch (error) {
|
|
737
|
-
|
|
738
|
-
res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
|
|
764
|
+
sendErrorResponse(res, error, "update service filter");
|
|
739
765
|
}
|
|
740
766
|
});
|
|
741
|
-
// POST /config/channels/setup-completed - Mark the
|
|
767
|
+
// POST /config/channels/setup-completed - Mark the Service Setup flow as completed. Called when the wizard finishes or the user explicitly skips.
|
|
742
768
|
app.post("/config/channels/setup-completed", async (_req, res) => {
|
|
743
769
|
try {
|
|
744
770
|
CONFIG.channels.setupCompleted = true;
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
771
|
+
await mutateConfig((config) => {
|
|
772
|
+
config.channels ??= {};
|
|
773
|
+
config.channels.setupCompleted = true;
|
|
774
|
+
});
|
|
749
775
|
// Return a full patch so the client can refresh the channel table after the setup wizard completes. The wizard's browse step may have added channels
|
|
750
|
-
// via the modify endpoint, and the
|
|
776
|
+
// via the modify endpoint, and the service filter may have changed — the patch ensures the table reflects the current state.
|
|
751
777
|
const { counts, scopeCounts } = buildChannelTableState();
|
|
752
778
|
res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
|
|
753
779
|
}
|
|
754
780
|
catch (error) {
|
|
755
|
-
|
|
756
|
-
res.status(500).json({ error: "Failed to save setup state: " + formatError(error), success: false });
|
|
781
|
+
sendErrorResponse(res, error, "save setup state");
|
|
757
782
|
}
|
|
758
783
|
});
|
|
759
784
|
// POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
|
|
@@ -791,28 +816,26 @@ export function setupChannelRoutes(app) {
|
|
|
791
816
|
CONFIG.channels.channelSortDirection = body.sortDirection;
|
|
792
817
|
}
|
|
793
818
|
// Persist to config file.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
await saveUserConfig(filterDefaults(userConfig));
|
|
819
|
+
await mutateConfig((config) => {
|
|
820
|
+
config.channels ??= {};
|
|
821
|
+
config.channels.channelSortDirection = CONFIG.channels.channelSortDirection;
|
|
822
|
+
config.channels.channelSortField = CONFIG.channels.channelSortField;
|
|
823
|
+
config.channels.visibleColumns = CONFIG.channels.visibleColumns;
|
|
824
|
+
});
|
|
801
825
|
res.json({ success: true });
|
|
802
826
|
}
|
|
803
827
|
catch (error) {
|
|
804
|
-
|
|
805
|
-
res.status(500).json({ error: "Failed to update display preferences: " + formatError(error), success: false });
|
|
828
|
+
sendErrorResponse(res, error, "update display preferences");
|
|
806
829
|
}
|
|
807
830
|
});
|
|
808
|
-
// POST /config/
|
|
809
|
-
app.post("/config/
|
|
831
|
+
// POST /config/service-bulk-assign - Set all channels to a specific service.
|
|
832
|
+
app.post("/config/service-bulk-assign", async (req, res) => {
|
|
810
833
|
try {
|
|
811
834
|
const body = req.body;
|
|
812
|
-
const
|
|
813
|
-
// Validate
|
|
814
|
-
if (!
|
|
815
|
-
res.status(400).json({ error: "
|
|
835
|
+
const serviceTag = body.service?.trim();
|
|
836
|
+
// Validate service tag.
|
|
837
|
+
if (!serviceTag) {
|
|
838
|
+
res.status(400).json({ error: "Service tag is required.", success: false });
|
|
816
839
|
return;
|
|
817
840
|
}
|
|
818
841
|
let affected = 0;
|
|
@@ -821,17 +844,17 @@ export function setupChannelRoutes(app) {
|
|
|
821
844
|
// Iterate all channels and set those with a matching variant.
|
|
822
845
|
const listing = getChannelListing();
|
|
823
846
|
for (const entry of listing) {
|
|
824
|
-
const group =
|
|
847
|
+
const group = getServiceGroup(entry.key);
|
|
825
848
|
if (!group || (group.variants.length <= 1)) {
|
|
826
849
|
continue;
|
|
827
850
|
}
|
|
828
|
-
// Find a variant matching the requested
|
|
829
|
-
const matchingVariant = group.variants.find((v) => (
|
|
851
|
+
// Find a variant matching the requested service tag.
|
|
852
|
+
const matchingVariant = group.variants.find((v) => (getServiceTagForChannel(v.key) === serviceTag));
|
|
830
853
|
if (matchingVariant) {
|
|
831
854
|
// Snapshot the current selection before overwriting so the client can offer undo.
|
|
832
|
-
const currentVariant =
|
|
855
|
+
const currentVariant = getServiceSelection(entry.key);
|
|
833
856
|
previousSelections[entry.key] = currentVariant ?? null;
|
|
834
|
-
|
|
857
|
+
setServiceSelection(entry.key, matchingVariant.key);
|
|
835
858
|
affected++;
|
|
836
859
|
// Collect the resolved profile name for client-side UI update.
|
|
837
860
|
const resolvedChannel = getResolvedChannel(matchingVariant.key);
|
|
@@ -839,17 +862,16 @@ export function setupChannelRoutes(app) {
|
|
|
839
862
|
}
|
|
840
863
|
}
|
|
841
864
|
// Save to disk.
|
|
842
|
-
await
|
|
843
|
-
LOG.info("Bulk assign to '%s': %d of %d channels affected.",
|
|
865
|
+
await saveServiceSelections();
|
|
866
|
+
LOG.info("Bulk assign to '%s': %d of %d channels affected.", serviceTag, affected, listing.length);
|
|
844
867
|
res.json({ affected, previousSelections, selections, success: true, total: listing.length });
|
|
845
868
|
}
|
|
846
869
|
catch (error) {
|
|
847
|
-
|
|
848
|
-
res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
|
|
870
|
+
sendErrorResponse(res, error, "bulk assign service");
|
|
849
871
|
}
|
|
850
872
|
});
|
|
851
|
-
// POST /config/
|
|
852
|
-
app.post("/config/
|
|
873
|
+
// POST /config/service-bulk-restore - Restore previous service selections (undo bulk assign).
|
|
874
|
+
app.post("/config/service-bulk-restore", async (req, res) => {
|
|
853
875
|
try {
|
|
854
876
|
const body = req.body;
|
|
855
877
|
const previousSelections = body.selections;
|
|
@@ -860,21 +882,21 @@ export function setupChannelRoutes(app) {
|
|
|
860
882
|
let restored = 0;
|
|
861
883
|
const selections = {};
|
|
862
884
|
for (const [key, variantKey] of Object.entries(previousSelections)) {
|
|
863
|
-
const group =
|
|
885
|
+
const group = getServiceGroup(key);
|
|
864
886
|
if (!group) {
|
|
865
887
|
continue;
|
|
866
888
|
}
|
|
867
889
|
// A null value means the channel was using the default (canonical) selection. Restoring by setting the selection to the canonical key clears the override.
|
|
868
890
|
if (variantKey === null) {
|
|
869
|
-
|
|
891
|
+
setServiceSelection(key, key);
|
|
870
892
|
}
|
|
871
893
|
else {
|
|
872
|
-
// Validate the variant belongs to this channel's
|
|
894
|
+
// Validate the variant belongs to this channel's service group before restoring.
|
|
873
895
|
const isValid = group.variants.some((v) => (v.key === variantKey));
|
|
874
896
|
if (!isValid) {
|
|
875
897
|
continue;
|
|
876
898
|
}
|
|
877
|
-
|
|
899
|
+
setServiceSelection(key, variantKey);
|
|
878
900
|
}
|
|
879
901
|
restored++;
|
|
880
902
|
// Build the same selection response format as bulk assign for client-side UI updates.
|
|
@@ -883,13 +905,12 @@ export function setupChannelRoutes(app) {
|
|
|
883
905
|
selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
|
|
884
906
|
}
|
|
885
907
|
// Save to disk.
|
|
886
|
-
await
|
|
908
|
+
await saveServiceSelections();
|
|
887
909
|
LOG.info("Bulk restore: %d channel(s) reverted.", restored);
|
|
888
910
|
res.json({ restored, selections, success: true });
|
|
889
911
|
}
|
|
890
912
|
catch (error) {
|
|
891
|
-
|
|
892
|
-
res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
|
|
913
|
+
sendErrorResponse(res, error, "bulk restore services");
|
|
893
914
|
}
|
|
894
915
|
});
|
|
895
916
|
// POST /config/channels - Handle channel add, edit, delete, and revert operations. Returns JSON response.
|
|
@@ -913,16 +934,12 @@ export function setupChannelRoutes(app) {
|
|
|
913
934
|
res.status(400).json({ message: "Cannot revert '" + key + "': no override exists.", success: false });
|
|
914
935
|
return;
|
|
915
936
|
}
|
|
916
|
-
// Remove the override.
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
}
|
|
922
|
-
// Check if the override contains M3U-relevant fields before removing it.
|
|
923
|
-
const revertHint = playlistHintForStored(result.channels[key]);
|
|
924
|
-
Reflect.deleteProperty(result.channels, key);
|
|
925
|
-
await saveUserChannels(result.channels);
|
|
937
|
+
// Remove the override. Check if it contains M3U-relevant fields before removing so the response can include the playlist reload hint.
|
|
938
|
+
let revertHint = "";
|
|
939
|
+
await mutateChannels((channels) => {
|
|
940
|
+
revertHint = playlistHintForStored(channels[key]);
|
|
941
|
+
Reflect.deleteProperty(channels, key);
|
|
942
|
+
});
|
|
926
943
|
LOG.info("Channel '%s' reverted to predefined defaults.", key);
|
|
927
944
|
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
|
|
928
945
|
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
@@ -949,31 +966,28 @@ export function setupChannelRoutes(app) {
|
|
|
949
966
|
return;
|
|
950
967
|
}
|
|
951
968
|
}
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
result.channels[key] = stored;
|
|
976
|
-
await saveUserChannels(result.channels);
|
|
969
|
+
// Update the channel inside a transactional mutation. For predefined channels, store a delta override with null to clear fields. For user channels, use
|
|
970
|
+
// undefined to delete. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet. Null signals "clear this field"
|
|
971
|
+
// for all channel types - the normalizer handles delta comparison for predefined channels and null-stripping for user channels.
|
|
972
|
+
await mutateChannels((channels) => {
|
|
973
|
+
const stored = channels[key] ?? {};
|
|
974
|
+
if (field === "channelNumber") {
|
|
975
|
+
stored.channelNumber = value ? parseInt(value, 10) : null;
|
|
976
|
+
}
|
|
977
|
+
else if (field === "hdhrEnabled") {
|
|
978
|
+
stored.hdhrEnabled = (value === "false") ? false : null;
|
|
979
|
+
}
|
|
980
|
+
else if (field === "tags") {
|
|
981
|
+
const tags = value ?
|
|
982
|
+
[...new Set(value.split(",").map((t) => t.trim()).filter((t) => t.length > 0))].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) :
|
|
983
|
+
[];
|
|
984
|
+
stored.tags = (tags.length > 0) ? tags : null;
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
stored.stationId = value || null;
|
|
988
|
+
}
|
|
989
|
+
channels[key] = stored;
|
|
990
|
+
});
|
|
977
991
|
const fieldLabels = {
|
|
978
992
|
channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
|
|
979
993
|
};
|
|
@@ -995,13 +1009,9 @@ export function setupChannelRoutes(app) {
|
|
|
995
1009
|
return;
|
|
996
1010
|
}
|
|
997
1011
|
// Delete the channel.
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
Reflect.deleteProperty(result.channels, key);
|
|
1004
|
-
await saveUserChannels(result.channels);
|
|
1012
|
+
await mutateChannels((channels) => {
|
|
1013
|
+
Reflect.deleteProperty(channels, key);
|
|
1014
|
+
});
|
|
1005
1015
|
LOG.info("User channel '%s' deleted.", key);
|
|
1006
1016
|
// Return a patch. If a predefined channel exists with the same key, the patch includes an "update" row for the predefined version (so the client replaces
|
|
1007
1017
|
// the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
|
|
@@ -1025,13 +1035,17 @@ export function setupChannelRoutes(app) {
|
|
|
1025
1035
|
const name = sanitizeString(body.name ?? "");
|
|
1026
1036
|
const url = sanitizeString(body.url ?? "");
|
|
1027
1037
|
const profile = sanitizeString(body.profile ?? "");
|
|
1038
|
+
const guideTitle = sanitizeString(body.guideTitle ?? "");
|
|
1039
|
+
const logoUrl = sanitizeString(body.logoUrl ?? "");
|
|
1028
1040
|
const stationId = sanitizeString(body.stationId ?? "");
|
|
1029
1041
|
const channelSelector = sanitizeString(body.channelSelector ?? "");
|
|
1030
1042
|
const channelNumberStr = sanitizeString(body.channelNumber ?? "");
|
|
1031
1043
|
const hdhrEnabled = body.hdhrEnabled !== "false";
|
|
1032
|
-
// Parse tags from comma-separated input. Trim,
|
|
1044
|
+
// Parse tags from comma-separated input. Trim, filter empty, deduplicate, and sort case-insensitively.
|
|
1033
1045
|
const tagsRaw = sanitizeString(body.tags ?? "");
|
|
1034
|
-
const tags = tagsRaw ?
|
|
1046
|
+
const tags = tagsRaw ?
|
|
1047
|
+
[...new Set(tagsRaw.split(",").map((t) => t.trim()).filter((t) => t.length > 0))].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) :
|
|
1048
|
+
[];
|
|
1035
1049
|
// Validate channel number if provided.
|
|
1036
1050
|
const channelNumberError = validateChannelNumber(channelNumberStr, key);
|
|
1037
1051
|
if (channelNumberError) {
|
|
@@ -1064,18 +1078,6 @@ export function setupChannelRoutes(app) {
|
|
|
1064
1078
|
res.status(400).json({ errors: formErrors, success: false });
|
|
1065
1079
|
return;
|
|
1066
1080
|
}
|
|
1067
|
-
// Load existing user channels.
|
|
1068
|
-
const result = await loadUserChannels();
|
|
1069
|
-
if (result.parseError) {
|
|
1070
|
-
// If channels file is corrupt, start fresh on add (which will create a valid file).
|
|
1071
|
-
if (action === "add") {
|
|
1072
|
-
result.channels = {};
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
res.status(400).json({ message: "Cannot edit channel: channels file contains invalid JSON.", success: false });
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
1081
|
let playlistChanged = false;
|
|
1080
1082
|
// For predefined channels being edited, compute a delta of only the changed fields. For user-defined channels and adds, build a full channel object.
|
|
1081
1083
|
const predefinedBase = getPredefinedChannel(key);
|
|
@@ -1085,7 +1087,9 @@ export function setupChannelRoutes(app) {
|
|
|
1085
1087
|
const formValues = {
|
|
1086
1088
|
channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
|
|
1087
1089
|
channelSelector,
|
|
1090
|
+
guideTitle,
|
|
1088
1091
|
hdhrEnabled,
|
|
1092
|
+
logoUrl,
|
|
1089
1093
|
name,
|
|
1090
1094
|
profile,
|
|
1091
1095
|
stationId,
|
|
@@ -1100,11 +1104,11 @@ export function setupChannelRoutes(app) {
|
|
|
1100
1104
|
}
|
|
1101
1105
|
return (field === "channelNumber") ? val : val ?? "";
|
|
1102
1106
|
};
|
|
1103
|
-
// First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected
|
|
1107
|
+
// First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected service's resolved channel, which
|
|
1104
1108
|
// 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
|
|
1105
|
-
// values match the displayChannel exactly, the user saved without modification
|
|
1109
|
+
// values match the displayChannel exactly, the user saved without modification - no override should be created, and any existing override is preserved.
|
|
1106
1110
|
// Tags are compared separately (array comparison) after the scalar fields.
|
|
1107
|
-
const resolvedKey =
|
|
1111
|
+
const resolvedKey = resolveServiceKey(key);
|
|
1108
1112
|
const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
|
|
1109
1113
|
const displayTags = getChannelEffectiveTags(displayChannel);
|
|
1110
1114
|
const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
|
|
@@ -1137,12 +1141,14 @@ export function setupChannelRoutes(app) {
|
|
|
1137
1141
|
const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
|
|
1138
1142
|
(JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
|
|
1139
1143
|
if (!hasChanges) {
|
|
1140
|
-
// The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away
|
|
1144
|
+
// The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away - treat it as an
|
|
1141
1145
|
// implicit revert by removing the override and returning the predefined row HTML.
|
|
1142
1146
|
if (isUserChannel(key)) {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1147
|
+
let implicitRevertHint = "";
|
|
1148
|
+
await mutateChannels((channels) => {
|
|
1149
|
+
implicitRevertHint = playlistHintForStored(channels[key]);
|
|
1150
|
+
Reflect.deleteProperty(channels, key);
|
|
1151
|
+
});
|
|
1146
1152
|
LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
|
|
1147
1153
|
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
|
|
1148
1154
|
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
@@ -1151,15 +1157,15 @@ export function setupChannelRoutes(app) {
|
|
|
1151
1157
|
res.json({ key, message: "No changes to save.", success: true });
|
|
1152
1158
|
return;
|
|
1153
1159
|
}
|
|
1154
|
-
// The delta has changes vs the canonical predefined. Before storing a custom override, check if the form values match any
|
|
1160
|
+
// The delta has changes vs the canonical predefined. Before storing a custom override, check if the form values match any service variant's predefined
|
|
1155
1161
|
// 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
|
|
1156
|
-
// and reverts the change. The URL and channelSelector still differ from the canonical predefined but match the variant
|
|
1162
|
+
// 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,
|
|
1157
1163
|
// not a new customization. We resolve each variant against pure PREDEFINED data (not the user-overridden channelsRef) to avoid contamination from the
|
|
1158
1164
|
// current override.
|
|
1159
|
-
const
|
|
1165
|
+
const serviceGroup = getServiceGroup(key);
|
|
1160
1166
|
let matchedVariantKey;
|
|
1161
|
-
if (
|
|
1162
|
-
for (const variant of
|
|
1167
|
+
if (serviceGroup && isUserChannel(key)) {
|
|
1168
|
+
for (const variant of serviceGroup.variants) {
|
|
1163
1169
|
// Skip the canonical entry (already handled by the !hasChanges check above) and :predefined entries (synthetic entries for override UI).
|
|
1164
1170
|
if ((variant.key === key) || variant.key.includes(":")) {
|
|
1165
1171
|
continue;
|
|
@@ -1173,29 +1179,38 @@ export function setupChannelRoutes(app) {
|
|
|
1173
1179
|
}
|
|
1174
1180
|
}
|
|
1175
1181
|
if (matchedVariantKey) {
|
|
1176
|
-
// Form values match a known variant
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1182
|
+
// Form values match a known variant - revert the override and switch back to that variant.
|
|
1183
|
+
let variantRevertHint = "";
|
|
1184
|
+
await mutateChannels((channels) => {
|
|
1185
|
+
variantRevertHint = playlistHintForStored(channels[key]);
|
|
1186
|
+
Reflect.deleteProperty(channels, key);
|
|
1187
|
+
});
|
|
1188
|
+
setServiceSelection(key, matchedVariantKey);
|
|
1181
1189
|
LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
|
|
1182
1190
|
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
|
|
1183
1191
|
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
1184
1192
|
return;
|
|
1185
1193
|
}
|
|
1186
|
-
// No variant match
|
|
1194
|
+
// No variant match - store the delta and switch the service selection to the canonical key (the custom override). This ensures the service dropdown shows
|
|
1187
1195
|
// "Custom" after saving, which is the expected behavior when a user customizes a predefined channel.
|
|
1188
|
-
|
|
1189
|
-
|
|
1196
|
+
setServiceSelection(key, key);
|
|
1197
|
+
await mutateChannels((channels) => {
|
|
1198
|
+
channels[key] = delta;
|
|
1199
|
+
});
|
|
1190
1200
|
playlistChanged = M3U_FIELDS.some((f) => f in delta);
|
|
1191
1201
|
}
|
|
1192
1202
|
else {
|
|
1193
|
-
// User-defined channel or add: build a full channel object.
|
|
1194
|
-
const oldChannel = ((action === "edit") && (key in result.channels)) ? result.channels[key] : undefined;
|
|
1203
|
+
// User-defined channel or add: build a full channel object.
|
|
1195
1204
|
const channel = {
|
|
1196
1205
|
name,
|
|
1197
1206
|
url
|
|
1198
1207
|
};
|
|
1208
|
+
if (guideTitle) {
|
|
1209
|
+
channel.guideTitle = guideTitle;
|
|
1210
|
+
}
|
|
1211
|
+
if (logoUrl) {
|
|
1212
|
+
channel.logoUrl = logoUrl;
|
|
1213
|
+
}
|
|
1199
1214
|
if (profile) {
|
|
1200
1215
|
channel.profile = profile;
|
|
1201
1216
|
}
|
|
@@ -1215,13 +1230,20 @@ export function setupChannelRoutes(app) {
|
|
|
1215
1230
|
if (!hdhrEnabled) {
|
|
1216
1231
|
channel.hdhrEnabled = false;
|
|
1217
1232
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1233
|
+
// For edits, snapshot the old channel inside the mutation to detect M3U-relevant changes. For adds the playlist always changes.
|
|
1234
|
+
await mutateChannels((channels) => {
|
|
1235
|
+
if (action === "edit") {
|
|
1236
|
+
const oldChannel = (key in channels) ? channels[key] : undefined;
|
|
1237
|
+
channels[key] = channel;
|
|
1238
|
+
playlistChanged = (oldChannel !== undefined) &&
|
|
1239
|
+
M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]);
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
channels[key] = channel;
|
|
1243
|
+
playlistChanged = true;
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1223
1246
|
}
|
|
1224
|
-
await saveUserChannels(result.channels);
|
|
1225
1247
|
// Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
|
|
1226
1248
|
if (stationId) {
|
|
1227
1249
|
updateChannelLogo(name || key, stationId);
|
|
@@ -1231,18 +1253,20 @@ export function setupChannelRoutes(app) {
|
|
|
1231
1253
|
// Generate HTML for the channel row so the client can update the DOM without a full page reload.
|
|
1232
1254
|
// Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
|
|
1233
1255
|
const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
|
|
1234
|
-
//
|
|
1256
|
+
// Check if the new channel's service isn't in the active filter.
|
|
1257
|
+
const serviceWarning = (action === "add") ? buildServiceFilterWarning(url) : undefined;
|
|
1258
|
+
// Return success response with patch for client-side DOM update. Changes take effect immediately due to hot-reloading in mutateChannels().
|
|
1235
1259
|
res.json({
|
|
1236
1260
|
isNew: action === "add",
|
|
1237
1261
|
key,
|
|
1238
1262
|
message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
|
|
1239
1263
|
patch: buildChannelTablePatch([key], profiles),
|
|
1264
|
+
serviceWarning,
|
|
1240
1265
|
success: true
|
|
1241
1266
|
});
|
|
1242
1267
|
}
|
|
1243
1268
|
catch (error) {
|
|
1244
|
-
|
|
1245
|
-
res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
|
|
1269
|
+
sendErrorResponse(res, error, "save channel");
|
|
1246
1270
|
}
|
|
1247
1271
|
});
|
|
1248
1272
|
// Tag Management Endpoints.
|
|
@@ -1261,7 +1285,7 @@ export function setupChannelRoutes(app) {
|
|
|
1261
1285
|
app.post("/config/tags", async (req, res) => {
|
|
1262
1286
|
try {
|
|
1263
1287
|
const body = req.body;
|
|
1264
|
-
const tag = typeof body.tag === "string" ? body.tag.trim()
|
|
1288
|
+
const tag = typeof body.tag === "string" ? body.tag.trim() : "";
|
|
1265
1289
|
if (tag.length === 0) {
|
|
1266
1290
|
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1267
1291
|
return;
|
|
@@ -1270,19 +1294,19 @@ export function setupChannelRoutes(app) {
|
|
|
1270
1294
|
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1271
1295
|
return;
|
|
1272
1296
|
}
|
|
1273
|
-
if (!(/^[a-
|
|
1274
|
-
res.status(400).json({ error: "Tag name must start and end with a letter or number, and contain only
|
|
1297
|
+
if (!(/^[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?$/).test(tag)) {
|
|
1298
|
+
res.status(400).json({ error: "Tag name must start and end with a letter or number, and contain only letters, numbers, spaces, and hyphens.", success: false });
|
|
1275
1299
|
return;
|
|
1276
1300
|
}
|
|
1277
|
-
// Check if the tag already exists in the active vocabulary.
|
|
1278
|
-
const
|
|
1279
|
-
if (
|
|
1301
|
+
// Check if the tag already exists in the active vocabulary (case-insensitive).
|
|
1302
|
+
const vocabularyLower = new Set(getActiveTagVocabulary().map((t) => t.toLowerCase()));
|
|
1303
|
+
if (vocabularyLower.has(tag.toLowerCase())) {
|
|
1280
1304
|
res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
|
|
1281
1305
|
return;
|
|
1282
1306
|
}
|
|
1283
1307
|
// Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
|
|
1284
1308
|
const registry = getTagRegistry();
|
|
1285
|
-
if (registry.deletedTags.
|
|
1309
|
+
if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
|
|
1286
1310
|
res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
|
|
1287
1311
|
return;
|
|
1288
1312
|
}
|
|
@@ -1297,8 +1321,7 @@ export function setupChannelRoutes(app) {
|
|
|
1297
1321
|
});
|
|
1298
1322
|
}
|
|
1299
1323
|
catch (error) {
|
|
1300
|
-
|
|
1301
|
-
res.status(500).json({ error: "Failed to create tag: " + formatError(error), success: false });
|
|
1324
|
+
sendErrorResponse(res, error, "create tag");
|
|
1302
1325
|
}
|
|
1303
1326
|
});
|
|
1304
1327
|
// DELETE /config/tags/:tag - Delete a tag from the vocabulary and cascade to all channel assignments. For predefined tags, the tag is added to deletedTags. For
|
|
@@ -1306,36 +1329,38 @@ export function setupChannelRoutes(app) {
|
|
|
1306
1329
|
// override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
|
|
1307
1330
|
app.delete("/config/tags/:tag", async (req, res) => {
|
|
1308
1331
|
try {
|
|
1309
|
-
const tag = req.params.tag?.
|
|
1332
|
+
const tag = req.params.tag?.trim() ?? "";
|
|
1310
1333
|
if (tag.length === 0) {
|
|
1311
1334
|
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1312
1335
|
return;
|
|
1313
1336
|
}
|
|
1314
1337
|
const registry = getTagRegistry();
|
|
1315
|
-
const isPredefined = PREDEFINED_TAGS.
|
|
1316
|
-
const isUserTag = registry.tags.
|
|
1338
|
+
const isPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, tag));
|
|
1339
|
+
const isUserTag = registry.tags.some((t) => tagsMatch(t, tag));
|
|
1317
1340
|
if (!isPredefined && !isUserTag) {
|
|
1318
1341
|
res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
|
|
1319
1342
|
return;
|
|
1320
1343
|
}
|
|
1321
1344
|
if (isPredefined) {
|
|
1345
|
+
// Find the canonical predefined form for storage.
|
|
1346
|
+
const canonicalTag = PREDEFINED_TAGS.find((t) => tagsMatch(t, tag)) ?? tag;
|
|
1322
1347
|
// Already deleted — no-op, return current state without unnecessary I/O.
|
|
1323
|
-
if (registry.deletedTags.
|
|
1348
|
+
if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
|
|
1324
1349
|
res.json({
|
|
1325
1350
|
active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1326
1351
|
registry: getTagRegistry(), success: true
|
|
1327
1352
|
});
|
|
1328
1353
|
return;
|
|
1329
1354
|
}
|
|
1330
|
-
registry.deletedTags.push(
|
|
1355
|
+
registry.deletedTags.push(canonicalTag);
|
|
1331
1356
|
}
|
|
1332
1357
|
else {
|
|
1333
1358
|
// Remove from user tags.
|
|
1334
|
-
registry.tags = registry.tags.filter((t) => t
|
|
1359
|
+
registry.tags = registry.tags.filter((t) => !tagsMatch(t, tag));
|
|
1335
1360
|
}
|
|
1336
1361
|
setTagRegistry(registry);
|
|
1337
1362
|
// Cascade: strip the deleted tag from all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
1338
|
-
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.
|
|
1363
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.some((t) => tagsMatch(t, tag)) === true, (tags) => tags.filter((t) => !tagsMatch(t, tag)));
|
|
1339
1364
|
if (error) {
|
|
1340
1365
|
res.status(400).json({ error, success: false });
|
|
1341
1366
|
return;
|
|
@@ -1351,8 +1376,7 @@ export function setupChannelRoutes(app) {
|
|
|
1351
1376
|
});
|
|
1352
1377
|
}
|
|
1353
1378
|
catch (error) {
|
|
1354
|
-
|
|
1355
|
-
res.status(500).json({ error: "Failed to delete tag: " + formatError(error), success: false });
|
|
1379
|
+
sendErrorResponse(res, error, "delete tag");
|
|
1356
1380
|
}
|
|
1357
1381
|
});
|
|
1358
1382
|
// POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
|
|
@@ -1361,25 +1385,28 @@ export function setupChannelRoutes(app) {
|
|
|
1361
1385
|
app.post("/config/tags/restore", async (req, res) => {
|
|
1362
1386
|
try {
|
|
1363
1387
|
const body = req.body;
|
|
1364
|
-
const tag = typeof body.tag === "string" ? body.tag.trim()
|
|
1388
|
+
const tag = typeof body.tag === "string" ? body.tag.trim() : "";
|
|
1365
1389
|
if (tag.length === 0) {
|
|
1366
1390
|
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1367
1391
|
return;
|
|
1368
1392
|
}
|
|
1369
1393
|
const registry = getTagRegistry();
|
|
1370
|
-
if (!registry.deletedTags.
|
|
1394
|
+
if (!registry.deletedTags.some((t) => tagsMatch(t, tag))) {
|
|
1371
1395
|
res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
|
|
1372
1396
|
return;
|
|
1373
1397
|
}
|
|
1374
|
-
registry.deletedTags = registry.deletedTags.filter((t) => t
|
|
1398
|
+
registry.deletedTags = registry.deletedTags.filter((t) => !tagsMatch(t, tag));
|
|
1375
1399
|
setTagRegistry(registry);
|
|
1376
1400
|
// Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
|
|
1377
1401
|
// delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
|
|
1378
1402
|
// predefined definition, cleanly reverting the channel to its default state.
|
|
1403
|
+
// Resolve the canonical predefined tag name for the restored tag so channel data uses the predefined casing.
|
|
1404
|
+
const canonicalTag = PREDEFINED_TAGS.find((t) => tagsMatch(t, tag)) ?? tag;
|
|
1379
1405
|
const { affectedKeys, error } = await transformChannelTags((entry) => {
|
|
1380
1406
|
const predefined = getPredefinedChannel(entry.key);
|
|
1381
|
-
return (predefined?.tags?.
|
|
1382
|
-
|
|
1407
|
+
return (predefined?.tags?.some((t) => tagsMatch(t, tag)) === true) &&
|
|
1408
|
+
(entry.channel.tags?.some((t) => tagsMatch(t, tag)) !== true);
|
|
1409
|
+
}, (tags) => [...tags, canonicalTag]);
|
|
1383
1410
|
if (error) {
|
|
1384
1411
|
res.status(400).json({ error, success: false });
|
|
1385
1412
|
return;
|
|
@@ -1395,8 +1422,7 @@ export function setupChannelRoutes(app) {
|
|
|
1395
1422
|
});
|
|
1396
1423
|
}
|
|
1397
1424
|
catch (error) {
|
|
1398
|
-
|
|
1399
|
-
res.status(500).json({ error: "Failed to restore tag: " + formatError(error), success: false });
|
|
1425
|
+
sendErrorResponse(res, error, "restore tag");
|
|
1400
1426
|
}
|
|
1401
1427
|
});
|
|
1402
1428
|
// POST /config/tags/rename - Rename a tag atomically across the vocabulary and all channel assignments. For predefined tags, the old name is added to
|
|
@@ -1405,19 +1431,20 @@ export function setupChannelRoutes(app) {
|
|
|
1405
1431
|
app.post("/config/tags/rename", async (req, res) => {
|
|
1406
1432
|
try {
|
|
1407
1433
|
const body = req.body;
|
|
1408
|
-
const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim()
|
|
1409
|
-
const newTag = typeof body.newTag === "string" ? body.newTag.trim()
|
|
1434
|
+
const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim() : "";
|
|
1435
|
+
const newTag = typeof body.newTag === "string" ? body.newTag.trim() : "";
|
|
1410
1436
|
if (!oldTag || !newTag) {
|
|
1411
1437
|
res.status(400).json({ error: "Both old and new tag names are required.", success: false });
|
|
1412
1438
|
return;
|
|
1413
1439
|
}
|
|
1440
|
+
// Exact comparison — case-only renames (e.g., "sports" → "Sports") are valid since tags are freeform with preserved casing.
|
|
1414
1441
|
if (oldTag === newTag) {
|
|
1415
1442
|
res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
|
|
1416
1443
|
return;
|
|
1417
1444
|
}
|
|
1418
|
-
if (!(/^[a-
|
|
1445
|
+
if (!(/^[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?$/).test(newTag)) {
|
|
1419
1446
|
res.status(400).json({
|
|
1420
|
-
error: "Tag name must start and end with a letter or number, and contain only
|
|
1447
|
+
error: "Tag name must start and end with a letter or number, and contain only letters, numbers, spaces, and hyphens.", success: false
|
|
1421
1448
|
});
|
|
1422
1449
|
return;
|
|
1423
1450
|
}
|
|
@@ -1425,34 +1452,36 @@ export function setupChannelRoutes(app) {
|
|
|
1425
1452
|
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1426
1453
|
return;
|
|
1427
1454
|
}
|
|
1428
|
-
// Validate old tag exists in the active vocabulary.
|
|
1429
|
-
const
|
|
1430
|
-
if (!
|
|
1455
|
+
// Validate old tag exists in the active vocabulary (case-insensitive).
|
|
1456
|
+
const vocabularyLower = new Set(getActiveTagVocabulary().map((t) => t.toLowerCase()));
|
|
1457
|
+
if (!vocabularyLower.has(oldTag.toLowerCase())) {
|
|
1431
1458
|
res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
|
|
1432
1459
|
return;
|
|
1433
1460
|
}
|
|
1434
|
-
// Validate new tag doesn't
|
|
1435
|
-
|
|
1461
|
+
// Validate new tag doesn't collide with a different existing tag (case-insensitive). A case-only rename of the same tag (e.g., "sports" → "Sports") is
|
|
1462
|
+
// allowed since it's changing the display form of the same identity.
|
|
1463
|
+
if (vocabularyLower.has(newTag.toLowerCase()) && !tagsMatch(oldTag, newTag)) {
|
|
1436
1464
|
res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
|
|
1437
1465
|
return;
|
|
1438
1466
|
}
|
|
1439
1467
|
// Update the tag registry: replace old with new in the appropriate list.
|
|
1440
1468
|
const registry = getTagRegistry();
|
|
1441
|
-
const oldIsPredefined = PREDEFINED_TAGS.
|
|
1469
|
+
const oldIsPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, oldTag));
|
|
1442
1470
|
if (oldIsPredefined) {
|
|
1443
|
-
// Predefined tag: "delete" the old (add to deletedTags) and create the new as a user tag.
|
|
1444
|
-
|
|
1445
|
-
|
|
1471
|
+
// Predefined tag: "delete" the old (add to deletedTags using the canonical predefined form) and create the new as a user tag.
|
|
1472
|
+
const canonicalOld = PREDEFINED_TAGS.find((t) => tagsMatch(t, oldTag)) ?? oldTag;
|
|
1473
|
+
if (!registry.deletedTags.some((t) => tagsMatch(t, oldTag))) {
|
|
1474
|
+
registry.deletedTags.push(canonicalOld);
|
|
1446
1475
|
}
|
|
1447
1476
|
registry.tags.push(newTag);
|
|
1448
1477
|
}
|
|
1449
1478
|
else {
|
|
1450
1479
|
// User tag: replace in the user tags list.
|
|
1451
|
-
registry.tags = registry.tags.map((t) => (t
|
|
1480
|
+
registry.tags = registry.tags.map((t) => tagsMatch(t, oldTag) ? newTag : t);
|
|
1452
1481
|
}
|
|
1453
1482
|
setTagRegistry(registry);
|
|
1454
1483
|
// Cascade: substitute the old tag with the new tag across all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
1455
|
-
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.
|
|
1484
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.some((t) => tagsMatch(t, oldTag)) === true, (tags) => tags.map((t) => tagsMatch(t, oldTag) ? newTag : t));
|
|
1456
1485
|
if (error) {
|
|
1457
1486
|
res.status(400).json({ error, success: false });
|
|
1458
1487
|
return;
|
|
@@ -1469,8 +1498,7 @@ export function setupChannelRoutes(app) {
|
|
|
1469
1498
|
});
|
|
1470
1499
|
}
|
|
1471
1500
|
catch (error) {
|
|
1472
|
-
|
|
1473
|
-
res.status(500).json({ error: "Failed to rename tag: " + formatError(error), success: false });
|
|
1501
|
+
sendErrorResponse(res, error, "rename tag");
|
|
1474
1502
|
}
|
|
1475
1503
|
});
|
|
1476
1504
|
}
|