prismcast 1.7.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 +24 -13
- 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/login.js +11 -8
- package/dist/browser/login.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 +2 -0
- package/dist/channels/index.js +438 -252
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +13 -18
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +11 -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/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- 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/services.js +758 -0
- 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 +89 -27
- package/dist/config/userChannels.js +400 -184
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +13 -9
- package/dist/config/userConfig.js +78 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +154 -123
- 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/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +17 -27
- 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.d.ts +2 -2
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +29 -51
- 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/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +0 -8
- package/dist/routes/components.js +0 -19
- package/dist/routes/components.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 +748 -420
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +21 -6
- package/dist/routes/config/channels/table.js +456 -193
- 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} +47 -42
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +88 -49
- 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/icons.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- 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 +6 -4
- package/dist/routes/playlist.js +98 -45
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +94 -55
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +620 -478
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +884 -964
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +468 -188
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +252 -207
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +57 -8
- 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.d.ts +1 -1
- package/dist/routes/ui.js +3 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +8 -43
- 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/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +35 -29
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +3 -3
- 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.d.ts +15 -14
- package/dist/streaming/preroll.js +32 -12
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +4 -4
- package/dist/streaming/setup.js +28 -34
- 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 +18 -11
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- 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 +8 -16
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- 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 +21 -4
- package/dist/utils/format.js +45 -7
- 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/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/retry.js +2 -4
- package/dist/utils/retry.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 +0 -678
- 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
|
-
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getUserChannels, isPredefinedChannel, isUserChannel,
|
|
1
|
+
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString, stringifySorted } from "../../../utils/index.js";
|
|
2
|
+
import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../../../channels/index.js";
|
|
3
|
+
import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState, generateTagFilterContent, generateTagManagerBody } from "./table.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";
|
|
6
6
|
import { CONFIG } from "../../../config/index.js";
|
|
7
|
-
import {
|
|
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.
|
|
@@ -77,11 +112,10 @@ export function setupChannelRoutes(app) {
|
|
|
77
112
|
}
|
|
78
113
|
res.setHeader("Content-Type", "application/json");
|
|
79
114
|
res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
|
|
80
|
-
res.send(
|
|
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,140 +272,140 @@ 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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 };
|
|
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
|
|
285
|
+
// canonicalKey is provided, the channel is a service variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
|
|
286
|
+
// at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
|
|
287
|
+
function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
|
|
251
288
|
const channel = {
|
|
289
|
+
...(canonicalKey ? { canonicalKey } : { name: channelName }),
|
|
252
290
|
channelSelector: selector || undefined,
|
|
253
|
-
name: channelName,
|
|
254
291
|
url: channelUrl
|
|
255
292
|
};
|
|
256
|
-
if (entry.stationId) {
|
|
293
|
+
if (!canonicalKey && entry.stationId) {
|
|
257
294
|
channel.stationId = sanitizeString(entry.stationId);
|
|
258
295
|
}
|
|
259
296
|
return channel;
|
|
260
297
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
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);
|
|
271
326
|
continue;
|
|
272
327
|
}
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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;
|
|
278
356
|
}
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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;
|
|
285
364
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
switched++;
|
|
289
|
-
affectedKeys.add(canonicalKey);
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
// Handle provider removal. Reverts the channel away from this provider using a three-tier fallback: (1) clear the selection and let
|
|
293
|
-
// resolveProviderKey find the next enabled variant or canonical, (2) if the resolved provider is still this provider (no alternative exists),
|
|
294
|
-
// disable the predefined channel or delete the user channel.
|
|
295
|
-
if (action === "remove") {
|
|
296
|
-
const canonicalKey = entry.canonicalKey?.trim() ?? "";
|
|
297
|
-
if (!canonicalKey) {
|
|
298
|
-
errors.push("Remove entry for '" + name + "' missing canonical key.");
|
|
365
|
+
if (!url) {
|
|
366
|
+
errors.push("Channel '" + name + "' missing URL.");
|
|
299
367
|
continue;
|
|
300
368
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const resolvedKey = resolveProviderKey(canonicalKey);
|
|
306
|
-
const resolvedTag = getProviderTagForChannel(resolvedKey);
|
|
307
|
-
if (resolvedTag === providerSlug) {
|
|
308
|
-
// No alternative provider exists. Disable predefined channels or delete user channels.
|
|
309
|
-
if (isPredefinedChannel(canonicalKey)) {
|
|
310
|
-
disablePredefined.add(canonicalKey);
|
|
311
|
-
}
|
|
312
|
-
else {
|
|
313
|
-
Reflect.deleteProperty(existingChannels, canonicalKey);
|
|
314
|
-
channelsChanged = true;
|
|
315
|
-
}
|
|
369
|
+
const urlError = validateChannelUrl(url);
|
|
370
|
+
if (urlError) {
|
|
371
|
+
errors.push("Channel '" + name + "': " + urlError);
|
|
372
|
+
continue;
|
|
316
373
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const baseKey = generateChannelKey(name);
|
|
339
|
-
if (!baseKey) {
|
|
340
|
-
errors.push("Could not generate key for channel '" + name + "'.");
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
const canonicalExists = allKeys.has(baseKey);
|
|
344
|
-
const key = (canonicalExists && providerSlug) ? baseKey + "-" + providerSlug : baseKey;
|
|
345
|
-
// Skip channels that already exist in the lineup.
|
|
346
|
-
if (allKeys.has(key)) {
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
const newChannel = buildUserChannel(entry, name, url, channelSelector);
|
|
350
|
-
// When this is a provider variant (key differs from baseKey), store the canonical relationship explicitly so that buildProviderGroups can
|
|
351
|
-
// associate the user channel with its canonical via the canonicalKey field.
|
|
352
|
-
if (key !== baseKey) {
|
|
353
|
-
newChannel.canonicalKey = baseKey;
|
|
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);
|
|
354
393
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (channelsChanged) {
|
|
364
|
-
await saveUserChannels(existingChannels);
|
|
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);
|
|
365
402
|
}
|
|
366
|
-
|
|
367
|
-
|
|
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();
|
|
368
407
|
}
|
|
369
|
-
// Disable predefined channels that had no alternative
|
|
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,41 +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
|
-
|
|
580
|
-
|
|
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
|
+
}
|
|
581
614
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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);
|
|
590
627
|
}
|
|
591
|
-
const existing = result.channels[entry.key] ?? {};
|
|
592
|
-
existing.channelNumber = num;
|
|
593
|
-
result.channels[entry.key] = existing;
|
|
594
|
-
affectedKeys.push(entry.key);
|
|
595
628
|
}
|
|
596
|
-
}
|
|
597
|
-
await saveUserChannels(result.channels);
|
|
629
|
+
});
|
|
598
630
|
const action = clearMode ? "Cleared" : "Numbered";
|
|
599
631
|
LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
|
|
600
632
|
const profiles = getProfiles();
|
|
@@ -604,8 +636,7 @@ export function setupChannelRoutes(app) {
|
|
|
604
636
|
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
605
637
|
}
|
|
606
638
|
catch (error) {
|
|
607
|
-
|
|
608
|
-
res.status(500).json({ error: "Failed to auto-number channels: " + formatError(error), success: false });
|
|
639
|
+
sendErrorResponse(res, error, "auto-number channels");
|
|
609
640
|
}
|
|
610
641
|
});
|
|
611
642
|
// POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
|
|
@@ -614,32 +645,29 @@ export function setupChannelRoutes(app) {
|
|
|
614
645
|
try {
|
|
615
646
|
const body = req.body;
|
|
616
647
|
const enable = body.enable === true;
|
|
617
|
-
// Get visible channels (enabled + available by
|
|
618
|
-
const listing = getChannelListing().filter((entry) => entry.enabled && entry.
|
|
619
|
-
// Load user channels for modification.
|
|
620
|
-
const result = await loadUserChannels();
|
|
621
|
-
if (result.parseError) {
|
|
622
|
-
res.status(400).json({ error: "Cannot update HDHR settings: channels file contains invalid JSON.", success: false });
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
648
|
+
// Get visible channels (enabled + available by service filter).
|
|
649
|
+
const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByService);
|
|
625
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.
|
|
626
652
|
for (const entry of listing) {
|
|
627
653
|
const current = entry.channel.hdhrEnabled !== false;
|
|
628
654
|
// Skip channels that already match the target state.
|
|
629
655
|
if (current === enable) {
|
|
630
656
|
continue;
|
|
631
657
|
}
|
|
632
|
-
const existing = result.channels[entry.key] ?? {};
|
|
633
|
-
const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
|
|
634
|
-
existing.hdhrEnabled = enable ? clearValue : false;
|
|
635
|
-
result.channels[entry.key] = existing;
|
|
636
658
|
affectedKeys.push(entry.key);
|
|
637
659
|
}
|
|
638
660
|
if (affectedKeys.length === 0) {
|
|
639
661
|
res.json({ affected: 0, message: "No changes needed.", success: true });
|
|
640
662
|
return;
|
|
641
663
|
}
|
|
642
|
-
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
|
+
});
|
|
643
671
|
const action = enable ? "included in" : "excluded from";
|
|
644
672
|
LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
|
|
645
673
|
const profiles = getProfiles();
|
|
@@ -647,66 +675,110 @@ export function setupChannelRoutes(app) {
|
|
|
647
675
|
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
648
676
|
}
|
|
649
677
|
catch (error) {
|
|
650
|
-
|
|
651
|
-
res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
|
|
678
|
+
sendErrorResponse(res, error, "toggle HDHR settings");
|
|
652
679
|
}
|
|
653
680
|
});
|
|
654
|
-
// POST /config/
|
|
655
|
-
|
|
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.
|
|
682
|
+
// transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
|
|
683
|
+
// modal body.
|
|
684
|
+
app.post("/config/channels/bulk-tags", async (req, res) => {
|
|
656
685
|
try {
|
|
657
686
|
const body = req.body;
|
|
658
|
-
const
|
|
687
|
+
const action = body.action;
|
|
688
|
+
const tag = typeof body.tag === "string" ? body.tag.trim() : "";
|
|
689
|
+
if ((action !== "add") && (action !== "remove")) {
|
|
690
|
+
res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (!tag) {
|
|
694
|
+
res.status(400).json({ error: "Tag is required.", success: false });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
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())) {
|
|
700
|
+
res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
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));
|
|
704
|
+
if (error) {
|
|
705
|
+
res.status(400).json({ error, success: false });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (affectedKeys.length === 0) {
|
|
709
|
+
res.json({ affected: 0, message: "No changes needed.", success: true });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const verb = (action === "add") ? "added to" : "removed from";
|
|
713
|
+
LOG.info("Bulk tag %s: %s on %d channels.", action, tag, affectedKeys.length);
|
|
714
|
+
const profiles = getProfiles();
|
|
715
|
+
const message = "Tag '" + tag + "' " + verb + " " + String(affectedKeys.length) + " channel(s).";
|
|
716
|
+
res.json({
|
|
717
|
+
affected: affectedKeys.length,
|
|
718
|
+
filterContent: generateTagFilterContent(),
|
|
719
|
+
message,
|
|
720
|
+
modalBody: generateTagManagerBody(),
|
|
721
|
+
patch: buildChannelTablePatch(affectedKeys, profiles),
|
|
722
|
+
success: true
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
sendErrorResponse(res, error, "update tags");
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
// POST /config/service-filter - Update the service filter (enabled service tags).
|
|
730
|
+
app.post("/config/service-filter", async (req, res) => {
|
|
731
|
+
try {
|
|
732
|
+
const body = req.body;
|
|
733
|
+
const tags = body.enabledServices;
|
|
659
734
|
// Validate tags is an array.
|
|
660
735
|
if (!Array.isArray(tags)) {
|
|
661
|
-
res.status(400).json({ error: "
|
|
736
|
+
res.status(400).json({ error: "enabledServices must be an array.", success: false });
|
|
662
737
|
return;
|
|
663
738
|
}
|
|
664
|
-
// 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
|
|
665
740
|
// removed via the UI without blocking the request.
|
|
666
|
-
const knownTags = new Set(
|
|
667
|
-
const currentTags = new Set(
|
|
741
|
+
const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
|
|
742
|
+
const currentTags = new Set(getEnabledServices());
|
|
668
743
|
for (const tag of tags) {
|
|
669
744
|
if (!knownTags.has(tag) && !currentTags.has(tag)) {
|
|
670
|
-
res.status(400).json({ error: "Unknown
|
|
745
|
+
res.status(400).json({ error: "Unknown service tag: " + tag, success: false });
|
|
671
746
|
return;
|
|
672
747
|
}
|
|
673
748
|
}
|
|
674
749
|
// Update module-level state.
|
|
675
|
-
|
|
750
|
+
setEnabledServices(tags);
|
|
676
751
|
// Update runtime CONFIG.
|
|
677
|
-
CONFIG.channels.
|
|
752
|
+
CONFIG.channels.enabledServices = [...tags];
|
|
678
753
|
// Save to config file.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
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");
|
|
685
759
|
// Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
|
|
686
760
|
const { counts, scopeCounts } = buildChannelTableState();
|
|
687
|
-
res.json({
|
|
761
|
+
res.json({ enabledServices: tags, patch: { counts, rows: [], scopeCounts }, success: true });
|
|
688
762
|
}
|
|
689
763
|
catch (error) {
|
|
690
|
-
|
|
691
|
-
res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
|
|
764
|
+
sendErrorResponse(res, error, "update service filter");
|
|
692
765
|
}
|
|
693
766
|
});
|
|
694
|
-
// 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.
|
|
695
768
|
app.post("/config/channels/setup-completed", async (_req, res) => {
|
|
696
769
|
try {
|
|
697
770
|
CONFIG.channels.setupCompleted = true;
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
771
|
+
await mutateConfig((config) => {
|
|
772
|
+
config.channels ??= {};
|
|
773
|
+
config.channels.setupCompleted = true;
|
|
774
|
+
});
|
|
702
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
|
|
703
|
-
// 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.
|
|
704
777
|
const { counts, scopeCounts } = buildChannelTableState();
|
|
705
778
|
res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
|
|
706
779
|
}
|
|
707
780
|
catch (error) {
|
|
708
|
-
|
|
709
|
-
res.status(500).json({ error: "Failed to save setup state: " + formatError(error), success: false });
|
|
781
|
+
sendErrorResponse(res, error, "save setup state");
|
|
710
782
|
}
|
|
711
783
|
});
|
|
712
784
|
// POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
|
|
@@ -744,28 +816,26 @@ export function setupChannelRoutes(app) {
|
|
|
744
816
|
CONFIG.channels.channelSortDirection = body.sortDirection;
|
|
745
817
|
}
|
|
746
818
|
// Persist to config file.
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
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
|
+
});
|
|
754
825
|
res.json({ success: true });
|
|
755
826
|
}
|
|
756
827
|
catch (error) {
|
|
757
|
-
|
|
758
|
-
res.status(500).json({ error: "Failed to update display preferences: " + formatError(error), success: false });
|
|
828
|
+
sendErrorResponse(res, error, "update display preferences");
|
|
759
829
|
}
|
|
760
830
|
});
|
|
761
|
-
// POST /config/
|
|
762
|
-
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) => {
|
|
763
833
|
try {
|
|
764
834
|
const body = req.body;
|
|
765
|
-
const
|
|
766
|
-
// Validate
|
|
767
|
-
if (!
|
|
768
|
-
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 });
|
|
769
839
|
return;
|
|
770
840
|
}
|
|
771
841
|
let affected = 0;
|
|
@@ -774,17 +844,17 @@ export function setupChannelRoutes(app) {
|
|
|
774
844
|
// Iterate all channels and set those with a matching variant.
|
|
775
845
|
const listing = getChannelListing();
|
|
776
846
|
for (const entry of listing) {
|
|
777
|
-
const group =
|
|
847
|
+
const group = getServiceGroup(entry.key);
|
|
778
848
|
if (!group || (group.variants.length <= 1)) {
|
|
779
849
|
continue;
|
|
780
850
|
}
|
|
781
|
-
// Find a variant matching the requested
|
|
782
|
-
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));
|
|
783
853
|
if (matchingVariant) {
|
|
784
854
|
// Snapshot the current selection before overwriting so the client can offer undo.
|
|
785
|
-
const currentVariant =
|
|
855
|
+
const currentVariant = getServiceSelection(entry.key);
|
|
786
856
|
previousSelections[entry.key] = currentVariant ?? null;
|
|
787
|
-
|
|
857
|
+
setServiceSelection(entry.key, matchingVariant.key);
|
|
788
858
|
affected++;
|
|
789
859
|
// Collect the resolved profile name for client-side UI update.
|
|
790
860
|
const resolvedChannel = getResolvedChannel(matchingVariant.key);
|
|
@@ -792,17 +862,16 @@ export function setupChannelRoutes(app) {
|
|
|
792
862
|
}
|
|
793
863
|
}
|
|
794
864
|
// Save to disk.
|
|
795
|
-
await
|
|
796
|
-
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);
|
|
797
867
|
res.json({ affected, previousSelections, selections, success: true, total: listing.length });
|
|
798
868
|
}
|
|
799
869
|
catch (error) {
|
|
800
|
-
|
|
801
|
-
res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
|
|
870
|
+
sendErrorResponse(res, error, "bulk assign service");
|
|
802
871
|
}
|
|
803
872
|
});
|
|
804
|
-
// POST /config/
|
|
805
|
-
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) => {
|
|
806
875
|
try {
|
|
807
876
|
const body = req.body;
|
|
808
877
|
const previousSelections = body.selections;
|
|
@@ -813,21 +882,21 @@ export function setupChannelRoutes(app) {
|
|
|
813
882
|
let restored = 0;
|
|
814
883
|
const selections = {};
|
|
815
884
|
for (const [key, variantKey] of Object.entries(previousSelections)) {
|
|
816
|
-
const group =
|
|
885
|
+
const group = getServiceGroup(key);
|
|
817
886
|
if (!group) {
|
|
818
887
|
continue;
|
|
819
888
|
}
|
|
820
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.
|
|
821
890
|
if (variantKey === null) {
|
|
822
|
-
|
|
891
|
+
setServiceSelection(key, key);
|
|
823
892
|
}
|
|
824
893
|
else {
|
|
825
|
-
// Validate the variant belongs to this channel's
|
|
894
|
+
// Validate the variant belongs to this channel's service group before restoring.
|
|
826
895
|
const isValid = group.variants.some((v) => (v.key === variantKey));
|
|
827
896
|
if (!isValid) {
|
|
828
897
|
continue;
|
|
829
898
|
}
|
|
830
|
-
|
|
899
|
+
setServiceSelection(key, variantKey);
|
|
831
900
|
}
|
|
832
901
|
restored++;
|
|
833
902
|
// Build the same selection response format as bulk assign for client-side UI updates.
|
|
@@ -836,13 +905,12 @@ export function setupChannelRoutes(app) {
|
|
|
836
905
|
selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
|
|
837
906
|
}
|
|
838
907
|
// Save to disk.
|
|
839
|
-
await
|
|
908
|
+
await saveServiceSelections();
|
|
840
909
|
LOG.info("Bulk restore: %d channel(s) reverted.", restored);
|
|
841
910
|
res.json({ restored, selections, success: true });
|
|
842
911
|
}
|
|
843
912
|
catch (error) {
|
|
844
|
-
|
|
845
|
-
res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
|
|
913
|
+
sendErrorResponse(res, error, "bulk restore services");
|
|
846
914
|
}
|
|
847
915
|
});
|
|
848
916
|
// POST /config/channels - Handle channel add, edit, delete, and revert operations. Returns JSON response.
|
|
@@ -866,16 +934,12 @@ export function setupChannelRoutes(app) {
|
|
|
866
934
|
res.status(400).json({ message: "Cannot revert '" + key + "': no override exists.", success: false });
|
|
867
935
|
return;
|
|
868
936
|
}
|
|
869
|
-
// Remove the override.
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
}
|
|
875
|
-
// Check if the override contains M3U-relevant fields before removing it.
|
|
876
|
-
const revertHint = playlistHintForStored(result.channels[key]);
|
|
877
|
-
Reflect.deleteProperty(result.channels, key);
|
|
878
|
-
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
|
+
});
|
|
879
943
|
LOG.info("Channel '%s' reverted to predefined defaults.", key);
|
|
880
944
|
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
|
|
881
945
|
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
@@ -890,7 +954,7 @@ export function setupChannelRoutes(app) {
|
|
|
890
954
|
}
|
|
891
955
|
const field = body.field;
|
|
892
956
|
const value = sanitizeString(body.value ?? "");
|
|
893
|
-
if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId")) {
|
|
957
|
+
if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId") && (field !== "tags")) {
|
|
894
958
|
res.status(400).json({ message: "Invalid inline-edit field.", success: false });
|
|
895
959
|
return;
|
|
896
960
|
}
|
|
@@ -902,29 +966,31 @@ export function setupChannelRoutes(app) {
|
|
|
902
966
|
return;
|
|
903
967
|
}
|
|
904
968
|
}
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
const fieldLabels = {
|
|
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
|
+
});
|
|
991
|
+
const fieldLabels = {
|
|
992
|
+
channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
|
|
993
|
+
};
|
|
928
994
|
const fieldLabel = fieldLabels[field] ?? field;
|
|
929
995
|
const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
|
|
930
996
|
LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
|
|
@@ -943,13 +1009,9 @@ export function setupChannelRoutes(app) {
|
|
|
943
1009
|
return;
|
|
944
1010
|
}
|
|
945
1011
|
// Delete the channel.
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
Reflect.deleteProperty(result.channels, key);
|
|
952
|
-
await saveUserChannels(result.channels);
|
|
1012
|
+
await mutateChannels((channels) => {
|
|
1013
|
+
Reflect.deleteProperty(channels, key);
|
|
1014
|
+
});
|
|
953
1015
|
LOG.info("User channel '%s' deleted.", key);
|
|
954
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
|
|
955
1017
|
// the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
|
|
@@ -973,10 +1035,17 @@ export function setupChannelRoutes(app) {
|
|
|
973
1035
|
const name = sanitizeString(body.name ?? "");
|
|
974
1036
|
const url = sanitizeString(body.url ?? "");
|
|
975
1037
|
const profile = sanitizeString(body.profile ?? "");
|
|
1038
|
+
const guideTitle = sanitizeString(body.guideTitle ?? "");
|
|
1039
|
+
const logoUrl = sanitizeString(body.logoUrl ?? "");
|
|
976
1040
|
const stationId = sanitizeString(body.stationId ?? "");
|
|
977
1041
|
const channelSelector = sanitizeString(body.channelSelector ?? "");
|
|
978
1042
|
const channelNumberStr = sanitizeString(body.channelNumber ?? "");
|
|
979
1043
|
const hdhrEnabled = body.hdhrEnabled !== "false";
|
|
1044
|
+
// Parse tags from comma-separated input. Trim, filter empty, deduplicate, and sort case-insensitively.
|
|
1045
|
+
const tagsRaw = sanitizeString(body.tags ?? "");
|
|
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
|
+
[];
|
|
980
1049
|
// Validate channel number if provided.
|
|
981
1050
|
const channelNumberError = validateChannelNumber(channelNumberStr, key);
|
|
982
1051
|
if (channelNumberError) {
|
|
@@ -1009,18 +1078,6 @@ export function setupChannelRoutes(app) {
|
|
|
1009
1078
|
res.status(400).json({ errors: formErrors, success: false });
|
|
1010
1079
|
return;
|
|
1011
1080
|
}
|
|
1012
|
-
// Load existing user channels.
|
|
1013
|
-
const result = await loadUserChannels();
|
|
1014
|
-
if (result.parseError) {
|
|
1015
|
-
// If channels file is corrupt, start fresh on add (which will create a valid file).
|
|
1016
|
-
if (action === "add") {
|
|
1017
|
-
result.channels = {};
|
|
1018
|
-
}
|
|
1019
|
-
else {
|
|
1020
|
-
res.status(400).json({ message: "Cannot edit channel: channels file contains invalid JSON.", success: false });
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
1081
|
let playlistChanged = false;
|
|
1025
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.
|
|
1026
1083
|
const predefinedBase = getPredefinedChannel(key);
|
|
@@ -1030,7 +1087,9 @@ export function setupChannelRoutes(app) {
|
|
|
1030
1087
|
const formValues = {
|
|
1031
1088
|
channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
|
|
1032
1089
|
channelSelector,
|
|
1090
|
+
guideTitle,
|
|
1033
1091
|
hdhrEnabled,
|
|
1092
|
+
logoUrl,
|
|
1034
1093
|
name,
|
|
1035
1094
|
profile,
|
|
1036
1095
|
stationId,
|
|
@@ -1045,17 +1104,22 @@ export function setupChannelRoutes(app) {
|
|
|
1045
1104
|
}
|
|
1046
1105
|
return (field === "channelNumber") ? val : val ?? "";
|
|
1047
1106
|
};
|
|
1048
|
-
// 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
|
|
1049
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
|
|
1050
|
-
// values match the displayChannel exactly, the user saved without modification
|
|
1051
|
-
|
|
1109
|
+
// values match the displayChannel exactly, the user saved without modification - no override should be created, and any existing override is preserved.
|
|
1110
|
+
// Tags are compared separately (array comparison) after the scalar fields.
|
|
1111
|
+
const resolvedKey = resolveServiceKey(key);
|
|
1052
1112
|
const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
|
|
1053
|
-
|
|
1113
|
+
const displayTags = getChannelEffectiveTags(displayChannel);
|
|
1114
|
+
const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
|
|
1115
|
+
const tagsUnchanged = JSON.stringify(tags) === JSON.stringify(displayTags);
|
|
1116
|
+
if (scalarUnchanged && tagsUnchanged) {
|
|
1054
1117
|
res.json({ key, message: "No changes to save.", success: true });
|
|
1055
1118
|
return;
|
|
1056
1119
|
}
|
|
1057
1120
|
// Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
|
|
1058
|
-
// revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
|
|
1121
|
+
// revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear). Tags are handled
|
|
1122
|
+
// separately from the scalar loop since they require array comparison.
|
|
1059
1123
|
const delta = {};
|
|
1060
1124
|
let hasChanges = false;
|
|
1061
1125
|
for (const field of Object.keys(formValues)) {
|
|
@@ -1065,15 +1129,26 @@ export function setupChannelRoutes(app) {
|
|
|
1065
1129
|
hasChanges = true;
|
|
1066
1130
|
}
|
|
1067
1131
|
}
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1132
|
+
// Tags delta: compare the submitted tags against the predefined base's vocabulary-filtered tags. Using effective tags (not raw) ensures that editing an
|
|
1133
|
+
// unrelated field while a predefined tag is deleted from the vocabulary doesn't spuriously bake the deletion into the channel's stored delta.
|
|
1134
|
+
const predefinedEffectiveTags = getChannelEffectiveTags(predefinedBase);
|
|
1135
|
+
if (JSON.stringify(tags) !== JSON.stringify(predefinedEffectiveTags)) {
|
|
1136
|
+
delta.tags = (tags.length > 0) ? tags : null;
|
|
1137
|
+
hasChanges = true;
|
|
1138
|
+
}
|
|
1139
|
+
// Helper to check if form values (scalars + tags) match a given channel's properties. Tags use vocabulary-filtered comparison so variant matching works
|
|
1140
|
+
// correctly when predefined tags are deleted from the vocabulary.
|
|
1141
|
+
const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
|
|
1142
|
+
(JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
|
|
1070
1143
|
if (!hasChanges) {
|
|
1071
|
-
// 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
|
|
1072
1145
|
// implicit revert by removing the override and returning the predefined row HTML.
|
|
1073
1146
|
if (isUserChannel(key)) {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1147
|
+
let implicitRevertHint = "";
|
|
1148
|
+
await mutateChannels((channels) => {
|
|
1149
|
+
implicitRevertHint = playlistHintForStored(channels[key]);
|
|
1150
|
+
Reflect.deleteProperty(channels, key);
|
|
1151
|
+
});
|
|
1077
1152
|
LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
|
|
1078
1153
|
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
|
|
1079
1154
|
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
@@ -1082,15 +1157,15 @@ export function setupChannelRoutes(app) {
|
|
|
1082
1157
|
res.json({ key, message: "No changes to save.", success: true });
|
|
1083
1158
|
return;
|
|
1084
1159
|
}
|
|
1085
|
-
// 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
|
|
1086
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
|
|
1087
|
-
// 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,
|
|
1088
1163
|
// not a new customization. We resolve each variant against pure PREDEFINED data (not the user-overridden channelsRef) to avoid contamination from the
|
|
1089
1164
|
// current override.
|
|
1090
|
-
const
|
|
1165
|
+
const serviceGroup = getServiceGroup(key);
|
|
1091
1166
|
let matchedVariantKey;
|
|
1092
|
-
if (
|
|
1093
|
-
for (const variant of
|
|
1167
|
+
if (serviceGroup && isUserChannel(key)) {
|
|
1168
|
+
for (const variant of serviceGroup.variants) {
|
|
1094
1169
|
// Skip the canonical entry (already handled by the !hasChanges check above) and :predefined entries (synthetic entries for override UI).
|
|
1095
1170
|
if ((variant.key === key) || variant.key.includes(":")) {
|
|
1096
1171
|
continue;
|
|
@@ -1104,29 +1179,38 @@ export function setupChannelRoutes(app) {
|
|
|
1104
1179
|
}
|
|
1105
1180
|
}
|
|
1106
1181
|
if (matchedVariantKey) {
|
|
1107
|
-
// Form values match a known variant
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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);
|
|
1112
1189
|
LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
|
|
1113
1190
|
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
|
|
1114
1191
|
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
1115
1192
|
return;
|
|
1116
1193
|
}
|
|
1117
|
-
// 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
|
|
1118
1195
|
// "Custom" after saving, which is the expected behavior when a user customizes a predefined channel.
|
|
1119
|
-
|
|
1120
|
-
|
|
1196
|
+
setServiceSelection(key, key);
|
|
1197
|
+
await mutateChannels((channels) => {
|
|
1198
|
+
channels[key] = delta;
|
|
1199
|
+
});
|
|
1121
1200
|
playlistChanged = M3U_FIELDS.some((f) => f in delta);
|
|
1122
1201
|
}
|
|
1123
1202
|
else {
|
|
1124
|
-
// User-defined channel or add: build a full channel object.
|
|
1125
|
-
const oldChannel = ((action === "edit") && (key in result.channels)) ? result.channels[key] : undefined;
|
|
1203
|
+
// User-defined channel or add: build a full channel object.
|
|
1126
1204
|
const channel = {
|
|
1127
1205
|
name,
|
|
1128
1206
|
url
|
|
1129
1207
|
};
|
|
1208
|
+
if (guideTitle) {
|
|
1209
|
+
channel.guideTitle = guideTitle;
|
|
1210
|
+
}
|
|
1211
|
+
if (logoUrl) {
|
|
1212
|
+
channel.logoUrl = logoUrl;
|
|
1213
|
+
}
|
|
1130
1214
|
if (profile) {
|
|
1131
1215
|
channel.profile = profile;
|
|
1132
1216
|
}
|
|
@@ -1139,17 +1223,27 @@ export function setupChannelRoutes(app) {
|
|
|
1139
1223
|
if (channelNumberStr) {
|
|
1140
1224
|
channel.channelNumber = parseInt(channelNumberStr, 10);
|
|
1141
1225
|
}
|
|
1226
|
+
if (tags.length > 0) {
|
|
1227
|
+
channel.tags = tags;
|
|
1228
|
+
}
|
|
1142
1229
|
// Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
|
|
1143
1230
|
if (!hdhrEnabled) {
|
|
1144
1231
|
channel.hdhrEnabled = false;
|
|
1145
1232
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
+
});
|
|
1151
1246
|
}
|
|
1152
|
-
await saveUserChannels(result.channels);
|
|
1153
1247
|
// Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
|
|
1154
1248
|
if (stationId) {
|
|
1155
1249
|
updateChannelLogo(name || key, stationId);
|
|
@@ -1159,18 +1253,252 @@ export function setupChannelRoutes(app) {
|
|
|
1159
1253
|
// Generate HTML for the channel row so the client can update the DOM without a full page reload.
|
|
1160
1254
|
// Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
|
|
1161
1255
|
const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
|
|
1162
|
-
//
|
|
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().
|
|
1163
1259
|
res.json({
|
|
1164
1260
|
isNew: action === "add",
|
|
1165
1261
|
key,
|
|
1166
1262
|
message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
|
|
1167
1263
|
patch: buildChannelTablePatch([key], profiles),
|
|
1264
|
+
serviceWarning,
|
|
1265
|
+
success: true
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
catch (error) {
|
|
1269
|
+
sendErrorResponse(res, error, "save channel");
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
// Tag Management Endpoints.
|
|
1273
|
+
// GET /config/tags - Returns the tag vocabulary and registry state. The active vocabulary is the computed merge of predefined tags (minus deleted) plus user
|
|
1274
|
+
// tags. The registry contains the raw user state for the tag management UI.
|
|
1275
|
+
app.get("/config/tags", (_req, res) => {
|
|
1276
|
+
res.json({
|
|
1277
|
+
active: getActiveTagVocabulary(),
|
|
1278
|
+
predefined: [...PREDEFINED_TAGS],
|
|
1279
|
+
registry: getTagRegistry(),
|
|
1280
|
+
success: true
|
|
1281
|
+
});
|
|
1282
|
+
});
|
|
1283
|
+
// POST /config/tags - Create a new user tag. Validates the tag name (lowercase alphanumeric + hyphens, max 30 chars) and checks for duplicates against the
|
|
1284
|
+
// active vocabulary and deleted predefined tags (which should be restored, not re-created).
|
|
1285
|
+
app.post("/config/tags", async (req, res) => {
|
|
1286
|
+
try {
|
|
1287
|
+
const body = req.body;
|
|
1288
|
+
const tag = typeof body.tag === "string" ? body.tag.trim() : "";
|
|
1289
|
+
if (tag.length === 0) {
|
|
1290
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (tag.length > 30) {
|
|
1294
|
+
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
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 });
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
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())) {
|
|
1304
|
+
res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
// Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
|
|
1308
|
+
const registry = getTagRegistry();
|
|
1309
|
+
if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
|
|
1310
|
+
res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
// Add the tag to the user registry and persist. No channels are affected (new tag has no assignments yet).
|
|
1314
|
+
registry.tags.push(tag);
|
|
1315
|
+
setTagRegistry(registry);
|
|
1316
|
+
await saveTagRegistry();
|
|
1317
|
+
LOG.info("Created tag: %s.", tag);
|
|
1318
|
+
res.json({
|
|
1319
|
+
active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1320
|
+
registry: getTagRegistry(), success: true
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
sendErrorResponse(res, error, "create tag");
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
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
|
|
1328
|
+
// user-created tags, the tag is removed from the user registry. In both cases, the tag is stripped from every channel that has it — predefined channels via delta
|
|
1329
|
+
// override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
|
|
1330
|
+
app.delete("/config/tags/:tag", async (req, res) => {
|
|
1331
|
+
try {
|
|
1332
|
+
const tag = req.params.tag?.trim() ?? "";
|
|
1333
|
+
if (tag.length === 0) {
|
|
1334
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const registry = getTagRegistry();
|
|
1338
|
+
const isPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, tag));
|
|
1339
|
+
const isUserTag = registry.tags.some((t) => tagsMatch(t, tag));
|
|
1340
|
+
if (!isPredefined && !isUserTag) {
|
|
1341
|
+
res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (isPredefined) {
|
|
1345
|
+
// Find the canonical predefined form for storage.
|
|
1346
|
+
const canonicalTag = PREDEFINED_TAGS.find((t) => tagsMatch(t, tag)) ?? tag;
|
|
1347
|
+
// Already deleted — no-op, return current state without unnecessary I/O.
|
|
1348
|
+
if (registry.deletedTags.some((t) => tagsMatch(t, tag))) {
|
|
1349
|
+
res.json({
|
|
1350
|
+
active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1351
|
+
registry: getTagRegistry(), success: true
|
|
1352
|
+
});
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
registry.deletedTags.push(canonicalTag);
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
// Remove from user tags.
|
|
1359
|
+
registry.tags = registry.tags.filter((t) => !tagsMatch(t, tag));
|
|
1360
|
+
}
|
|
1361
|
+
setTagRegistry(registry);
|
|
1362
|
+
// Cascade: strip the deleted tag from all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
1363
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.some((t) => tagsMatch(t, tag)) === true, (tags) => tags.filter((t) => !tagsMatch(t, tag)));
|
|
1364
|
+
if (error) {
|
|
1365
|
+
res.status(400).json({ error, success: false });
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
LOG.info("Deleted tag '%s' from vocabulary and %d channel assignments.", tag, affectedKeys.length);
|
|
1369
|
+
const profiles = getProfiles();
|
|
1370
|
+
res.json({
|
|
1371
|
+
active: getActiveTagVocabulary(),
|
|
1372
|
+
filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1373
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1374
|
+
registry: getTagRegistry(),
|
|
1375
|
+
success: true
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
catch (error) {
|
|
1379
|
+
sendErrorResponse(res, error, "delete tag");
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
// POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
|
|
1383
|
+
// cascade-restores the tag on predefined channels whose definition includes it. This reverses the cascade delete — predefined channels go back to their default
|
|
1384
|
+
// tag assignments. User channels are not affected (their tag assignments were permanently removed during cascade delete with no source of truth to restore from).
|
|
1385
|
+
app.post("/config/tags/restore", async (req, res) => {
|
|
1386
|
+
try {
|
|
1387
|
+
const body = req.body;
|
|
1388
|
+
const tag = typeof body.tag === "string" ? body.tag.trim() : "";
|
|
1389
|
+
if (tag.length === 0) {
|
|
1390
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
const registry = getTagRegistry();
|
|
1394
|
+
if (!registry.deletedTags.some((t) => tagsMatch(t, tag))) {
|
|
1395
|
+
res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
registry.deletedTags = registry.deletedTags.filter((t) => !tagsMatch(t, tag));
|
|
1399
|
+
setTagRegistry(registry);
|
|
1400
|
+
// Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
|
|
1401
|
+
// delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
|
|
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;
|
|
1405
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => {
|
|
1406
|
+
const predefined = getPredefinedChannel(entry.key);
|
|
1407
|
+
return (predefined?.tags?.some((t) => tagsMatch(t, tag)) === true) &&
|
|
1408
|
+
(entry.channel.tags?.some((t) => tagsMatch(t, tag)) !== true);
|
|
1409
|
+
}, (tags) => [...tags, canonicalTag]);
|
|
1410
|
+
if (error) {
|
|
1411
|
+
res.status(400).json({ error, success: false });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
LOG.info("Restored predefined tag '%s' on %d channels.", tag, affectedKeys.length);
|
|
1415
|
+
const profiles = getProfiles();
|
|
1416
|
+
res.json({
|
|
1417
|
+
active: getActiveTagVocabulary(),
|
|
1418
|
+
filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1419
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1420
|
+
registry: getTagRegistry(),
|
|
1421
|
+
success: true
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
catch (error) {
|
|
1425
|
+
sendErrorResponse(res, error, "restore tag");
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
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
|
|
1429
|
+
// deletedTags and the new name is added to user tags. For user tags, the old name is replaced with the new name. All channels with the old tag get the
|
|
1430
|
+
// new tag substituted in their stored data (predefined channels via delta, user channels via direct modification).
|
|
1431
|
+
app.post("/config/tags/rename", async (req, res) => {
|
|
1432
|
+
try {
|
|
1433
|
+
const body = req.body;
|
|
1434
|
+
const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim() : "";
|
|
1435
|
+
const newTag = typeof body.newTag === "string" ? body.newTag.trim() : "";
|
|
1436
|
+
if (!oldTag || !newTag) {
|
|
1437
|
+
res.status(400).json({ error: "Both old and new tag names are required.", success: false });
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
// Exact comparison — case-only renames (e.g., "sports" → "Sports") are valid since tags are freeform with preserved casing.
|
|
1441
|
+
if (oldTag === newTag) {
|
|
1442
|
+
res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
if (!(/^[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?$/).test(newTag)) {
|
|
1446
|
+
res.status(400).json({
|
|
1447
|
+
error: "Tag name must start and end with a letter or number, and contain only letters, numbers, spaces, and hyphens.", success: false
|
|
1448
|
+
});
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (newTag.length > 30) {
|
|
1452
|
+
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
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())) {
|
|
1458
|
+
res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
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)) {
|
|
1464
|
+
res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
// Update the tag registry: replace old with new in the appropriate list.
|
|
1468
|
+
const registry = getTagRegistry();
|
|
1469
|
+
const oldIsPredefined = PREDEFINED_TAGS.some((t) => tagsMatch(t, oldTag));
|
|
1470
|
+
if (oldIsPredefined) {
|
|
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);
|
|
1475
|
+
}
|
|
1476
|
+
registry.tags.push(newTag);
|
|
1477
|
+
}
|
|
1478
|
+
else {
|
|
1479
|
+
// User tag: replace in the user tags list.
|
|
1480
|
+
registry.tags = registry.tags.map((t) => tagsMatch(t, oldTag) ? newTag : t);
|
|
1481
|
+
}
|
|
1482
|
+
setTagRegistry(registry);
|
|
1483
|
+
// Cascade: substitute the old tag with the new tag across all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
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));
|
|
1485
|
+
if (error) {
|
|
1486
|
+
res.status(400).json({ error, success: false });
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
LOG.info("Renamed tag '%s' to '%s' across %d channels.", oldTag, newTag, affectedKeys.length);
|
|
1490
|
+
const profiles = getProfiles();
|
|
1491
|
+
res.json({
|
|
1492
|
+
active: getActiveTagVocabulary(),
|
|
1493
|
+
filterContent: generateTagFilterContent(),
|
|
1494
|
+
modalBody: generateTagManagerBody(),
|
|
1495
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1496
|
+
registry: getTagRegistry(),
|
|
1168
1497
|
success: true
|
|
1169
1498
|
});
|
|
1170
1499
|
}
|
|
1171
1500
|
catch (error) {
|
|
1172
|
-
|
|
1173
|
-
res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
|
|
1501
|
+
sendErrorResponse(res, error, "rename tag");
|
|
1174
1502
|
}
|
|
1175
1503
|
});
|
|
1176
1504
|
}
|