prismcast 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +9 -8
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +10 -34
- package/dist/browser/channelSelection.js +19 -96
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +19 -0
- package/dist/browser/display.js +16 -11
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +9 -46
- package/dist/browser/index.js +136 -191
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +65 -0
- package/dist/browser/login.js +196 -0
- package/dist/browser/login.js.map +1 -0
- package/dist/browser/precaching.js +12 -0
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
- package/dist/browser/tuning/comcastPolymer.js +655 -0
- package/dist/browser/tuning/comcastPolymer.js.map +1 -0
- package/dist/browser/tuning/cox.d.ts +2 -0
- package/dist/browser/tuning/cox.js +14 -0
- package/dist/browser/tuning/cox.js.map +1 -0
- package/dist/browser/tuning/directv.js +1 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +2 -2
- 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 +1 -1
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +46 -0
- package/dist/browser/tuning/shared.js +100 -0
- package/dist/browser/tuning/shared.js.map +1 -0
- package/dist/browser/tuning/sling.js +1 -1
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +1 -1
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/thumbnailRow.js.map +1 -1
- package/dist/browser/tuning/tileClick.js +1 -1
- package/dist/browser/tuning/tileClick.js.map +1 -1
- package/dist/browser/tuning/xfinity.js +10 -603
- package/dist/browser/tuning/xfinity.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.js +1 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +18 -0
- package/dist/browser/video.js +69 -107
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +4 -2
- package/dist/channels/index.js +2261 -800
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.js +30 -17
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +37 -63
- package/dist/config/index.js.map +1 -1
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerPacks.js +4 -1
- package/dist/config/providerPacks.js.map +1 -1
- package/dist/config/providers.d.ts +12 -5
- package/dist/config/providers.js +133 -104
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.js +15 -9
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +108 -7
- package/dist/config/userChannels.js +374 -44
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +3 -1
- package/dist/config/userConfig.js +31 -19
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.js +27 -39
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -4
- 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/index.js +9 -15
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +3 -0
- package/dist/native/index.js +16 -25
- package/dist/native/index.js.map +1 -1
- package/dist/native/probe.d.ts +4 -3
- package/dist/native/probe.js +36 -12
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -0
- package/dist/native/proxy.js +369 -326
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +26 -48
- package/dist/routes/assets.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 +43 -8
- package/dist/routes/components.js +122 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +2 -2
- 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 +749 -71
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +93 -2
- package/dist/routes/config/channels/table.js +594 -104
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +1 -1
- package/dist/routes/config/index.js +1 -1
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/providers.d.ts +6 -6
- package/dist/routes/config/providers.js +76 -102
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/config/settings.js +57 -36
- package/dist/routes/config/settings.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.d.ts +0 -16
- package/dist/routes/index.js +0 -17
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +68 -27
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/providers.js +106 -3
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/root/content.js +96 -40
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -1
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +955 -310
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +695 -569
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +1 -0
- package/dist/routes/root/scripts/index.js +1 -0
- package/dist/routes/root/scripts/index.js.map +1 -1
- package/dist/routes/root/scripts/shared.d.ts +6 -0
- package/dist/routes/root/scripts/shared.js +437 -0
- package/dist/routes/root/scripts/shared.js.map +1 -0
- package/dist/routes/root/scripts/status.js +33 -24
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +157 -11
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +1 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +4 -35
- package/dist/service/commands.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 -0
- package/dist/streaming/fmp4Segmenter.js +7 -4
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.js +274 -188
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +9 -3
- package/dist/streaming/hlsResume.js +15 -8
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +1 -2
- package/dist/streaming/hlsSegments.js +48 -46
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +8 -12
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.js +625 -585
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mpegts.js +1 -1
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/preroll.d.ts +41 -20
- package/dist/streaming/preroll.js +157 -76
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/recovery.d.ts +0 -6
- package/dist/streaming/recovery.js +0 -8
- package/dist/streaming/recovery.js.map +1 -1
- package/dist/streaming/registry.d.ts +36 -9
- package/dist/streaming/registry.js +6 -9
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +3 -3
- package/dist/streaming/setup.js +38 -22
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +7 -6
- package/dist/streaming/showInfo.js +156 -36
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +14 -2
- package/dist/streaming/statusEmitter.js +22 -4
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +34 -0
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +8 -3
- package/dist/types/selection.d.ts +1 -0
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -15
- 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/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +5 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +12 -0
- package/dist/utils/delay.js +20 -0
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +6 -11
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +13 -5
- package/dist/utils/ffmpeg.js +32 -11
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/format.d.ts +20 -3
- package/dist/utils/format.js +32 -6
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.js +1 -1
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/retry.d.ts +15 -9
- package/dist/utils/retry.js +8 -26
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/timing.d.ts +5 -0
- package/dist/utils/timing.js +13 -0
- package/dist/utils/timing.js.map +1 -0
- package/dist/utils/version.js +2 -2
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -5
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
|
|
4
5
|
import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.js";
|
|
5
|
-
import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels,
|
|
6
|
+
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getTagRegistry, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveTagRegistry, saveUserChannels, setTagRegistry, transformChannelTags, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
|
|
6
7
|
import { CONFIG } from "../../../config/index.js";
|
|
7
8
|
import { getProfiles } from "../../../config/profiles.js";
|
|
9
|
+
import { updateChannelLogo } from "../../../streaming/showInfo.js";
|
|
8
10
|
// Fields that appear in the generated M3U playlist and affect Channels DVR's view of the channel. Used to decide whether the playlist reload hint is shown.
|
|
9
11
|
const M3U_FIELDS = ["channelNumber", "name", "stationId", "tvgShift"];
|
|
10
12
|
const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change.";
|
|
@@ -17,6 +19,48 @@ const PLAYLIST_HINT = " Reload the playlist in Channels DVR to see this change."
|
|
|
17
19
|
function playlistHintForStored(stored) {
|
|
18
20
|
return M3U_FIELDS.some((f) => f in stored) ? PLAYLIST_HINT : "";
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Disables one or more predefined channels by adding their keys to the disabledPredefined list in user config. Updates both the config file and the runtime
|
|
24
|
+
* CONFIG object. Shared by the toggle-predefined and bulk-add handlers to avoid duplicating the config load/save/update pattern.
|
|
25
|
+
* @param keys - The predefined channel keys to disable.
|
|
26
|
+
*/
|
|
27
|
+
async function disablePredefinedChannels(keys) {
|
|
28
|
+
if (keys.length === 0) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const configResult = await loadUserConfig();
|
|
32
|
+
const userConfig = configResult.config;
|
|
33
|
+
userConfig.channels ??= {};
|
|
34
|
+
userConfig.channels.disabledPredefined ??= [];
|
|
35
|
+
const disabledSet = new Set(userConfig.channels.disabledPredefined);
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
disabledSet.add(key);
|
|
38
|
+
}
|
|
39
|
+
userConfig.channels.disabledPredefined = [...disabledSet].sort();
|
|
40
|
+
await saveUserConfig(userConfig);
|
|
41
|
+
CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Enables one or more predefined channels by removing their keys from the disabledPredefined list in user config. Updates both the config file and the
|
|
45
|
+
* runtime CONFIG object. Shared by the toggle-predefined and bulk-add handlers to avoid duplicating the config load/save/update pattern.
|
|
46
|
+
* @param keys - The predefined channel keys to enable.
|
|
47
|
+
*/
|
|
48
|
+
async function enablePredefinedChannels(keys) {
|
|
49
|
+
if (keys.length === 0) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const configResult = await loadUserConfig();
|
|
53
|
+
const userConfig = configResult.config;
|
|
54
|
+
userConfig.channels ??= {};
|
|
55
|
+
userConfig.channels.disabledPredefined ??= [];
|
|
56
|
+
const disabledSet = new Set(userConfig.channels.disabledPredefined);
|
|
57
|
+
for (const key of keys) {
|
|
58
|
+
disabledSet.delete(key);
|
|
59
|
+
}
|
|
60
|
+
userConfig.channels.disabledPredefined = [...disabledSet].sort();
|
|
61
|
+
await saveUserConfig(userConfig);
|
|
62
|
+
CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
|
|
63
|
+
}
|
|
20
64
|
/**
|
|
21
65
|
* Installs all channel-related route handlers on the Express application.
|
|
22
66
|
* @param app - The Express application.
|
|
@@ -33,7 +77,7 @@ export function setupChannelRoutes(app) {
|
|
|
33
77
|
}
|
|
34
78
|
res.setHeader("Content-Type", "application/json");
|
|
35
79
|
res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
|
|
36
|
-
res.send(
|
|
80
|
+
res.send(stringifySorted(resolved) + "\n");
|
|
37
81
|
}
|
|
38
82
|
catch (error) {
|
|
39
83
|
LOG.error("Failed to export channels: %s.", formatError(error));
|
|
@@ -54,8 +98,10 @@ export function setupChannelRoutes(app) {
|
|
|
54
98
|
// Save the imported channels, replacing all existing user channels.
|
|
55
99
|
await saveUserChannels(validationResult.channels);
|
|
56
100
|
const channelCount = Object.keys(validationResult.channels).length;
|
|
57
|
-
//
|
|
58
|
-
|
|
101
|
+
// Return a full table patch since a bulk JSON import replaces all user channels — every row in the listing may have changed.
|
|
102
|
+
const allKeys = getChannelListing().map((entry) => entry.key);
|
|
103
|
+
res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.",
|
|
104
|
+
patch: buildChannelTablePatch(allKeys, getProfiles()), success: true });
|
|
59
105
|
}
|
|
60
106
|
catch (error) {
|
|
61
107
|
LOG.error("Failed to import channels: %s.", formatError(error));
|
|
@@ -92,6 +138,7 @@ export function setupChannelRoutes(app) {
|
|
|
92
138
|
const loadResult = await loadUserChannels();
|
|
93
139
|
const existingChannels = loadResult.parseError ? {} : loadResult.channels;
|
|
94
140
|
// Track import statistics.
|
|
141
|
+
const affectedKeys = [];
|
|
95
142
|
const conflicts = [];
|
|
96
143
|
const importErrors = [];
|
|
97
144
|
const seenKeys = new Set();
|
|
@@ -151,16 +198,18 @@ export function setupChannelRoutes(app) {
|
|
|
151
198
|
}
|
|
152
199
|
// Add to channels collection.
|
|
153
200
|
existingChannels[key] = channel;
|
|
201
|
+
affectedKeys.push(key);
|
|
154
202
|
}
|
|
155
203
|
// Save the updated channels.
|
|
156
204
|
await saveUserChannels(existingChannels);
|
|
157
205
|
// Log the import.
|
|
158
206
|
LOG.info("M3U import completed: %d imported, %d replaced, %d skipped.", imported, replaced, skipped);
|
|
159
|
-
// Build response.
|
|
207
|
+
// Build response with patch for in-place UI update.
|
|
160
208
|
res.json({
|
|
161
209
|
conflicts,
|
|
162
210
|
errors: [...parseResult.errors, ...importErrors],
|
|
163
211
|
imported,
|
|
212
|
+
patch: ((imported > 0) || (replaced > 0)) ? buildChannelTablePatch(affectedKeys, getProfiles()) : undefined,
|
|
164
213
|
replaced,
|
|
165
214
|
skipped,
|
|
166
215
|
success: true
|
|
@@ -171,6 +220,177 @@ export function setupChannelRoutes(app) {
|
|
|
171
220
|
res.status(500).json({ error: "Failed to import channels: " + formatError(error), success: false });
|
|
172
221
|
}
|
|
173
222
|
});
|
|
223
|
+
// 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 provider, 'switch' changes the provider selection for an existing channel, and 'remove' reverts a
|
|
225
|
+
// channel to its canonical provider (disabling it if no alternative provider is available). Channel writes and provider selection changes are batched into
|
|
226
|
+
// single saves to avoid redundant file I/O.
|
|
227
|
+
app.post("/config/channels/modify", async (req, res) => {
|
|
228
|
+
try {
|
|
229
|
+
const body = req.body;
|
|
230
|
+
const channels = body.channels;
|
|
231
|
+
// Validate that channels array is provided and non-empty.
|
|
232
|
+
if (!Array.isArray(channels) || (channels.length === 0)) {
|
|
233
|
+
res.status(400).json({ error: "No channels provided.", success: false });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
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
|
+
const affectedKeys = new Set();
|
|
242
|
+
const disablePredefined = new Set();
|
|
243
|
+
const errors = [];
|
|
244
|
+
let added = 0;
|
|
245
|
+
let removed = 0;
|
|
246
|
+
let switched = 0;
|
|
247
|
+
let channelsChanged = false;
|
|
248
|
+
let selectionsChanged = false;
|
|
249
|
+
// Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic. When
|
|
250
|
+
// canonicalKey is provided, the channel is a provider variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
|
|
251
|
+
// at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
|
|
252
|
+
function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
|
|
253
|
+
const channel = {
|
|
254
|
+
...(canonicalKey ? { canonicalKey } : { name: channelName }),
|
|
255
|
+
channelSelector: selector || undefined,
|
|
256
|
+
url: channelUrl
|
|
257
|
+
};
|
|
258
|
+
if (!canonicalKey && entry.stationId) {
|
|
259
|
+
channel.stationId = sanitizeString(entry.stationId);
|
|
260
|
+
}
|
|
261
|
+
return channel;
|
|
262
|
+
}
|
|
263
|
+
for (const entry of channels) {
|
|
264
|
+
const action = entry.action ?? "add";
|
|
265
|
+
const name = sanitizeString(entry.name?.trim() ?? "");
|
|
266
|
+
const providerSlug = entry.providerSlug?.trim() ?? "";
|
|
267
|
+
// Handle enable and switch. Enable re-enables a disabled predefined channel, then falls through to the switch logic to set the provider selection.
|
|
268
|
+
// Switch changes the provider selection for an existing channel to point to the browsed provider's variant, creating the variant if needed.
|
|
269
|
+
if ((action === "switch") || (action === "enable")) {
|
|
270
|
+
const canonicalKey = entry.canonicalKey?.trim() ?? "";
|
|
271
|
+
if (!canonicalKey) {
|
|
272
|
+
errors.push("Entry for '" + name + "' missing canonical key.");
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// Re-enable the channel if it was disabled. Must complete before setting the provider selection since the config write makes the channel
|
|
276
|
+
// visible in the lineup.
|
|
277
|
+
if (action === "enable") {
|
|
278
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential: config state must be updated before setting provider selection.
|
|
279
|
+
await enablePredefinedChannels([canonicalKey]);
|
|
280
|
+
}
|
|
281
|
+
const variantKey = canonicalKey + "-" + providerSlug;
|
|
282
|
+
// If no variant exists for this provider, create one as a user channel. The canonicalKey parameter tells buildUserChannel to produce a variant
|
|
283
|
+
// (provider-specific fields only, no identity fields).
|
|
284
|
+
if (!allKeys.has(variantKey)) {
|
|
285
|
+
existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""), canonicalKey);
|
|
286
|
+
allKeys.add(variantKey);
|
|
287
|
+
channelsChanged = true;
|
|
288
|
+
}
|
|
289
|
+
setProviderSelection(canonicalKey, variantKey);
|
|
290
|
+
selectionsChanged = true;
|
|
291
|
+
switched++;
|
|
292
|
+
affectedKeys.add(canonicalKey);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// Handle provider removal. Reverts the channel away from this provider using a three-tier fallback: (1) clear the selection and let
|
|
296
|
+
// resolveProviderKey find the next enabled variant or canonical, (2) if the resolved provider is still this provider (no alternative exists),
|
|
297
|
+
// disable the predefined channel or delete the user channel.
|
|
298
|
+
if (action === "remove") {
|
|
299
|
+
const canonicalKey = entry.canonicalKey?.trim() ?? "";
|
|
300
|
+
if (!canonicalKey) {
|
|
301
|
+
errors.push("Remove entry for '" + name + "' missing canonical key.");
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// Clear the provider selection. resolveProviderKey will now return the canonical or the first enabled alternative variant.
|
|
305
|
+
setProviderSelection(canonicalKey, canonicalKey);
|
|
306
|
+
selectionsChanged = true;
|
|
307
|
+
// Check if the resolved provider is still this provider. If so, there is no alternative — disable or delete the channel.
|
|
308
|
+
const resolvedKey = resolveProviderKey(canonicalKey);
|
|
309
|
+
const resolvedTag = getProviderTagForChannel(resolvedKey);
|
|
310
|
+
if (resolvedTag === providerSlug) {
|
|
311
|
+
// No alternative provider exists. Disable predefined channels or delete user channels.
|
|
312
|
+
if (isPredefinedChannel(canonicalKey)) {
|
|
313
|
+
disablePredefined.add(canonicalKey);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
Reflect.deleteProperty(existingChannels, canonicalKey);
|
|
317
|
+
channelsChanged = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
removed++;
|
|
321
|
+
affectedKeys.add(canonicalKey);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// 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
|
|
325
|
+
// existing canonical). Channels that match existing canonicals appear as 'switch' state in the modal instead.
|
|
326
|
+
const url = sanitizeString(entry.url?.trim() ?? "");
|
|
327
|
+
const channelSelector = sanitizeString(entry.channelSelector?.trim() ?? "");
|
|
328
|
+
if (!name) {
|
|
329
|
+
errors.push("Channel entry missing name.");
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (!url) {
|
|
333
|
+
errors.push("Channel '" + name + "' missing URL.");
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const urlError = validateChannelUrl(url);
|
|
337
|
+
if (urlError) {
|
|
338
|
+
errors.push("Channel '" + name + "': " + urlError);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const baseKey = generateChannelKey(name);
|
|
342
|
+
if (!baseKey) {
|
|
343
|
+
errors.push("Could not generate key for channel '" + name + "'.");
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const canonicalExists = allKeys.has(baseKey);
|
|
347
|
+
const key = (canonicalExists && providerSlug) ? baseKey + "-" + providerSlug : baseKey;
|
|
348
|
+
// Skip channels that already exist in the lineup.
|
|
349
|
+
if (allKeys.has(key)) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// When the key differs from baseKey, this is a provider variant — pass baseKey as canonicalKey so buildUserChannel produces a variant with only
|
|
353
|
+
// provider-specific fields. Standalone channels (key === baseKey) get the full channel object with identity fields.
|
|
354
|
+
const newChannel = buildUserChannel(entry, name, url, channelSelector, (key !== baseKey) ? baseKey : undefined);
|
|
355
|
+
existingChannels[key] = newChannel;
|
|
356
|
+
allKeys.add(key);
|
|
357
|
+
channelsChanged = true;
|
|
358
|
+
added++;
|
|
359
|
+
affectedKeys.add(canonicalExists ? baseKey : key);
|
|
360
|
+
}
|
|
361
|
+
// Save changes. saveUserChannels() persists both channel data and provider selections in a single file write, so we only need saveProviderSelections()
|
|
362
|
+
// (which writes the same file using the module-level cache) when selections changed but no new channels were added.
|
|
363
|
+
if (channelsChanged) {
|
|
364
|
+
await saveUserChannels(existingChannels);
|
|
365
|
+
}
|
|
366
|
+
else if (selectionsChanged) {
|
|
367
|
+
await saveProviderSelections();
|
|
368
|
+
}
|
|
369
|
+
// Disable predefined channels that had no alternative provider after removal.
|
|
370
|
+
await disablePredefinedChannels([...disablePredefined]);
|
|
371
|
+
for (const disabledKey of disablePredefined) {
|
|
372
|
+
affectedKeys.add(disabledKey);
|
|
373
|
+
}
|
|
374
|
+
// Build a descriptive response message.
|
|
375
|
+
const parts = [];
|
|
376
|
+
if (added > 0) {
|
|
377
|
+
parts.push("Added " + String(added) + " channel" + (added === 1 ? "" : "s") + ".");
|
|
378
|
+
}
|
|
379
|
+
if (switched > 0) {
|
|
380
|
+
parts.push("Switched " + String(switched) + " channel" + (switched === 1 ? "" : "s") + ".");
|
|
381
|
+
}
|
|
382
|
+
if (removed > 0) {
|
|
383
|
+
parts.push("Reverted " + String(removed) + " channel" + (removed === 1 ? "" : "s") + ".");
|
|
384
|
+
}
|
|
385
|
+
const message = (parts.length > 0 ? parts.join(" ") : "No changes made.") + PLAYLIST_HINT;
|
|
386
|
+
LOG.info("Browse channels completed: %d added, %d switched, %d removed.", added, switched, removed);
|
|
387
|
+
res.json({ added, errors, message, patch: buildChannelTablePatch([...affectedKeys], getProfiles()), removed, success: true, switched });
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
LOG.error("Failed to apply browse changes: %s.", formatError(error));
|
|
391
|
+
res.status(500).json({ error: "Failed to apply changes: " + formatError(error), success: false });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
174
394
|
// POST /config/channels/toggle-predefined - Toggle a single predefined channel's enabled/disabled state.
|
|
175
395
|
app.post("/config/channels/toggle-predefined", async (req, res) => {
|
|
176
396
|
try {
|
|
@@ -192,28 +412,15 @@ export function setupChannelRoutes(app) {
|
|
|
192
412
|
res.status(400).json({ error: "Channel '" + key + "' is not a predefined channel.", success: false });
|
|
193
413
|
return;
|
|
194
414
|
}
|
|
195
|
-
//
|
|
196
|
-
const configResult = await loadUserConfig();
|
|
197
|
-
const userConfig = configResult.config;
|
|
198
|
-
// Initialize channels.disabledPredefined if not present.
|
|
199
|
-
userConfig.channels ??= {};
|
|
200
|
-
userConfig.channels.disabledPredefined ??= [];
|
|
201
|
-
const disabledSet = new Set(userConfig.channels.disabledPredefined);
|
|
415
|
+
// Enable or disable the channel using the shared helpers that handle config file I/O and runtime CONFIG update.
|
|
202
416
|
if (enabled) {
|
|
203
|
-
|
|
204
|
-
disabledSet.delete(key);
|
|
417
|
+
await enablePredefinedChannels([key]);
|
|
205
418
|
}
|
|
206
419
|
else {
|
|
207
|
-
|
|
208
|
-
disabledSet.add(key);
|
|
420
|
+
await disablePredefinedChannels([key]);
|
|
209
421
|
}
|
|
210
|
-
// Update and save config.
|
|
211
|
-
userConfig.channels.disabledPredefined = [...disabledSet].sort();
|
|
212
|
-
await saveUserConfig(userConfig);
|
|
213
|
-
// Update the runtime CONFIG to reflect the change immediately.
|
|
214
|
-
CONFIG.channels.disabledPredefined = userConfig.channels.disabledPredefined;
|
|
215
422
|
LOG.info("Predefined channel '%s' %s.", key, enabled ? "enabled" : "disabled");
|
|
216
|
-
res.json({
|
|
423
|
+
res.json({ enabled, key, patch: buildChannelTablePatch([key], getProfiles()), success: true });
|
|
217
424
|
}
|
|
218
425
|
catch (error) {
|
|
219
426
|
LOG.error("Failed to toggle predefined channel: %s.", formatError(error));
|
|
@@ -260,10 +467,9 @@ export function setupChannelRoutes(app) {
|
|
|
260
467
|
const channelName = canonicalChannel?.name ?? canonicalKey;
|
|
261
468
|
const providerLabel = variantChannel ? getChannelProviderLabel(variantChannel) : providerKey;
|
|
262
469
|
LOG.info("Provider for %s changed to %s.", channelName, providerLabel);
|
|
263
|
-
// Return
|
|
470
|
+
// Return a channel table patch so the client can update the row and summary counts in place.
|
|
264
471
|
const profiles = getProfiles();
|
|
265
|
-
|
|
266
|
-
res.json({ channel: canonicalKey, html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow }, provider: providerKey, success: true });
|
|
472
|
+
res.json({ channel: canonicalKey, patch: buildChannelTablePatch([canonicalKey], profiles), provider: providerKey, success: true });
|
|
267
473
|
}
|
|
268
474
|
catch (error) {
|
|
269
475
|
LOG.error("Failed to update provider selection: %s.", formatError(error));
|
|
@@ -330,15 +536,168 @@ export function setupChannelRoutes(app) {
|
|
|
330
536
|
const affected = targetKeys.length;
|
|
331
537
|
const scopeLabel = (scope === "all") ? "All" : (scope === "pacific") ? "Pacific" : "East";
|
|
332
538
|
LOG.info("%s predefined channels %s (%d affected).", scopeLabel, enabled ? "enabled" : "disabled", affected);
|
|
333
|
-
|
|
334
|
-
const counts = getPredefinedScopeCounts();
|
|
335
|
-
res.json({ affected, counts, enabled, keys: targetKeys, scope, success: true });
|
|
539
|
+
res.json({ affected, enabled, keys: targetKeys, patch: buildChannelTablePatch(targetKeys, getProfiles()), scope, success: true });
|
|
336
540
|
}
|
|
337
541
|
catch (error) {
|
|
338
542
|
LOG.error("Failed to toggle predefined channels: %s.", formatError(error));
|
|
339
543
|
res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
|
|
340
544
|
}
|
|
341
545
|
});
|
|
546
|
+
// 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 provider filter are affected.
|
|
548
|
+
app.post("/config/channels/auto-number", async (req, res) => {
|
|
549
|
+
try {
|
|
550
|
+
const body = req.body;
|
|
551
|
+
const start = (typeof body.start === "number") ? body.start : 1;
|
|
552
|
+
const clearMode = (start === 0);
|
|
553
|
+
const sortField = body.sortField ?? "name";
|
|
554
|
+
const sortDir = (body.sortDirection === "desc") ? "desc" : "asc";
|
|
555
|
+
if (!clearMode && ((start < 1) || (start > 99999))) {
|
|
556
|
+
res.status(400).json({ error: "Starting number must be between 1 and 99999.", success: false });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (!VALID_SORT_FIELDS.has(sortField)) {
|
|
560
|
+
res.status(400).json({ error: "Invalid sort field.", success: false });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Get visible channels (enabled + available by provider filter) sorted by the user's current sort order.
|
|
564
|
+
const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByProvider);
|
|
565
|
+
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
|
+
const affectedKeys = [];
|
|
573
|
+
if (clearMode) {
|
|
574
|
+
// Clear channel numbers from all visible channels. Null signals "clear this field" — the normalizer in saveUserChannels() handles the storage conventions.
|
|
575
|
+
for (const entry of listing) {
|
|
576
|
+
const existing = result.channels[entry.key] ?? {};
|
|
577
|
+
existing.channelNumber = null;
|
|
578
|
+
result.channels[entry.key] = existing;
|
|
579
|
+
affectedKeys.push(entry.key);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
// Assign sequential numbers starting from the requested start value.
|
|
584
|
+
for (let i = 0; i < listing.length; i++) {
|
|
585
|
+
const entry = listing[i];
|
|
586
|
+
const num = start + i;
|
|
587
|
+
if (num > 99999) {
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
const existing = result.channels[entry.key] ?? {};
|
|
591
|
+
existing.channelNumber = num;
|
|
592
|
+
result.channels[entry.key] = existing;
|
|
593
|
+
affectedKeys.push(entry.key);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
await saveUserChannels(result.channels);
|
|
597
|
+
const action = clearMode ? "Cleared" : "Numbered";
|
|
598
|
+
LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
|
|
599
|
+
const profiles = getProfiles();
|
|
600
|
+
const message = clearMode ?
|
|
601
|
+
"Cleared channel numbers from " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT :
|
|
602
|
+
"Numbered " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT;
|
|
603
|
+
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
LOG.error("Failed to auto-number channels: %s.", formatError(error));
|
|
607
|
+
res.status(500).json({ error: "Failed to auto-number channels: " + formatError(error), success: false });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
// POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
|
|
611
|
+
// cleared). When false, all channels are excluded (hdhrEnabled set to false).
|
|
612
|
+
app.post("/config/channels/hdhr-bulk", async (req, res) => {
|
|
613
|
+
try {
|
|
614
|
+
const body = req.body;
|
|
615
|
+
const enable = body.enable === true;
|
|
616
|
+
// Get visible channels (enabled + available by provider filter).
|
|
617
|
+
const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByProvider);
|
|
618
|
+
// Load user channels for modification.
|
|
619
|
+
const result = await loadUserChannels();
|
|
620
|
+
if (result.parseError) {
|
|
621
|
+
res.status(400).json({ error: "Cannot update HDHR settings: channels file contains invalid JSON.", success: false });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const affectedKeys = [];
|
|
625
|
+
for (const entry of listing) {
|
|
626
|
+
const current = entry.channel.hdhrEnabled !== false;
|
|
627
|
+
// Skip channels that already match the target state.
|
|
628
|
+
if (current === enable) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const existing = result.channels[entry.key] ?? {};
|
|
632
|
+
existing.hdhrEnabled = enable ? null : false;
|
|
633
|
+
result.channels[entry.key] = existing;
|
|
634
|
+
affectedKeys.push(entry.key);
|
|
635
|
+
}
|
|
636
|
+
if (affectedKeys.length === 0) {
|
|
637
|
+
res.json({ affected: 0, message: "No changes needed.", success: true });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
await saveUserChannels(result.channels);
|
|
641
|
+
const action = enable ? "included in" : "excluded from";
|
|
642
|
+
LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
|
|
643
|
+
const profiles = getProfiles();
|
|
644
|
+
const message = String(affectedKeys.length) + " channel(s) " + action + " the HDHomeRun lineup.";
|
|
645
|
+
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
LOG.error("Failed to bulk-toggle HDHR: %s.", formatError(error));
|
|
649
|
+
res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
// POST /config/channels/bulk-tags - Add or remove a tag on all enabled, provider-available channels. Operates on the same channel set as other bulk actions.
|
|
653
|
+
// transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
|
|
654
|
+
// modal body.
|
|
655
|
+
app.post("/config/channels/bulk-tags", async (req, res) => {
|
|
656
|
+
try {
|
|
657
|
+
const body = req.body;
|
|
658
|
+
const action = body.action;
|
|
659
|
+
const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
|
|
660
|
+
if ((action !== "add") && (action !== "remove")) {
|
|
661
|
+
res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (!tag) {
|
|
665
|
+
res.status(400).json({ error: "Tag is required.", success: false });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// Validate the tag is in the active vocabulary.
|
|
669
|
+
const vocabulary = new Set(getActiveTagVocabulary());
|
|
670
|
+
if (!vocabulary.has(tag)) {
|
|
671
|
+
res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.enabled && entry.availableByProvider, (tags) => (action === "add") ? (tags.includes(tag) ? tags : [...tags, tag]) : tags.filter((t) => t !== tag));
|
|
675
|
+
if (error) {
|
|
676
|
+
res.status(400).json({ error, success: false });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (affectedKeys.length === 0) {
|
|
680
|
+
res.json({ affected: 0, message: "No changes needed.", success: true });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const verb = (action === "add") ? "added to" : "removed from";
|
|
684
|
+
LOG.info("Bulk tag %s: %s on %d channels.", action, tag, affectedKeys.length);
|
|
685
|
+
const profiles = getProfiles();
|
|
686
|
+
const message = "Tag '" + tag + "' " + verb + " " + String(affectedKeys.length) + " channel(s).";
|
|
687
|
+
res.json({
|
|
688
|
+
affected: affectedKeys.length,
|
|
689
|
+
filterContent: generateTagFilterContent(),
|
|
690
|
+
message,
|
|
691
|
+
modalBody: generateTagManagerBody(),
|
|
692
|
+
patch: buildChannelTablePatch(affectedKeys, profiles),
|
|
693
|
+
success: true
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
LOG.error("Failed to bulk-update tags: %s.", formatError(error));
|
|
698
|
+
res.status(500).json({ error: "Failed to update tags: " + formatError(error), success: false });
|
|
699
|
+
}
|
|
700
|
+
});
|
|
342
701
|
// POST /config/provider-filter - Update the provider filter (enabled provider tags).
|
|
343
702
|
app.post("/config/provider-filter", async (req, res) => {
|
|
344
703
|
try {
|
|
@@ -370,13 +729,33 @@ export function setupChannelRoutes(app) {
|
|
|
370
729
|
userConfig.channels.enabledProviders = tags;
|
|
371
730
|
await saveUserConfig(filterDefaults(userConfig));
|
|
372
731
|
LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
|
|
373
|
-
|
|
732
|
+
// Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
|
|
733
|
+
const { counts, scopeCounts } = buildChannelTableState();
|
|
734
|
+
res.json({ enabledProviders: tags, patch: { counts, rows: [], scopeCounts }, success: true });
|
|
374
735
|
}
|
|
375
736
|
catch (error) {
|
|
376
737
|
LOG.error("Failed to update provider filter: %s.", formatError(error));
|
|
377
738
|
res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
|
|
378
739
|
}
|
|
379
740
|
});
|
|
741
|
+
// POST /config/channels/setup-completed - Mark the Provider Setup flow as completed. Called when the wizard finishes or the user explicitly skips.
|
|
742
|
+
app.post("/config/channels/setup-completed", async (_req, res) => {
|
|
743
|
+
try {
|
|
744
|
+
CONFIG.channels.setupCompleted = true;
|
|
745
|
+
const configResult = await loadUserConfig();
|
|
746
|
+
configResult.config.channels ??= {};
|
|
747
|
+
configResult.config.channels.setupCompleted = true;
|
|
748
|
+
await saveUserConfig(configResult.config);
|
|
749
|
+
// Return a full patch so the client can refresh the channel table after the setup wizard completes. The wizard's browse step may have added channels
|
|
750
|
+
// via the modify endpoint, and the provider filter may have changed — the patch ensures the table reflects the current state.
|
|
751
|
+
const { counts, scopeCounts } = buildChannelTableState();
|
|
752
|
+
res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
LOG.error("Failed to save setup completed state: %s.", formatError(error));
|
|
756
|
+
res.status(500).json({ error: "Failed to save setup state: " + formatError(error), success: false });
|
|
757
|
+
}
|
|
758
|
+
});
|
|
380
759
|
// POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
|
|
381
760
|
app.post("/config/channels/display-prefs", async (req, res) => {
|
|
382
761
|
try {
|
|
@@ -545,10 +924,64 @@ export function setupChannelRoutes(app) {
|
|
|
545
924
|
Reflect.deleteProperty(result.channels, key);
|
|
546
925
|
await saveUserChannels(result.channels);
|
|
547
926
|
LOG.info("Channel '%s' reverted to predefined defaults.", key);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
927
|
+
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
|
|
928
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
// Handle inline-edit action — update a single field on an existing channel without requiring all fields. Used by the inline cell editing UI for channel
|
|
932
|
+
// number, station ID, and HDHR lineup toggles.
|
|
933
|
+
if (action === "inline-edit") {
|
|
934
|
+
if (!key) {
|
|
935
|
+
res.status(400).json({ message: "Channel key is required.", success: false });
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const field = body.field;
|
|
939
|
+
const value = sanitizeString(body.value ?? "");
|
|
940
|
+
if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId") && (field !== "tags")) {
|
|
941
|
+
res.status(400).json({ message: "Invalid inline-edit field.", success: false });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
// Validate channel number if the field is channelNumber.
|
|
945
|
+
if (field === "channelNumber") {
|
|
946
|
+
const numberError = validateChannelNumber(value, key);
|
|
947
|
+
if (numberError) {
|
|
948
|
+
res.status(400).json({ message: numberError, success: false });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
// Load and update the channel. For predefined channels, store a delta override with null to clear fields. For user channels, use undefined to delete.
|
|
953
|
+
const result = await loadUserChannels();
|
|
954
|
+
if (result.parseError) {
|
|
955
|
+
res.status(400).json({ message: "Cannot edit: channels file contains invalid JSON.", success: false });
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
// Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet. Null signals
|
|
959
|
+
// "clear this field" for all channel types — the normalizer in saveUserChannels() handles delta comparison for predefined channels and null-stripping for
|
|
960
|
+
// user channels.
|
|
961
|
+
const stored = result.channels[key] ?? {};
|
|
962
|
+
if (field === "channelNumber") {
|
|
963
|
+
stored.channelNumber = value ? parseInt(value, 10) : null;
|
|
964
|
+
}
|
|
965
|
+
else if (field === "hdhrEnabled") {
|
|
966
|
+
stored.hdhrEnabled = (value === "false") ? false : null;
|
|
967
|
+
}
|
|
968
|
+
else if (field === "tags") {
|
|
969
|
+
const tags = value ? [...new Set(value.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
|
|
970
|
+
stored.tags = (tags.length > 0) ? tags : null;
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
stored.stationId = value || null;
|
|
974
|
+
}
|
|
975
|
+
result.channels[key] = stored;
|
|
976
|
+
await saveUserChannels(result.channels);
|
|
977
|
+
const fieldLabels = {
|
|
978
|
+
channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
|
|
979
|
+
};
|
|
980
|
+
const fieldLabel = fieldLabels[field] ?? field;
|
|
981
|
+
const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
|
|
982
|
+
LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
|
|
983
|
+
const playlistHint = (field === "hdhrEnabled") ? "" : PLAYLIST_HINT;
|
|
984
|
+
res.json({ key, message: fieldLabel + " updated." + playlistHint, patch: buildChannelTablePatch([key], profiles), success: true });
|
|
552
985
|
return;
|
|
553
986
|
}
|
|
554
987
|
// Handle delete action.
|
|
@@ -570,12 +1003,10 @@ export function setupChannelRoutes(app) {
|
|
|
570
1003
|
Reflect.deleteProperty(result.channels, key);
|
|
571
1004
|
await saveUserChannels(result.channels);
|
|
572
1005
|
LOG.info("User channel '%s' deleted.", key);
|
|
573
|
-
// If a predefined channel exists with the same key,
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
res.json({ html: predefined, key,
|
|
578
|
-
message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT, success: true });
|
|
1006
|
+
// Return a patch. If a predefined channel exists with the same key, the patch includes an "update" row for the predefined version (so the client replaces
|
|
1007
|
+
// the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
|
|
1008
|
+
res.json({ key, message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT,
|
|
1009
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
579
1010
|
return;
|
|
580
1011
|
}
|
|
581
1012
|
// Handle add and edit actions.
|
|
@@ -597,21 +1028,14 @@ export function setupChannelRoutes(app) {
|
|
|
597
1028
|
const stationId = sanitizeString(body.stationId ?? "");
|
|
598
1029
|
const channelSelector = sanitizeString(body.channelSelector ?? "");
|
|
599
1030
|
const channelNumberStr = sanitizeString(body.channelNumber ?? "");
|
|
1031
|
+
const hdhrEnabled = body.hdhrEnabled !== "false";
|
|
1032
|
+
// Parse tags from comma-separated input. Trim, lowercase, filter empty, sort, deduplicate.
|
|
1033
|
+
const tagsRaw = sanitizeString(body.tags ?? "");
|
|
1034
|
+
const tags = tagsRaw ? [...new Set(tagsRaw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
|
|
600
1035
|
// Validate channel number if provided.
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
formErrors.channelNumber = "Channel number must be between 1 and 99999.";
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
607
|
-
// Check for duplicate channel numbers across all resolved channels.
|
|
608
|
-
for (const entry of getChannelListing()) {
|
|
609
|
-
if ((entry.channel.channelNumber === num) && (entry.key !== key)) {
|
|
610
|
-
formErrors.channelNumber = "Channel number " + String(num) + " is already used by '" + entry.key + "'.";
|
|
611
|
-
break;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
1036
|
+
const channelNumberError = validateChannelNumber(channelNumberStr, key);
|
|
1037
|
+
if (channelNumberError) {
|
|
1038
|
+
formErrors.channelNumber = channelNumberError;
|
|
615
1039
|
}
|
|
616
1040
|
// Validate key (only for add action, not edit).
|
|
617
1041
|
if (action === "add") {
|
|
@@ -657,32 +1081,41 @@ export function setupChannelRoutes(app) {
|
|
|
657
1081
|
const predefinedBase = getPredefinedChannel(key);
|
|
658
1082
|
if ((action === "edit") && predefinedBase) {
|
|
659
1083
|
// Build a record of submitted form values keyed by channel property name. This record drives both the displayChannel comparison and the predefined delta
|
|
660
|
-
// computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined.
|
|
1084
|
+
// computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined; hdhrEnabled uses boolean.
|
|
661
1085
|
const formValues = {
|
|
662
1086
|
channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
|
|
663
1087
|
channelSelector,
|
|
1088
|
+
hdhrEnabled,
|
|
664
1089
|
name,
|
|
665
1090
|
profile,
|
|
666
1091
|
stationId,
|
|
667
1092
|
url
|
|
668
1093
|
};
|
|
669
1094
|
// Helper to read a comparable value from a Channel object. String fields default to "" when undefined so they match the form's empty-string representation.
|
|
670
|
-
// channelNumber stays as number | undefined since the form value uses the same representation.
|
|
1095
|
+
// channelNumber stays as number | undefined since the form value uses the same representation. hdhrEnabled defaults to true when absent.
|
|
671
1096
|
const channelValue = (ch, field) => {
|
|
672
1097
|
const val = ch[field];
|
|
1098
|
+
if (field === "hdhrEnabled") {
|
|
1099
|
+
return val !== false;
|
|
1100
|
+
}
|
|
673
1101
|
return (field === "channelNumber") ? val : val ?? "";
|
|
674
1102
|
};
|
|
675
1103
|
// First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
|
|
676
1104
|
// 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
|
|
677
1105
|
// values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
|
|
1106
|
+
// Tags are compared separately (array comparison) after the scalar fields.
|
|
678
1107
|
const resolvedKey = resolveProviderKey(key);
|
|
679
1108
|
const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
|
|
680
|
-
|
|
1109
|
+
const displayTags = getChannelEffectiveTags(displayChannel);
|
|
1110
|
+
const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
|
|
1111
|
+
const tagsUnchanged = JSON.stringify(tags) === JSON.stringify(displayTags);
|
|
1112
|
+
if (scalarUnchanged && tagsUnchanged) {
|
|
681
1113
|
res.json({ key, message: "No changes to save.", success: true });
|
|
682
1114
|
return;
|
|
683
1115
|
}
|
|
684
1116
|
// Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
|
|
685
|
-
// revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
|
|
1117
|
+
// revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear). Tags are handled
|
|
1118
|
+
// separately from the scalar loop since they require array comparison.
|
|
686
1119
|
const delta = {};
|
|
687
1120
|
let hasChanges = false;
|
|
688
1121
|
for (const field of Object.keys(formValues)) {
|
|
@@ -692,8 +1125,17 @@ export function setupChannelRoutes(app) {
|
|
|
692
1125
|
hasChanges = true;
|
|
693
1126
|
}
|
|
694
1127
|
}
|
|
695
|
-
//
|
|
696
|
-
|
|
1128
|
+
// Tags delta: compare the submitted tags against the predefined base's vocabulary-filtered tags. Using effective tags (not raw) ensures that editing an
|
|
1129
|
+
// unrelated field while a predefined tag is deleted from the vocabulary doesn't spuriously bake the deletion into the channel's stored delta.
|
|
1130
|
+
const predefinedEffectiveTags = getChannelEffectiveTags(predefinedBase);
|
|
1131
|
+
if (JSON.stringify(tags) !== JSON.stringify(predefinedEffectiveTags)) {
|
|
1132
|
+
delta.tags = (tags.length > 0) ? tags : null;
|
|
1133
|
+
hasChanges = true;
|
|
1134
|
+
}
|
|
1135
|
+
// Helper to check if form values (scalars + tags) match a given channel's properties. Tags use vocabulary-filtered comparison so variant matching works
|
|
1136
|
+
// correctly when predefined tags are deleted from the vocabulary.
|
|
1137
|
+
const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
|
|
1138
|
+
(JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
|
|
697
1139
|
if (!hasChanges) {
|
|
698
1140
|
// The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away — treat it as an
|
|
699
1141
|
// implicit revert by removing the override and returning the predefined row HTML.
|
|
@@ -702,9 +1144,8 @@ export function setupChannelRoutes(app) {
|
|
|
702
1144
|
Reflect.deleteProperty(result.channels, key);
|
|
703
1145
|
await saveUserChannels(result.channels);
|
|
704
1146
|
LOG.info("Channel '%s' reverted to predefined defaults (edit matched predefined values).", key);
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint, success: true });
|
|
1147
|
+
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
|
|
1148
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
708
1149
|
return;
|
|
709
1150
|
}
|
|
710
1151
|
res.json({ key, message: "No changes to save.", success: true });
|
|
@@ -738,9 +1179,8 @@ export function setupChannelRoutes(app) {
|
|
|
738
1179
|
setProviderSelection(key, matchedVariantKey);
|
|
739
1180
|
await saveUserChannels(result.channels);
|
|
740
1181
|
LOG.info("Channel '%s' reverted to variant '%s' (edit matched variant values).", key, matchedVariantKey);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
message: "Channel '" + key + "' reverted to defaults." + variantRevertHint, success: true });
|
|
1182
|
+
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
|
|
1183
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
744
1184
|
return;
|
|
745
1185
|
}
|
|
746
1186
|
// No variant match — store the delta and switch the provider selection to the canonical key (the custom override). This ensures the provider dropdown shows
|
|
@@ -768,6 +1208,13 @@ export function setupChannelRoutes(app) {
|
|
|
768
1208
|
if (channelNumberStr) {
|
|
769
1209
|
channel.channelNumber = parseInt(channelNumberStr, 10);
|
|
770
1210
|
}
|
|
1211
|
+
if (tags.length > 0) {
|
|
1212
|
+
channel.tags = tags;
|
|
1213
|
+
}
|
|
1214
|
+
// Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
|
|
1215
|
+
if (!hdhrEnabled) {
|
|
1216
|
+
channel.hdhrEnabled = false;
|
|
1217
|
+
}
|
|
771
1218
|
result.channels[key] = channel;
|
|
772
1219
|
// For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
|
|
773
1220
|
playlistChanged = (action === "add") ||
|
|
@@ -775,18 +1222,21 @@ export function setupChannelRoutes(app) {
|
|
|
775
1222
|
M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
|
|
776
1223
|
}
|
|
777
1224
|
await saveUserChannels(result.channels);
|
|
1225
|
+
// Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
|
|
1226
|
+
if (stationId) {
|
|
1227
|
+
updateChannelLogo(name || key, stationId);
|
|
1228
|
+
}
|
|
778
1229
|
const actionLabel = (action === "add") ? "added" : "updated";
|
|
779
1230
|
LOG.info("User channel '%s' %s.", key, actionLabel);
|
|
780
1231
|
// Generate HTML for the channel row so the client can update the DOM without a full page reload.
|
|
781
|
-
const rowHtml = generateChannelRowHtml(key, profiles);
|
|
782
1232
|
// Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
|
|
783
1233
|
const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
|
|
784
|
-
// Return success response with
|
|
1234
|
+
// Return success response with patch for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
|
|
785
1235
|
res.json({
|
|
786
|
-
html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow },
|
|
787
1236
|
isNew: action === "add",
|
|
788
1237
|
key,
|
|
789
1238
|
message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
|
|
1239
|
+
patch: buildChannelTablePatch([key], profiles),
|
|
790
1240
|
success: true
|
|
791
1241
|
});
|
|
792
1242
|
}
|
|
@@ -795,5 +1245,233 @@ export function setupChannelRoutes(app) {
|
|
|
795
1245
|
res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
|
|
796
1246
|
}
|
|
797
1247
|
});
|
|
1248
|
+
// Tag Management Endpoints.
|
|
1249
|
+
// GET /config/tags - Returns the tag vocabulary and registry state. The active vocabulary is the computed merge of predefined tags (minus deleted) plus user
|
|
1250
|
+
// tags. The registry contains the raw user state for the tag management UI.
|
|
1251
|
+
app.get("/config/tags", (_req, res) => {
|
|
1252
|
+
res.json({
|
|
1253
|
+
active: getActiveTagVocabulary(),
|
|
1254
|
+
predefined: [...PREDEFINED_TAGS],
|
|
1255
|
+
registry: getTagRegistry(),
|
|
1256
|
+
success: true
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
// POST /config/tags - Create a new user tag. Validates the tag name (lowercase alphanumeric + hyphens, max 30 chars) and checks for duplicates against the
|
|
1260
|
+
// active vocabulary and deleted predefined tags (which should be restored, not re-created).
|
|
1261
|
+
app.post("/config/tags", async (req, res) => {
|
|
1262
|
+
try {
|
|
1263
|
+
const body = req.body;
|
|
1264
|
+
const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
|
|
1265
|
+
if (tag.length === 0) {
|
|
1266
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (tag.length > 30) {
|
|
1270
|
+
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(tag)) {
|
|
1274
|
+
res.status(400).json({ error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
// Check if the tag already exists in the active vocabulary.
|
|
1278
|
+
const vocabulary = new Set(getActiveTagVocabulary());
|
|
1279
|
+
if (vocabulary.has(tag)) {
|
|
1280
|
+
res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
// Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
|
|
1284
|
+
const registry = getTagRegistry();
|
|
1285
|
+
if (registry.deletedTags.includes(tag)) {
|
|
1286
|
+
res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
// Add the tag to the user registry and persist. No channels are affected (new tag has no assignments yet).
|
|
1290
|
+
registry.tags.push(tag);
|
|
1291
|
+
setTagRegistry(registry);
|
|
1292
|
+
await saveTagRegistry();
|
|
1293
|
+
LOG.info("Created tag: %s.", tag);
|
|
1294
|
+
res.json({
|
|
1295
|
+
active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1296
|
+
registry: getTagRegistry(), success: true
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
LOG.error("Failed to create tag: %s.", formatError(error));
|
|
1301
|
+
res.status(500).json({ error: "Failed to create tag: " + formatError(error), success: false });
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
// 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
|
|
1305
|
+
// 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
|
|
1306
|
+
// override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
|
|
1307
|
+
app.delete("/config/tags/:tag", async (req, res) => {
|
|
1308
|
+
try {
|
|
1309
|
+
const tag = req.params.tag?.toLowerCase() ?? "";
|
|
1310
|
+
if (tag.length === 0) {
|
|
1311
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const registry = getTagRegistry();
|
|
1315
|
+
const isPredefined = PREDEFINED_TAGS.includes(tag);
|
|
1316
|
+
const isUserTag = registry.tags.includes(tag);
|
|
1317
|
+
if (!isPredefined && !isUserTag) {
|
|
1318
|
+
res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (isPredefined) {
|
|
1322
|
+
// Already deleted — no-op, return current state without unnecessary I/O.
|
|
1323
|
+
if (registry.deletedTags.includes(tag)) {
|
|
1324
|
+
res.json({
|
|
1325
|
+
active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1326
|
+
registry: getTagRegistry(), success: true
|
|
1327
|
+
});
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
registry.deletedTags.push(tag);
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
// Remove from user tags.
|
|
1334
|
+
registry.tags = registry.tags.filter((t) => t !== tag);
|
|
1335
|
+
}
|
|
1336
|
+
setTagRegistry(registry);
|
|
1337
|
+
// Cascade: strip the deleted tag from all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
1338
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(tag) === true, (tags) => tags.filter((t) => t !== tag));
|
|
1339
|
+
if (error) {
|
|
1340
|
+
res.status(400).json({ error, success: false });
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
LOG.info("Deleted tag '%s' from vocabulary and %d channel assignments.", tag, affectedKeys.length);
|
|
1344
|
+
const profiles = getProfiles();
|
|
1345
|
+
res.json({
|
|
1346
|
+
active: getActiveTagVocabulary(),
|
|
1347
|
+
filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1348
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1349
|
+
registry: getTagRegistry(),
|
|
1350
|
+
success: true
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
LOG.error("Failed to delete tag: %s.", formatError(error));
|
|
1355
|
+
res.status(500).json({ error: "Failed to delete tag: " + formatError(error), success: false });
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
// POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
|
|
1359
|
+
// cascade-restores the tag on predefined channels whose definition includes it. This reverses the cascade delete — predefined channels go back to their default
|
|
1360
|
+
// tag assignments. User channels are not affected (their tag assignments were permanently removed during cascade delete with no source of truth to restore from).
|
|
1361
|
+
app.post("/config/tags/restore", async (req, res) => {
|
|
1362
|
+
try {
|
|
1363
|
+
const body = req.body;
|
|
1364
|
+
const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
|
|
1365
|
+
if (tag.length === 0) {
|
|
1366
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const registry = getTagRegistry();
|
|
1370
|
+
if (!registry.deletedTags.includes(tag)) {
|
|
1371
|
+
res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
registry.deletedTags = registry.deletedTags.filter((t) => t !== tag);
|
|
1375
|
+
setTagRegistry(registry);
|
|
1376
|
+
// Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
|
|
1377
|
+
// delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
|
|
1378
|
+
// predefined definition, cleanly reverting the channel to its default state.
|
|
1379
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => {
|
|
1380
|
+
const predefined = getPredefinedChannel(entry.key);
|
|
1381
|
+
return (predefined?.tags?.includes(tag) === true) && (entry.channel.tags?.includes(tag) !== true);
|
|
1382
|
+
}, (tags) => [...tags, tag]);
|
|
1383
|
+
if (error) {
|
|
1384
|
+
res.status(400).json({ error, success: false });
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
LOG.info("Restored predefined tag '%s' on %d channels.", tag, affectedKeys.length);
|
|
1388
|
+
const profiles = getProfiles();
|
|
1389
|
+
res.json({
|
|
1390
|
+
active: getActiveTagVocabulary(),
|
|
1391
|
+
filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1392
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1393
|
+
registry: getTagRegistry(),
|
|
1394
|
+
success: true
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
catch (error) {
|
|
1398
|
+
LOG.error("Failed to restore tag: %s.", formatError(error));
|
|
1399
|
+
res.status(500).json({ error: "Failed to restore tag: " + formatError(error), success: false });
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
// POST /config/tags/rename - Rename a tag atomically across the vocabulary and all channel assignments. For predefined tags, the old name is added to
|
|
1403
|
+
// 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
|
|
1404
|
+
// new tag substituted in their stored data (predefined channels via delta, user channels via direct modification).
|
|
1405
|
+
app.post("/config/tags/rename", async (req, res) => {
|
|
1406
|
+
try {
|
|
1407
|
+
const body = req.body;
|
|
1408
|
+
const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim().toLowerCase() : "";
|
|
1409
|
+
const newTag = typeof body.newTag === "string" ? body.newTag.trim().toLowerCase() : "";
|
|
1410
|
+
if (!oldTag || !newTag) {
|
|
1411
|
+
res.status(400).json({ error: "Both old and new tag names are required.", success: false });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (oldTag === newTag) {
|
|
1415
|
+
res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(newTag)) {
|
|
1419
|
+
res.status(400).json({
|
|
1420
|
+
error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false
|
|
1421
|
+
});
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (newTag.length > 30) {
|
|
1425
|
+
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
// Validate old tag exists in the active vocabulary.
|
|
1429
|
+
const vocabulary = new Set(getActiveTagVocabulary());
|
|
1430
|
+
if (!vocabulary.has(oldTag)) {
|
|
1431
|
+
res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
// Validate new tag doesn't already exist.
|
|
1435
|
+
if (vocabulary.has(newTag)) {
|
|
1436
|
+
res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
// Update the tag registry: replace old with new in the appropriate list.
|
|
1440
|
+
const registry = getTagRegistry();
|
|
1441
|
+
const oldIsPredefined = PREDEFINED_TAGS.includes(oldTag);
|
|
1442
|
+
if (oldIsPredefined) {
|
|
1443
|
+
// Predefined tag: "delete" the old (add to deletedTags) and create the new as a user tag.
|
|
1444
|
+
if (!registry.deletedTags.includes(oldTag)) {
|
|
1445
|
+
registry.deletedTags.push(oldTag);
|
|
1446
|
+
}
|
|
1447
|
+
registry.tags.push(newTag);
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
// User tag: replace in the user tags list.
|
|
1451
|
+
registry.tags = registry.tags.map((t) => (t === oldTag) ? newTag : t);
|
|
1452
|
+
}
|
|
1453
|
+
setTagRegistry(registry);
|
|
1454
|
+
// Cascade: substitute the old tag with the new tag across all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
1455
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(oldTag) === true, (tags) => tags.map((t) => (t === oldTag) ? newTag : t));
|
|
1456
|
+
if (error) {
|
|
1457
|
+
res.status(400).json({ error, success: false });
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
LOG.info("Renamed tag '%s' to '%s' across %d channels.", oldTag, newTag, affectedKeys.length);
|
|
1461
|
+
const profiles = getProfiles();
|
|
1462
|
+
res.json({
|
|
1463
|
+
active: getActiveTagVocabulary(),
|
|
1464
|
+
filterContent: generateTagFilterContent(),
|
|
1465
|
+
modalBody: generateTagManagerBody(),
|
|
1466
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1467
|
+
registry: getTagRegistry(),
|
|
1468
|
+
success: true
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
LOG.error("Failed to rename tag: %s.", formatError(error));
|
|
1473
|
+
res.status(500).json({ error: "Failed to rename tag: " + formatError(error), success: false });
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
798
1476
|
}
|
|
799
1477
|
//# sourceMappingURL=routes.js.map
|