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