prismcast 1.5.2 → 1.7.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 +44 -28
- package/dist/app.d.ts +5 -0
- package/dist/app.js +63 -5
- 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 +31 -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 +31 -54
- package/dist/browser/index.js +334 -272
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +65 -0
- package/dist/browser/login.js +193 -0
- package/dist/browser/login.js.map +1 -0
- package/dist/browser/precaching.js +15 -3
- 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 +14 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +14 -2
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +12 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +24 -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 +35 -5
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +13 -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.d.ts +2 -0
- package/dist/browser/tuning/xfinity.js +14 -0
- package/dist/browser/tuning/xfinity.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.js +15 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +20 -0
- package/dist/browser/video.js +168 -124
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +2 -2
- package/dist/channels/index.js +2075 -683
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +28 -28
- package/dist/config/health.js +72 -54
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +29 -64
- package/dist/config/index.js.map +1 -1
- package/dist/config/paths.d.ts +12 -0
- package/dist/config/paths.js +16 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/profiles.d.ts +3 -2
- package/dist/config/profiles.js +45 -34
- 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 +19 -5
- package/dist/config/providers.js +123 -93
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +28 -0
- package/dist/config/sites.js +104 -127
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +53 -2
- package/dist/config/userChannels.js +222 -13
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -1
- package/dist/config/userConfig.js +6 -15
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +0 -1
- package/dist/config/userProfiles.js +18 -10
- 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/index.js +25 -22
- package/dist/index.js.map +1 -1
- package/dist/native/decrypt.d.ts +32 -0
- package/dist/native/decrypt.js +85 -0
- package/dist/native/decrypt.js.map +1 -0
- package/dist/native/index.d.ts +61 -0
- package/dist/native/index.js +342 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/intercept.d.ts +35 -0
- package/dist/native/intercept.js +184 -0
- package/dist/native/intercept.js.map +1 -0
- package/dist/native/probe.d.ts +49 -0
- package/dist/native/probe.js +294 -0
- package/dist/native/probe.js.map +1 -0
- package/dist/native/proxy.d.ts +54 -0
- package/dist/native/proxy.js +947 -0
- package/dist/native/proxy.js.map +1 -0
- package/dist/routes/components.d.ts +43 -0
- package/dist/routes/components.js +122 -0
- 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 +443 -65
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +76 -0
- package/dist/routes/config/channels/table.js +369 -89
- 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 +73 -99
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/debug.js +5 -5
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/hls.js +12 -2
- package/dist/routes/hls.js.map +1 -1
- package/dist/routes/index.d.ts +0 -15
- package/dist/routes/index.js +2 -16
- package/dist/routes/index.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 +124 -47
- 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 +772 -312
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +596 -544
- 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 +391 -0
- package/dist/routes/root/scripts/shared.js.map +1 -0
- package/dist/routes/root/scripts/status.js +49 -33
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +117 -10
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/service/commands.js +2 -1
- package/dist/service/commands.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +5 -0
- package/dist/streaming/fmp4Segmenter.js +146 -42
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +24 -19
- package/dist/streaming/hls.js +603 -297
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +16 -3
- package/dist/streaming/hlsResume.js +35 -8
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +41 -2
- package/dist/streaming/hlsSegments.js +131 -28
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +27 -13
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -0
- package/dist/streaming/monitor.js +862 -474
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +52 -6
- package/dist/streaming/mp4Parser.js +156 -30
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +2 -2
- package/dist/streaming/mpegts.js +156 -109
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/playlistBuilder.d.ts +38 -0
- package/dist/streaming/playlistBuilder.js +66 -0
- package/dist/streaming/playlistBuilder.js.map +1 -0
- package/dist/streaming/preroll.d.ts +126 -0
- package/dist/streaming/preroll.js +434 -0
- package/dist/streaming/preroll.js.map +1 -0
- package/dist/streaming/pretune.d.ts +8 -0
- package/dist/streaming/pretune.js +226 -0
- package/dist/streaming/pretune.js.map +1 -0
- 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 +57 -9
- package/dist/streaming/registry.js +26 -7
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +8 -1
- package/dist/streaming/setup.js +71 -27
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +29 -4
- package/dist/streaming/showInfo.js +210 -38
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +20 -6
- package/dist/streaming/statusEmitter.js +31 -7
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +32 -0
- package/dist/types/config.d.ts +2 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +13 -1
- package/dist/types/selection.d.ts +5 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -0
- package/dist/utils/chromeFetch.d.ts +20 -0
- package/dist/utils/chromeFetch.js +43 -0
- package/dist/utils/chromeFetch.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +20 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +28 -0
- package/dist/utils/delay.js +35 -1
- 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 +14 -12
- package/dist/utils/ffmpeg.js +114 -134
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +8 -0
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +3 -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/pid.d.ts +40 -0
- package/dist/utils/pid.js +83 -0
- package/dist/utils/pid.js.map +1 -0
- package/dist/utils/retry.d.ts +15 -9
- package/dist/utils/retry.js +7 -23
- 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 +7 -7
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
|
|
2
|
-
import { VALID_OPTIONAL_COLUMNS,
|
|
3
|
-
import { VALID_SORT_FIELDS, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
|
|
2
|
+
import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState } from "./table.js";
|
|
3
|
+
import { VALID_SORT_FIELDS, compareChannelSort, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
|
|
4
4
|
import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.js";
|
|
5
|
-
import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels,
|
|
5
|
+
import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
|
|
6
6
|
import { CONFIG } from "../../../config/index.js";
|
|
7
|
+
import { PREDEFINED_CHANNELS } from "../../../channels/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.
|
|
@@ -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.
|
|
250
|
+
function buildUserChannel(entry, channelName, channelUrl, selector) {
|
|
251
|
+
const channel = {
|
|
252
|
+
channelSelector: selector || undefined,
|
|
253
|
+
name: channelName,
|
|
254
|
+
url: channelUrl
|
|
255
|
+
};
|
|
256
|
+
if (entry.stationId) {
|
|
257
|
+
channel.stationId = sanitizeString(entry.stationId);
|
|
258
|
+
}
|
|
259
|
+
return channel;
|
|
260
|
+
}
|
|
261
|
+
for (const entry of channels) {
|
|
262
|
+
const action = entry.action ?? "add";
|
|
263
|
+
const name = sanitizeString(entry.name?.trim() ?? "");
|
|
264
|
+
const providerSlug = entry.providerSlug?.trim() ?? "";
|
|
265
|
+
// Handle enable and switch. Enable re-enables a disabled predefined channel, then falls through to the switch logic to set the provider selection.
|
|
266
|
+
// Switch changes the provider selection for an existing channel to point to the browsed provider's variant, creating the variant if needed.
|
|
267
|
+
if ((action === "switch") || (action === "enable")) {
|
|
268
|
+
const canonicalKey = entry.canonicalKey?.trim() ?? "";
|
|
269
|
+
if (!canonicalKey) {
|
|
270
|
+
errors.push("Entry for '" + name + "' missing canonical key.");
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Re-enable the channel if it was disabled. Must complete before setting the provider selection since the config write makes the channel
|
|
274
|
+
// visible in the lineup.
|
|
275
|
+
if (action === "enable") {
|
|
276
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential: config state must be updated before setting provider selection.
|
|
277
|
+
await enablePredefinedChannels([canonicalKey]);
|
|
278
|
+
}
|
|
279
|
+
const variantKey = canonicalKey + "-" + providerSlug;
|
|
280
|
+
// If no variant exists for this provider, create one as a user channel.
|
|
281
|
+
if (!allKeys.has(variantKey)) {
|
|
282
|
+
existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""));
|
|
283
|
+
allKeys.add(variantKey);
|
|
284
|
+
channelsChanged = true;
|
|
285
|
+
}
|
|
286
|
+
setProviderSelection(canonicalKey, variantKey);
|
|
287
|
+
selectionsChanged = true;
|
|
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.");
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// Clear the provider selection. resolveProviderKey will now return the canonical or the first enabled alternative variant.
|
|
302
|
+
setProviderSelection(canonicalKey, canonicalKey);
|
|
303
|
+
selectionsChanged = true;
|
|
304
|
+
// Check if the resolved provider is still this provider. If so, there is no alternative — disable or delete the channel.
|
|
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
|
+
}
|
|
316
|
+
}
|
|
317
|
+
removed++;
|
|
318
|
+
affectedKeys.add(canonicalKey);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
// 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
|
|
322
|
+
// existing canonical). Channels that match existing canonicals appear as 'switch' state in the modal instead.
|
|
323
|
+
const url = sanitizeString(entry.url?.trim() ?? "");
|
|
324
|
+
const channelSelector = sanitizeString(entry.channelSelector?.trim() ?? "");
|
|
325
|
+
if (!name) {
|
|
326
|
+
errors.push("Channel entry missing name.");
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!url) {
|
|
330
|
+
errors.push("Channel '" + name + "' missing URL.");
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const urlError = validateChannelUrl(url);
|
|
334
|
+
if (urlError) {
|
|
335
|
+
errors.push("Channel '" + name + "': " + urlError);
|
|
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;
|
|
354
|
+
}
|
|
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,121 @@ 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. For predefined channels, use null to signal removal in the delta. For user channels, use undefined.
|
|
575
|
+
for (const entry of listing) {
|
|
576
|
+
const existing = result.channels[entry.key] ?? {};
|
|
577
|
+
const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
|
|
578
|
+
existing.channelNumber = clearValue;
|
|
579
|
+
result.channels[entry.key] = existing;
|
|
580
|
+
affectedKeys.push(entry.key);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
// Assign sequential numbers starting from the requested start value.
|
|
585
|
+
for (let i = 0; i < listing.length; i++) {
|
|
586
|
+
const entry = listing[i];
|
|
587
|
+
const num = start + i;
|
|
588
|
+
if (num > 99999) {
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
const existing = result.channels[entry.key] ?? {};
|
|
592
|
+
existing.channelNumber = num;
|
|
593
|
+
result.channels[entry.key] = existing;
|
|
594
|
+
affectedKeys.push(entry.key);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
await saveUserChannels(result.channels);
|
|
598
|
+
const action = clearMode ? "Cleared" : "Numbered";
|
|
599
|
+
LOG.info("%s channel numbers for %d channels.", action, affectedKeys.length);
|
|
600
|
+
const profiles = getProfiles();
|
|
601
|
+
const message = clearMode ?
|
|
602
|
+
"Cleared channel numbers from " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT :
|
|
603
|
+
"Numbered " + String(affectedKeys.length) + " channels." + PLAYLIST_HINT;
|
|
604
|
+
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
LOG.error("Failed to auto-number channels: %s.", formatError(error));
|
|
608
|
+
res.status(500).json({ error: "Failed to auto-number channels: " + formatError(error), success: false });
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
// POST /config/channels/hdhr-bulk - Toggle HDHomeRun lineup inclusion for all visible channels. When enable is true, all channels are included (hdhrEnabled
|
|
612
|
+
// cleared). When false, all channels are excluded (hdhrEnabled set to false).
|
|
613
|
+
app.post("/config/channels/hdhr-bulk", async (req, res) => {
|
|
614
|
+
try {
|
|
615
|
+
const body = req.body;
|
|
616
|
+
const enable = body.enable === true;
|
|
617
|
+
// Get visible channels (enabled + available by provider filter).
|
|
618
|
+
const listing = getChannelListing().filter((entry) => entry.enabled && entry.availableByProvider);
|
|
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
|
+
}
|
|
625
|
+
const affectedKeys = [];
|
|
626
|
+
for (const entry of listing) {
|
|
627
|
+
const current = entry.channel.hdhrEnabled !== false;
|
|
628
|
+
// Skip channels that already match the target state.
|
|
629
|
+
if (current === enable) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
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
|
+
affectedKeys.push(entry.key);
|
|
637
|
+
}
|
|
638
|
+
if (affectedKeys.length === 0) {
|
|
639
|
+
res.json({ affected: 0, message: "No changes needed.", success: true });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
await saveUserChannels(result.channels);
|
|
643
|
+
const action = enable ? "included in" : "excluded from";
|
|
644
|
+
LOG.info("Bulk HDHR toggle: %d channels %s HDHR lineup.", affectedKeys.length, action);
|
|
645
|
+
const profiles = getProfiles();
|
|
646
|
+
const message = String(affectedKeys.length) + " channel(s) " + action + " the HDHomeRun lineup.";
|
|
647
|
+
res.json({ affected: affectedKeys.length, message, patch: buildChannelTablePatch(affectedKeys, profiles), success: true });
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
LOG.error("Failed to bulk-toggle HDHR: %s.", formatError(error));
|
|
651
|
+
res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
|
|
652
|
+
}
|
|
653
|
+
});
|
|
342
654
|
// POST /config/provider-filter - Update the provider filter (enabled provider tags).
|
|
343
655
|
app.post("/config/provider-filter", async (req, res) => {
|
|
344
656
|
try {
|
|
@@ -370,13 +682,33 @@ export function setupChannelRoutes(app) {
|
|
|
370
682
|
userConfig.channels.enabledProviders = tags;
|
|
371
683
|
await saveUserConfig(filterDefaults(userConfig));
|
|
372
684
|
LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
|
|
373
|
-
|
|
685
|
+
// Return counts patch so the client can update summary counts after filter change. No rows — the client applies CSS visibility changes itself.
|
|
686
|
+
const { counts, scopeCounts } = buildChannelTableState();
|
|
687
|
+
res.json({ enabledProviders: tags, patch: { counts, rows: [], scopeCounts }, success: true });
|
|
374
688
|
}
|
|
375
689
|
catch (error) {
|
|
376
690
|
LOG.error("Failed to update provider filter: %s.", formatError(error));
|
|
377
691
|
res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
|
|
378
692
|
}
|
|
379
693
|
});
|
|
694
|
+
// POST /config/channels/setup-completed - Mark the Provider Setup flow as completed. Called when the wizard finishes or the user explicitly skips.
|
|
695
|
+
app.post("/config/channels/setup-completed", async (_req, res) => {
|
|
696
|
+
try {
|
|
697
|
+
CONFIG.channels.setupCompleted = true;
|
|
698
|
+
const configResult = await loadUserConfig();
|
|
699
|
+
configResult.config.channels ??= {};
|
|
700
|
+
configResult.config.channels.setupCompleted = true;
|
|
701
|
+
await saveUserConfig(configResult.config);
|
|
702
|
+
// 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 provider filter may have changed — the patch ensures the table reflects the current state.
|
|
704
|
+
const { counts, scopeCounts } = buildChannelTableState();
|
|
705
|
+
res.json({ patch: { counts, rows: [], scopeCounts }, success: true });
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
LOG.error("Failed to save setup completed state: %s.", formatError(error));
|
|
709
|
+
res.status(500).json({ error: "Failed to save setup state: " + formatError(error), success: false });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
380
712
|
// POST /config/channels/display-prefs - Update channel table display preferences (visible columns, sort field, sort direction).
|
|
381
713
|
app.post("/config/channels/display-prefs", async (req, res) => {
|
|
382
714
|
try {
|
|
@@ -545,10 +877,59 @@ export function setupChannelRoutes(app) {
|
|
|
545
877
|
Reflect.deleteProperty(result.channels, key);
|
|
546
878
|
await saveUserChannels(result.channels);
|
|
547
879
|
LOG.info("Channel '%s' reverted to predefined defaults.", key);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
880
|
+
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + revertHint,
|
|
881
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
// 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
|
|
885
|
+
// number, station ID, and HDHR lineup toggles.
|
|
886
|
+
if (action === "inline-edit") {
|
|
887
|
+
if (!key) {
|
|
888
|
+
res.status(400).json({ message: "Channel key is required.", success: false });
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const field = body.field;
|
|
892
|
+
const value = sanitizeString(body.value ?? "");
|
|
893
|
+
if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId")) {
|
|
894
|
+
res.status(400).json({ message: "Invalid inline-edit field.", success: false });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
// Validate channel number if the field is channelNumber.
|
|
898
|
+
if (field === "channelNumber") {
|
|
899
|
+
const numberError = validateChannelNumber(value, key);
|
|
900
|
+
if (numberError) {
|
|
901
|
+
res.status(400).json({ message: numberError, success: false });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// Load and update the channel. For predefined channels, store a delta override with null to clear fields. For user channels, use undefined to delete.
|
|
906
|
+
const result = await loadUserChannels();
|
|
907
|
+
if (result.parseError) {
|
|
908
|
+
res.status(400).json({ message: "Cannot edit: channels file contains invalid JSON.", success: false });
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
// Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet.
|
|
912
|
+
const stored = result.channels[key] ?? {};
|
|
913
|
+
const clearValue = isPredefinedChannel(key) ? null : undefined;
|
|
914
|
+
if (field === "channelNumber") {
|
|
915
|
+
stored.channelNumber = value ? parseInt(value, 10) : clearValue;
|
|
916
|
+
}
|
|
917
|
+
else if (field === "hdhrEnabled") {
|
|
918
|
+
// hdhrEnabled: "true" means enabled (clear the override), "false" means disabled. For predefined channels, null clears the delta. For user channels,
|
|
919
|
+
// delete the field entirely to revert to the default (included).
|
|
920
|
+
stored.hdhrEnabled = (value === "false") ? false : clearValue;
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
stored.stationId = value || clearValue;
|
|
924
|
+
}
|
|
925
|
+
result.channels[key] = stored;
|
|
926
|
+
await saveUserChannels(result.channels);
|
|
927
|
+
const fieldLabels = { channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID" };
|
|
928
|
+
const fieldLabel = fieldLabels[field] ?? field;
|
|
929
|
+
const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
|
|
930
|
+
LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
|
|
931
|
+
const playlistHint = (field === "hdhrEnabled") ? "" : PLAYLIST_HINT;
|
|
932
|
+
res.json({ key, message: fieldLabel + " updated." + playlistHint, patch: buildChannelTablePatch([key], profiles), success: true });
|
|
552
933
|
return;
|
|
553
934
|
}
|
|
554
935
|
// Handle delete action.
|
|
@@ -570,12 +951,10 @@ export function setupChannelRoutes(app) {
|
|
|
570
951
|
Reflect.deleteProperty(result.channels, key);
|
|
571
952
|
await saveUserChannels(result.channels);
|
|
572
953
|
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 });
|
|
954
|
+
// 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
|
+
// the user channel row with the predefined original). Otherwise, the patch includes a "remove" action for the deleted key.
|
|
956
|
+
res.json({ key, message: "Channel '" + key + "' deleted successfully." + PLAYLIST_HINT,
|
|
957
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
579
958
|
return;
|
|
580
959
|
}
|
|
581
960
|
// Handle add and edit actions.
|
|
@@ -597,21 +976,11 @@ export function setupChannelRoutes(app) {
|
|
|
597
976
|
const stationId = sanitizeString(body.stationId ?? "");
|
|
598
977
|
const channelSelector = sanitizeString(body.channelSelector ?? "");
|
|
599
978
|
const channelNumberStr = sanitizeString(body.channelNumber ?? "");
|
|
979
|
+
const hdhrEnabled = body.hdhrEnabled !== "false";
|
|
600
980
|
// 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
|
-
}
|
|
981
|
+
const channelNumberError = validateChannelNumber(channelNumberStr, key);
|
|
982
|
+
if (channelNumberError) {
|
|
983
|
+
formErrors.channelNumber = channelNumberError;
|
|
615
984
|
}
|
|
616
985
|
// Validate key (only for add action, not edit).
|
|
617
986
|
if (action === "add") {
|
|
@@ -657,19 +1026,23 @@ export function setupChannelRoutes(app) {
|
|
|
657
1026
|
const predefinedBase = getPredefinedChannel(key);
|
|
658
1027
|
if ((action === "edit") && predefinedBase) {
|
|
659
1028
|
// 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.
|
|
1029
|
+
// computation, so adding a new form field only requires adding it here. String fields use "" for empty; channelNumber uses undefined; hdhrEnabled uses boolean.
|
|
661
1030
|
const formValues = {
|
|
662
1031
|
channelNumber: channelNumberStr ? parseInt(channelNumberStr, 10) : undefined,
|
|
663
1032
|
channelSelector,
|
|
1033
|
+
hdhrEnabled,
|
|
664
1034
|
name,
|
|
665
1035
|
profile,
|
|
666
1036
|
stationId,
|
|
667
1037
|
url
|
|
668
1038
|
};
|
|
669
1039
|
// 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.
|
|
1040
|
+
// channelNumber stays as number | undefined since the form value uses the same representation. hdhrEnabled defaults to true when absent.
|
|
671
1041
|
const channelValue = (ch, field) => {
|
|
672
1042
|
const val = ch[field];
|
|
1043
|
+
if (field === "hdhrEnabled") {
|
|
1044
|
+
return val !== false;
|
|
1045
|
+
}
|
|
673
1046
|
return (field === "channelNumber") ? val : val ?? "";
|
|
674
1047
|
};
|
|
675
1048
|
// 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
|
|
@@ -702,9 +1075,8 @@ export function setupChannelRoutes(app) {
|
|
|
702
1075
|
Reflect.deleteProperty(result.channels, key);
|
|
703
1076
|
await saveUserChannels(result.channels);
|
|
704
1077
|
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 });
|
|
1078
|
+
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + implicitRevertHint,
|
|
1079
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
708
1080
|
return;
|
|
709
1081
|
}
|
|
710
1082
|
res.json({ key, message: "No changes to save.", success: true });
|
|
@@ -738,9 +1110,8 @@ export function setupChannelRoutes(app) {
|
|
|
738
1110
|
setProviderSelection(key, matchedVariantKey);
|
|
739
1111
|
await saveUserChannels(result.channels);
|
|
740
1112
|
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 });
|
|
1113
|
+
res.json({ key, message: "Channel '" + key + "' reverted to defaults." + variantRevertHint,
|
|
1114
|
+
patch: buildChannelTablePatch([key], profiles), success: true });
|
|
744
1115
|
return;
|
|
745
1116
|
}
|
|
746
1117
|
// 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 +1139,10 @@ export function setupChannelRoutes(app) {
|
|
|
768
1139
|
if (channelNumberStr) {
|
|
769
1140
|
channel.channelNumber = parseInt(channelNumberStr, 10);
|
|
770
1141
|
}
|
|
1142
|
+
// Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
|
|
1143
|
+
if (!hdhrEnabled) {
|
|
1144
|
+
channel.hdhrEnabled = false;
|
|
1145
|
+
}
|
|
771
1146
|
result.channels[key] = channel;
|
|
772
1147
|
// For adds the playlist always changes. For edits, check whether any M3U-relevant field differs from the old stored values.
|
|
773
1148
|
playlistChanged = (action === "add") ||
|
|
@@ -775,18 +1150,21 @@ export function setupChannelRoutes(app) {
|
|
|
775
1150
|
M3U_FIELDS.some((f) => channel[f] !== oldChannel[f]));
|
|
776
1151
|
}
|
|
777
1152
|
await saveUserChannels(result.channels);
|
|
1153
|
+
// Trigger a logo lookup for the new or updated station ID. Fire-and-forget - the logo appears on the next page load.
|
|
1154
|
+
if (stationId) {
|
|
1155
|
+
updateChannelLogo(name || key, stationId);
|
|
1156
|
+
}
|
|
778
1157
|
const actionLabel = (action === "add") ? "added" : "updated";
|
|
779
1158
|
LOG.info("User channel '%s' %s.", key, actionLabel);
|
|
780
1159
|
// 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
1160
|
// Append a playlist reload hint when the change affects M3U content that Channels DVR consumes.
|
|
783
1161
|
const playlistHint = playlistChanged ? PLAYLIST_HINT : "";
|
|
784
|
-
// Return success response with
|
|
1162
|
+
// Return success response with patch for client-side DOM update. Changes take effect immediately due to hot-reloading in saveUserChannels().
|
|
785
1163
|
res.json({
|
|
786
|
-
html: { displayRow: rowHtml.displayRow, editRow: rowHtml.editRow },
|
|
787
1164
|
isNew: action === "add",
|
|
788
1165
|
key,
|
|
789
1166
|
message: "Channel '" + key + "' " + actionLabel + " successfully." + playlistHint,
|
|
1167
|
+
patch: buildChannelTablePatch([key], profiles),
|
|
790
1168
|
success: true
|
|
791
1169
|
});
|
|
792
1170
|
}
|