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
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
*
|
|
3
3
|
* userChannels.ts: User channel file management for PrismCast.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { CHANNEL_BINDING_KEYS, CHANNEL_IDENTITY_KEYS, DELTA_ELIGIBLE_BINDING_KEYS, DELTA_ELIGIBLE_IDENTITY_KEYS } from "../types/index.js";
|
|
6
6
|
import { FileStoreParseError, createFileStore } from "./persistence.js";
|
|
7
7
|
import { LOG, containsNonPrintable, sanitizeString } from "../utils/index.js";
|
|
8
|
-
import {
|
|
8
|
+
import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../channels/index.js";
|
|
9
|
+
import { buildServiceGroups, getAllServiceTags, getResolvedChannel, isChannelAvailableByService, isServiceVariant, resolveServiceKey, setEnabledServices, setServiceSelections } from "./services.js";
|
|
9
10
|
import { CONFIG } from "./index.js";
|
|
10
11
|
import fs from "node:fs";
|
|
11
12
|
import { getChannelsFilePath } from "./paths.js";
|
|
13
|
+
import { isDeepStrictEqual } from "node:util";
|
|
12
14
|
import { mutateConfig } from "./userConfig.js";
|
|
13
15
|
const { promises: fsPromises } = fs;
|
|
14
16
|
/* The channels file path is resolved via the centralized paths module (config/paths.ts). The data directory is initialized at startup before channel loading.
|
|
@@ -43,9 +45,27 @@ export function hasChannelsParseError() {
|
|
|
43
45
|
export function getChannelsParseErrorMessage() {
|
|
44
46
|
return userChannelsParseErrorMessage;
|
|
45
47
|
}
|
|
48
|
+
/* Transactional store for channels.json. The store uses a compound type that carries channel data alongside metadata (serviceSelections, tagRegistry). The parse
|
|
49
|
+
* function extracts all three from the JSON file. The mutateChannels() wrapper normalizes predefined channel deltas before the write and adapts the compound
|
|
50
|
+
* type so callers work directly with StoredChannelMap. The beforeWrite hook injects metadata from module state.
|
|
51
|
+
*/
|
|
52
|
+
/* Current schema version for channels.json. Migrations are declared in channelsMigrations below; the framework runs them in order from the file's stored
|
|
53
|
+
* version up to this constant, stamps the new version after each, and records the audit trail in migrationsApplied.
|
|
54
|
+
*
|
|
55
|
+
* Version history:
|
|
56
|
+
* 1 - Pre-delta-model user variants. Identity fields stored redundantly on variants and stripped eagerly at load time.
|
|
57
|
+
* 2 - Delta-model user variants. Variants store only fields that differ from their canonical. The v1 -> v2 migration converts legacy-shaped variants by
|
|
58
|
+
* stamping canonicalKey on entries whose identity fields match the canonical (shape-compatible with a legitimate variant) and skipping entries whose
|
|
59
|
+
* fields differ (user standalones that happened to share a hyphenated key with a predefined canonical).
|
|
60
|
+
* 3 - Foxone-era service naming. The v2 -> v3 migration renames "foxcom" -> "foxone" in service selections and channel keys, and the special-case
|
|
61
|
+
* "fox-site" -> "fox-foxone" rename for the FoxOne variant briefly mis-keyed in v1.8.0. Bumping this version also flushes parser-side legacy field
|
|
62
|
+
* cleanups (provider -> service field, providerSelections -> serviceSelections key) to disk on the same write that lands the foxone rename.
|
|
63
|
+
*/
|
|
64
|
+
const CURRENT_CHANNELS_SCHEMA_VERSION = 3;
|
|
46
65
|
/**
|
|
47
|
-
* Parses raw channels.json content into the compound data type. Extracts channel entries, service selections,
|
|
48
|
-
*
|
|
66
|
+
* Parses raw channels.json content into the compound data type. Extracts channel entries, service selections, tag registry, and framework metadata
|
|
67
|
+
* (schemaVersion, migrationsApplied) from the top-level JSON object. Also performs structural normalization for legacy field/key naming so consumer code only
|
|
68
|
+
* sees the current shape; the schemaVersion progression governs when the cleanup is persisted to disk via the migration framework.
|
|
49
69
|
* @param raw - The raw JSON string from the file.
|
|
50
70
|
* @returns The parsed compound data.
|
|
51
71
|
*/
|
|
@@ -54,9 +74,29 @@ function parseChannelsFile(raw) {
|
|
|
54
74
|
const channels = {};
|
|
55
75
|
const serviceSelections = {};
|
|
56
76
|
const tagRegistry = { deletedTags: [], tags: [] };
|
|
77
|
+
const migrationsApplied = [];
|
|
78
|
+
// Files predating the schemaVersion field are treated as version 1. The migration runner in the file store framework upgrades them to the current version.
|
|
79
|
+
let schemaVersion = 1;
|
|
57
80
|
for (const [key, value] of Object.entries(parsed)) {
|
|
58
|
-
if (
|
|
59
|
-
//
|
|
81
|
+
if (key === "schemaVersion") {
|
|
82
|
+
// Tolerate bad data by falling back to version 1 - any plausibly-legacy value is fine, since migrations are idempotent when no work is needed.
|
|
83
|
+
if ((typeof value === "number") && Number.isFinite(value) && (value >= 1)) {
|
|
84
|
+
schemaVersion = Math.floor(value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (key === "migrationsApplied") {
|
|
88
|
+
// Audit trail of migrations that have already been applied to this file. Strings only; non-string entries are dropped silently.
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
for (const entry of value) {
|
|
91
|
+
if (typeof entry === "string") {
|
|
92
|
+
migrationsApplied.push(entry);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if ((key === "serviceSelections") || (key === "providerSelections")) {
|
|
98
|
+
// Copy service selections if it is an object. Accepts the legacy "providerSelections" key for structural compatibility - the v2 -> v3 schema migration
|
|
99
|
+
// ensures the file is rewritten under the current key on next persist.
|
|
60
100
|
if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
|
|
61
101
|
for (const [selKey, selValue] of Object.entries(value)) {
|
|
62
102
|
if (typeof selValue === "string") {
|
|
@@ -69,84 +109,333 @@ function parseChannelsFile(raw) {
|
|
|
69
109
|
// Extract tag registry with defensive validation. String array fields (tags, deletedTags) drop non-string elements silently.
|
|
70
110
|
if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
|
|
71
111
|
const rawRegistry = value;
|
|
72
|
-
if (Array.isArray(rawRegistry
|
|
73
|
-
tagRegistry.tags = rawRegistry
|
|
112
|
+
if (Array.isArray(rawRegistry["tags"])) {
|
|
113
|
+
tagRegistry.tags = rawRegistry["tags"].filter((t) => typeof t === "string").sort();
|
|
74
114
|
}
|
|
75
|
-
if (Array.isArray(rawRegistry
|
|
76
|
-
tagRegistry.deletedTags = rawRegistry
|
|
115
|
+
if (Array.isArray(rawRegistry["deletedTags"])) {
|
|
116
|
+
tagRegistry.deletedTags = rawRegistry["deletedTags"].filter((t) => typeof t === "string").sort();
|
|
77
117
|
}
|
|
78
118
|
}
|
|
79
119
|
}
|
|
80
120
|
else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
|
|
81
|
-
// It
|
|
121
|
+
// It is a channel definition or delta override.
|
|
82
122
|
channels[key] = value;
|
|
83
123
|
}
|
|
84
124
|
}
|
|
85
|
-
//
|
|
86
|
-
// dropdown
|
|
125
|
+
// Structural normalization: rename legacy "provider" field to "service" on channel entries. The old field was used as a display name override for the
|
|
126
|
+
// service selection dropdown; new installs always write "service". The schema migration to v3 stamps the version after this normalization is in memory,
|
|
127
|
+
// ensuring the persist on next write drops the legacy field from disk.
|
|
87
128
|
for (const channel of Object.values(channels)) {
|
|
88
129
|
const legacy = channel;
|
|
89
|
-
if (
|
|
90
|
-
|
|
130
|
+
if ("provider" in legacy) {
|
|
131
|
+
if (!("service" in legacy)) {
|
|
132
|
+
legacy["service"] = legacy["provider"];
|
|
133
|
+
}
|
|
134
|
+
Reflect.deleteProperty(legacy, "provider");
|
|
91
135
|
}
|
|
92
|
-
Reflect.deleteProperty(legacy, "provider");
|
|
93
136
|
}
|
|
94
|
-
return { channels, serviceSelections, tagRegistry };
|
|
137
|
+
return { channels, migrationsApplied, schemaVersion, serviceSelections, tagRegistry };
|
|
95
138
|
}
|
|
139
|
+
/* Array-valued ChannelDelta fields whose equality requires canonical case-insensitive ordering before JSON.stringify. The set declares which array fields are
|
|
140
|
+
* unordered-set semantics rather than order-significant lists; equality for these fields canonical-sorts both sides so authoring-order or case differences do
|
|
141
|
+
* not defeat the match. Tags are the only such field today, but the property-based framing is the architectural truth: any unordered-set array field belongs in
|
|
142
|
+
* this set. The `satisfies` constraint keeps the list in sync with ChannelDelta's actual keys at compile time - renaming or removing a field forces this tuple
|
|
143
|
+
* to be updated.
|
|
144
|
+
*/
|
|
145
|
+
const CANONICAL_SORTED_ARRAY_FIELDS = new Set(["tags"]);
|
|
96
146
|
/**
|
|
97
|
-
* Normalizes
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* @
|
|
147
|
+
* Normalizes a single stored entry as a delta against a base channel. Delta fields are dropped when they are no-ops: undefined values, nulls against a base
|
|
148
|
+
* that has no such field, and values that match the base exactly. Array-valued fields use JSON.stringify for equality with case-insensitive canonical sorting
|
|
149
|
+
* for the fields listed in CANONICAL_SORTED_ARRAY_FIELDS so authoring-order differences do not defeat the match. Non-delta fields (e.g., canonicalKey) pass
|
|
150
|
+
* through unchanged so the stored entry retains its relationship metadata.
|
|
151
|
+
* @param stored - The raw stored entry.
|
|
152
|
+
* @param base - The base Channel to diff against (predefined definition for overrides, resolved canonical for variants).
|
|
153
|
+
* @returns The normalized entry, or null when every field is a no-op and the entry would carry no information.
|
|
102
154
|
*/
|
|
103
|
-
function
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (value === undefined) {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
// Skip null values when the predefined doesn't have the field - clearing a nonexistent field is a no-op.
|
|
120
|
-
if ((value === null) && !(field in predefined)) {
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
// Skip values that match the predefined exactly - redundant copies. Array fields (like tags) use JSON.stringify for comparison since reference equality
|
|
124
|
-
// always fails for arrays. Tags arrays are always sorted and lowercase, making JSON.stringify deterministic.
|
|
125
|
-
if (value !== null) {
|
|
126
|
-
const predefinedValue = predefined[field];
|
|
127
|
-
if (Array.isArray(value) ? (JSON.stringify(value) === JSON.stringify(predefinedValue)) : (predefinedValue === value)) {
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
155
|
+
function normalizeEntryAgainstBase(stored, base) {
|
|
156
|
+
const cleaned = {};
|
|
157
|
+
let hasFields = false;
|
|
158
|
+
for (const [field, value] of Object.entries(stored)) {
|
|
159
|
+
// Non-delta fields (like canonicalKey) pass through so the stored entry keeps its relationship metadata - except when the base already declares the same
|
|
160
|
+
// value. For example, a user override of a predefined variant doesn't need to store canonicalKey because the predefined variant entry already declares it;
|
|
161
|
+
// stripping the redundant copy keeps the stored record minimal and lets an empty override collapse to nothing.
|
|
162
|
+
if (!DELTA_ALLOWED_FIELDS.has(field)) {
|
|
163
|
+
if (value === undefined) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const baseValue = base[field];
|
|
167
|
+
if (baseValue !== value) {
|
|
131
168
|
cleaned[field] = value;
|
|
132
169
|
hasFields = true;
|
|
133
170
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// Undefined delta values are no-ops.
|
|
174
|
+
if (value === undefined) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Null means "clear this field on the base." When the base does not have the field, the clear is a no-op and gets dropped. Otherwise null is preserved so
|
|
178
|
+
// the clear survives.
|
|
179
|
+
if (value === null) {
|
|
180
|
+
if (field in base) {
|
|
181
|
+
cleaned[field] = null;
|
|
182
|
+
hasFields = true;
|
|
140
183
|
}
|
|
141
|
-
|
|
142
|
-
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Values that match the base exactly are redundant. Arrays compare by JSON.stringify; fields in CANONICAL_SORTED_ARRAY_FIELDS are sorted on both sides
|
|
187
|
+
// first so the equality check is order-independent.
|
|
188
|
+
const baseValue = base[field];
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
const canonical = CANONICAL_SORTED_ARRAY_FIELDS.has(field);
|
|
191
|
+
const left = canonical ? sortTags(value) : value;
|
|
192
|
+
const right = canonical ? sortTags((baseValue ?? [])) : baseValue;
|
|
193
|
+
if (isDeepStrictEqual(left, right)) {
|
|
194
|
+
continue;
|
|
143
195
|
}
|
|
144
196
|
}
|
|
197
|
+
else if (baseValue === value) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
cleaned[field] = value;
|
|
201
|
+
hasFields = true;
|
|
202
|
+
}
|
|
203
|
+
return hasFields ? (cleaned) : null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Classifies a channel entry by consulting both the predefined catalog and the user's stored map. The kind determines how the entry is resolved, normalized, and
|
|
207
|
+
* displayed.
|
|
208
|
+
*
|
|
209
|
+
* Precondition: at least one of the predefined catalog or the stored map must have the key. Callers iterate over the union of key sets and therefore always
|
|
210
|
+
* satisfy this. Violating the precondition throws rather than returning a nullable - the alternative is a defensive undefined check at every call site for a
|
|
211
|
+
* case that never occurs, which is both dead code and a misleading contract.
|
|
212
|
+
* @param key - The channel key.
|
|
213
|
+
* @param stored - The stored entry from the user's channels map (if any).
|
|
214
|
+
* @returns The classification. Never undefined; see the precondition.
|
|
215
|
+
* @throws Error when neither PREDEFINED_CHANNELS nor the caller's stored map contains the key.
|
|
216
|
+
*/
|
|
217
|
+
function classifyEntry(key, stored) {
|
|
218
|
+
const predefined = PREDEFINED_CHANNELS[key];
|
|
219
|
+
const userCanonicalKey = stored ? stored.canonicalKey : undefined;
|
|
220
|
+
const canonicalKey = userCanonicalKey ?? predefined?.canonicalKey;
|
|
221
|
+
// Variant: either source declares canonicalKey pointing at a different entry.
|
|
222
|
+
if (canonicalKey && (canonicalKey !== key)) {
|
|
223
|
+
return { canonicalKey, kind: "variant", predefined, stored };
|
|
224
|
+
}
|
|
225
|
+
// Canonical: a predefined entry exists (optionally with a user override on top).
|
|
226
|
+
if (predefined) {
|
|
227
|
+
return { kind: "canonical", predefined, stored };
|
|
228
|
+
}
|
|
229
|
+
// Standalone: user-only entry. Stored must be defined here; predefined is falsy (else canonical branch caught) and the neither-side-present state violates
|
|
230
|
+
// the precondition.
|
|
231
|
+
if (stored) {
|
|
232
|
+
return { kind: "standalone", stored };
|
|
233
|
+
}
|
|
234
|
+
throw new Error("classifyEntry called with key '" + key + "' that exists in neither PREDEFINED_CHANNELS nor the stored map.");
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Computes the field-name allowlist for a stored entry's expected shape. Stored entries fall into two structural shapes determined by classification:
|
|
238
|
+
*
|
|
239
|
+
* - Canonical-shaped (kind = canonical or standalone): allowed fields are DELTA_ELIGIBLE_IDENTITY_KEYS ∪ DELTA_ELIGIBLE_BINDING_KEYS. The full delta surface.
|
|
240
|
+
* - Variant-shaped (kind = variant): allowed fields are DELTA_ELIGIBLE_BINDING_KEYS plus the structural canonicalKey discriminator. Identity inherits from the
|
|
241
|
+
* canonical and must not appear on variants per the architectural principle.
|
|
242
|
+
*
|
|
243
|
+
* Single source of truth for the shape rule. Used by filterToDeltaSurface (normalizer pre-strip) and by the customization accessor's per-pass walks. Routes
|
|
244
|
+
* shape determination through classifyEntry so any caller automatically picks up classifier improvements - no parallel logic for "is this a variant?".
|
|
245
|
+
* @param key - The channel key for classification.
|
|
246
|
+
* @param stored - The stored entry for classification.
|
|
247
|
+
* @returns The set of field names allowed for this entry's shape.
|
|
248
|
+
*/
|
|
249
|
+
function getAllowedFieldsForShape(key, stored) {
|
|
250
|
+
const isVariant = classifyEntry(key, stored).kind === "variant";
|
|
251
|
+
if (isVariant) {
|
|
252
|
+
return new Set([...DELTA_ELIGIBLE_BINDING_KEYS, "canonicalKey"]);
|
|
253
|
+
}
|
|
254
|
+
return new Set([...DELTA_ELIGIBLE_IDENTITY_KEYS, ...DELTA_ELIGIBLE_BINDING_KEYS]);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Filters a stored entry to its expected delta surface, stripping any field outside the allowed shape. Returns both the filtered entry and the names of any
|
|
258
|
+
* fields that were stripped, so callers can surface diagnostics.
|
|
259
|
+
*
|
|
260
|
+
* The shape rule is enforced here at storage-write time (called from normalizeChannelDeltas) so legacy data with orphan fields - non-delta-eligible identity
|
|
261
|
+
* (pacificStationId), non-delta-eligible binding (DOM hooks), or identity-on-variants - cleans up automatically on next save. The `stripped` array lets the
|
|
262
|
+
* caller log a warning when cleanup happens, giving operators visibility into the violation.
|
|
263
|
+
* @param key - The channel key for shape classification.
|
|
264
|
+
* @param stored - The stored entry to filter.
|
|
265
|
+
* @returns The filtered entry plus the list of stripped field names.
|
|
266
|
+
*/
|
|
267
|
+
function filterToDeltaSurface(key, stored) {
|
|
268
|
+
const allowed = getAllowedFieldsForShape(key, stored);
|
|
269
|
+
const filtered = {};
|
|
270
|
+
const stripped = [];
|
|
271
|
+
for (const [field, value] of Object.entries(stored)) {
|
|
272
|
+
if (allowed.has(field)) {
|
|
273
|
+
filtered[field] = value;
|
|
274
|
+
}
|
|
145
275
|
else {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
276
|
+
stripped.push(field);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { filtered, stripped };
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Resolves a variant by layering canonical identity, the predefined variant entry (service binding), and the user delta in order. The canonical contributes
|
|
283
|
+
* ONLY identity (name, stationId, channelNumber, tags, etc.) - its service binding is for the canonical service and would be structurally wrong for any other
|
|
284
|
+
* service variant. The variant's binding is supplied by the predefined variant entry (typically: discovery-driven channelSelector, service-specific URL,
|
|
285
|
+
* profile overrides) and the user's stored delta.
|
|
286
|
+
*
|
|
287
|
+
* Fields propagate through the delta-overlay kernel so null-means-clear and undefined-means-inherit semantics apply uniformly at every layer.
|
|
288
|
+
*
|
|
289
|
+
* Precondition: at least one of predefined or stored must be defined. A variant with no binding source has nothing to resolve; the result would have no URL
|
|
290
|
+
* and no selector, and downstream consumers would fail at the consumption site. classifyEntry only returns a "variant" classification when at least one
|
|
291
|
+
* source carries canonicalKey, so the precondition is satisfied by the only in-tree caller (getMergedChannelMap). Other callers must guarantee the same.
|
|
292
|
+
* @param canonical - The resolved canonical channel. Variant identity inherits from here; canonical binding is intentionally stripped via pickIdentity.
|
|
293
|
+
* @param predefined - The predefined variant entry, if the key has one. Contributes service-binding fields and any per-variant channelNumber override.
|
|
294
|
+
* @param stored - The user's stored delta for this variant, if any. Applied last so user edits win over predefined variant fields.
|
|
295
|
+
* @returns The fully resolved variant.
|
|
296
|
+
*/
|
|
297
|
+
function resolveVariant(canonical, predefined, stored) {
|
|
298
|
+
// Identity-only base. The url field (required on ResolvedChannel) is supplied by the predefined variant or stored delta below; the cast acknowledges that
|
|
299
|
+
// intermediate state. After the chain completes, the result has url populated when at least one of the binding-bearing layers (predefined or stored) is
|
|
300
|
+
// present - which is the documented invariant for any well-formed variant entry.
|
|
301
|
+
let resolved = pickIdentity(canonical);
|
|
302
|
+
// Variant overlays apply binding fields only - identity stays from the canonical per the architectural principle that variants are pure tuning data. Both
|
|
303
|
+
// the predefined variant and the user's stored variant entry use the same binding-only overlay.
|
|
304
|
+
if (predefined) {
|
|
305
|
+
resolved = overlayVariantBinding(resolved, predefined);
|
|
306
|
+
}
|
|
307
|
+
if (stored) {
|
|
308
|
+
resolved = overlayVariantBinding(resolved, stored);
|
|
309
|
+
}
|
|
310
|
+
return resolved;
|
|
311
|
+
}
|
|
312
|
+
/* Dangling-canonical warning dedup. When a variant's canonicalKey points at a missing canonical, getMergedChannelMap preserves the variant as best it can but
|
|
313
|
+
* the user deserves a diagnostic - their data references something that is not there, and silently carrying on would hide a real data-integrity issue. We log
|
|
314
|
+
* once per (variant, canonical) pair to surface the problem without spamming the log on every mutation. When the canonical later reappears (user re-creates
|
|
315
|
+
* it, predefined gets restored), the pair is cleared from the set so a future regression re-warns.
|
|
316
|
+
*/
|
|
317
|
+
const warnedDanglingVariants = new Set();
|
|
318
|
+
/**
|
|
319
|
+
* Composes a stable token identifying a dangling-variant relationship for the warning dedup set.
|
|
320
|
+
* @param variantKey - The variant channel key.
|
|
321
|
+
* @param canonicalKey - The canonical key the variant references.
|
|
322
|
+
* @returns The composite token.
|
|
323
|
+
*/
|
|
324
|
+
function danglingToken(variantKey, canonicalKey) {
|
|
325
|
+
return variantKey + " -> " + canonicalKey;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Emits a dangling-canonical warning at most once per (variant, canonical) pair for the lifetime of this process. Called from getMergedChannelMap whenever a
|
|
329
|
+
* variant references a canonical that is not present in the resolved map.
|
|
330
|
+
* @param variantKey - The variant key whose canonicalKey points at a missing canonical.
|
|
331
|
+
* @param canonicalKey - The missing canonical key.
|
|
332
|
+
*/
|
|
333
|
+
function warnDanglingCanonical(variantKey, canonicalKey) {
|
|
334
|
+
const token = danglingToken(variantKey, canonicalKey);
|
|
335
|
+
if (warnedDanglingVariants.has(token)) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
warnedDanglingVariants.add(token);
|
|
339
|
+
LOG.warn("Channel variant '%s' references missing canonical '%s'. Preserving the variant as-is; restore the canonical or remove the variant to clear this.", variantKey, canonicalKey);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Builds the Pass 1 resolved-canonical map for a given stored map. Iterates every key present in either the predefined catalog or the stored map, resolves
|
|
343
|
+
* non-variant entries (canonicals and standalones), and returns the resulting ChannelMap. Variants are deliberately skipped - they depend on this map and are
|
|
344
|
+
* resolved in Pass 2 by the caller.
|
|
345
|
+
*
|
|
346
|
+
* This is shared by getMergedChannelMap (which uses the map directly as the starting point for its full resolution) and normalizeChannelDeltas (which uses the
|
|
347
|
+
* map as a source of bases for diffing variant deltas).
|
|
348
|
+
* @param stored - The stored channels map.
|
|
349
|
+
* @returns The resolved-canonical map, keyed by channel key.
|
|
350
|
+
*/
|
|
351
|
+
function buildResolvedCanonicals(stored) {
|
|
352
|
+
const result = {};
|
|
353
|
+
const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(stored)]);
|
|
354
|
+
for (const key of allKeys) {
|
|
355
|
+
const classification = classifyEntry(key, stored[key]);
|
|
356
|
+
if (classification.kind === "variant") {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (classification.kind === "canonical") {
|
|
360
|
+
result[key] = classification.stored ? overlayDelta(classification.predefined, classification.stored) : classification.predefined;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// Standalone: defensive copy so downstream mutation cannot leak back into loadedUserChannels. A standalone is structurally equivalent to a CanonicalChannel
|
|
364
|
+
// (identity + binding, no canonicalKey).
|
|
365
|
+
const copy = { ...classification.stored };
|
|
366
|
+
copy.tags &&= copy.tags.slice();
|
|
367
|
+
result[key] = copy;
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Strips null fields from a stored entry. Null is a delta-only convention meaning "clear this field on the base"; without a base to clear against, nulls
|
|
373
|
+
* have no meaning and get dropped. Used by normalizeChannelDeltas for standalone user channels and for dangling-canonical variants that fall back to
|
|
374
|
+
* standalone-style normalization.
|
|
375
|
+
* @param stored - The raw stored entry.
|
|
376
|
+
* @returns The entry with null-valued fields removed.
|
|
377
|
+
*/
|
|
378
|
+
function stripNulls(stored) {
|
|
379
|
+
return Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Normalizes the stored channels map to its minimal delta form. Each entry is classified, diffed against its applicable base, and any no-op fields are
|
|
383
|
+
* stripped. Standalone user channels have no base, so nulls are stripped but everything else is preserved.
|
|
384
|
+
*
|
|
385
|
+
* The flow is uniform across kinds:
|
|
386
|
+
*
|
|
387
|
+
* - Canonical (predefined with user override): base is the predefined channel.
|
|
388
|
+
* - Variant: base is the resolved canonical layered with the predefined variant's service fields (the view the user sees in the form/table, so the delta
|
|
389
|
+
* records only what differs from that).
|
|
390
|
+
* - Standalone: no base, null values are stripped as a storage convention.
|
|
391
|
+
*
|
|
392
|
+
* Entries whose normalized delta is empty collapse to nothing and get dropped - a redundant override carries no information, and resolution falls through to
|
|
393
|
+
* the default.
|
|
394
|
+
* @param channels - The raw channel entries to normalize.
|
|
395
|
+
* @returns The normalized channel map.
|
|
396
|
+
*/
|
|
397
|
+
function normalizeChannelDeltas(channels) {
|
|
398
|
+
const filtered = {};
|
|
399
|
+
const resolvedCanonicals = buildResolvedCanonicals(channels);
|
|
400
|
+
for (const [key, stored] of Object.entries(channels)) {
|
|
401
|
+
// Step 1: enforce the expected stored shape via filterToDeltaSurface. Strips orphan fields (non-delta-eligible identity, non-delta-eligible binding,
|
|
402
|
+
// identity-on-variants) before any diff computation. When fields are stripped, log a warning so operators see when their data is being cleaned. The shape
|
|
403
|
+
// rule is the same regardless of classification kind - canonicalKey-discriminated through filterToDeltaSurface internally.
|
|
404
|
+
const { filtered: shapeFiltered, stripped } = filterToDeltaSurface(key, stored);
|
|
405
|
+
if (stripped.length > 0) {
|
|
406
|
+
const isVariant = classifyEntry(key, stored).kind === "variant";
|
|
407
|
+
LOG.warn("Channel '%s' (%s-shaped) carried fields outside the delta surface: [%s]. Stripped during normalization.", key, isVariant ? "variant" : "canonical", stripped.join(", "));
|
|
408
|
+
}
|
|
409
|
+
// Step 2: classification-driven delta minimization against the appropriate base.
|
|
410
|
+
const classification = classifyEntry(key, shapeFiltered);
|
|
411
|
+
if (classification.kind === "canonical") {
|
|
412
|
+
const normalized = normalizeEntryAgainstBase(shapeFiltered, classification.predefined);
|
|
413
|
+
if (normalized) {
|
|
414
|
+
filtered[key] = normalized;
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (classification.kind === "standalone") {
|
|
419
|
+
filtered[key] = stripNulls(shapeFiltered);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
// Variant. The normalization base is identity-from-canonical layered with the predefined variant's binding (if any) - the same shape the user sees in the
|
|
423
|
+
// form/table, so the delta records only what differs from that. Critically, this matches the inheritance shape used by resolveVariant: identity from
|
|
424
|
+
// canonical, binding from variant. Without the pickIdentity strip, the canonical service's binding would leak into the normalization base and stored
|
|
425
|
+
// overrides matching the canonical's binding would be incorrectly stripped as no-ops. A dangling canonical falls back to standalone-style normalization so
|
|
426
|
+
// the user does not silently lose data.
|
|
427
|
+
const canonical = resolvedCanonicals[classification.canonicalKey];
|
|
428
|
+
if (!canonical) {
|
|
429
|
+
filtered[key] = stripNulls(shapeFiltered);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const identityBase = pickIdentity(canonical);
|
|
433
|
+
// Mirror the resolveVariant chain: predefined-variant overlay applies binding fields only. The base computed here is what a stored variant entry should
|
|
434
|
+
// be diffed against - using a category-aware overlay keeps normalization symmetric with resolution.
|
|
435
|
+
const base = classification.predefined ? overlayVariantBinding(identityBase, classification.predefined) : identityBase;
|
|
436
|
+
const normalized = normalizeEntryAgainstBase(shapeFiltered, base);
|
|
437
|
+
if (normalized) {
|
|
438
|
+
filtered[key] = normalized;
|
|
150
439
|
}
|
|
151
440
|
}
|
|
152
441
|
return filtered;
|
|
@@ -159,24 +448,133 @@ function normalizeChannelDeltas(channels) {
|
|
|
159
448
|
* @returns The serializable output with channels and current metadata.
|
|
160
449
|
*/
|
|
161
450
|
function prepareChannelsForWrite(data) {
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
451
|
+
// Pure function of data. The framework guarantees data is the source of truth at write time - serviceSelections and tagRegistry come from data, never from
|
|
452
|
+
// module state. Module-state caches in services.ts and userChannels.ts are downstream of this writer (hydrated post-mutate from the same data), so reading
|
|
453
|
+
// them here would be a side channel that breaks correctness when the boot persist runs before module state is populated.
|
|
454
|
+
const output = { schemaVersion: data.schemaVersion, ...data.channels };
|
|
455
|
+
if (data.migrationsApplied.length > 0) {
|
|
456
|
+
output["migrationsApplied"] = data.migrationsApplied;
|
|
167
457
|
}
|
|
168
|
-
if ((
|
|
169
|
-
output
|
|
458
|
+
if (Object.keys(data.serviceSelections).length > 0) {
|
|
459
|
+
output["serviceSelections"] = data.serviceSelections;
|
|
460
|
+
}
|
|
461
|
+
if ((data.tagRegistry.tags.length > 0) || (data.tagRegistry.deletedTags.length > 0)) {
|
|
462
|
+
output["tagRegistry"] = data.tagRegistry;
|
|
170
463
|
}
|
|
171
464
|
return output;
|
|
172
465
|
}
|
|
466
|
+
/* Declarative schema migrations. The file store framework runs these in order from the file's stored schemaVersion up to CURRENT_CHANNELS_SCHEMA_VERSION,
|
|
467
|
+
* stamping the new version and recording the description in migrationsApplied after each application. Apply functions mutate the data in place. Any new
|
|
468
|
+
* migration is a single entry below; the framework wires the rest.
|
|
469
|
+
*/
|
|
470
|
+
const channelsMigrations = {
|
|
471
|
+
2: {
|
|
472
|
+
apply: (data) => {
|
|
473
|
+
// Stamp canonicalKey on hyphenated user channel entries whose identity matches a predefined canonical (shape-compatible variants). Conservative
|
|
474
|
+
// classifier - entries whose identity diverges are treated as user standalones and left alone.
|
|
475
|
+
const stampedKeys = collectLegacyVariantStamps(data.channels);
|
|
476
|
+
for (const key of stampedKeys) {
|
|
477
|
+
const channel = data.channels[key];
|
|
478
|
+
if (!channel) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const hyphenIndex = key.indexOf("-");
|
|
482
|
+
channel.canonicalKey = key.substring(0, hyphenIndex);
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
description: "Stamp canonicalKey on legacy user channel variant entries"
|
|
486
|
+
},
|
|
487
|
+
3: {
|
|
488
|
+
apply: (data) => {
|
|
489
|
+
// Rename foxcom -> foxone in service selections, the special-case fox-site -> fox-foxone, and corresponding channel keys. Bumping to v3 also flushes
|
|
490
|
+
// any parser-side legacy field cleanups (provider -> service field, providerSelections -> serviceSelections key) since the migration runner triggers a
|
|
491
|
+
// persist that writes the cleaned shape via prepareChannelsForWrite.
|
|
492
|
+
for (const [canonicalKey, selectedVariant] of Object.entries(data.serviceSelections)) {
|
|
493
|
+
if (selectedVariant.endsWith("-foxcom")) {
|
|
494
|
+
data.serviceSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
|
|
495
|
+
}
|
|
496
|
+
if ((canonicalKey === "fox") && (selectedVariant === "fox-site")) {
|
|
497
|
+
data.serviceSelections[canonicalKey] = "fox-foxone";
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const [key, value] of Object.entries(data.channels)) {
|
|
501
|
+
if (key.endsWith("-foxcom")) {
|
|
502
|
+
data.channels[key.slice(0, -6) + "foxone"] = value;
|
|
503
|
+
Reflect.deleteProperty(data.channels, key);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
description: "Rename foxcom service references to foxone and persist legacy field name cleanup"
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
/**
|
|
511
|
+
* Pre-write integrity validator for the channels store. Detects two classes of suspicious mutations:
|
|
512
|
+
*
|
|
513
|
+
* 1. Identity-field loss: a stored channel's identity field went from set to undefined without canonical fallback. Catches code that
|
|
514
|
+
* accidentally drops user-authored channel data while normalizing or transforming entries.
|
|
515
|
+
* 2. Metadata wholesale clear: a top-level metadata collection (serviceSelections, tagRegistry.tags, tagRegistry.deletedTags) went from non-empty to empty
|
|
516
|
+
* in a single mutation. Catches code that accidentally drops the entire collection (the original bug class - if a future writer or fn regression empties
|
|
517
|
+
* one of these, this guard logs loudly even though the change is structurally allowed).
|
|
518
|
+
*
|
|
519
|
+
* Both checks are log-only - issues surface as warnings without blocking the write. Genuine wholesale clears (operator clears all selections via UI) are the
|
|
520
|
+
* rare false-positive case here; in practice those happen via per-entry mutations rather than a single empty assignment.
|
|
521
|
+
* @param prev - Pre-mutation snapshot of the file data.
|
|
522
|
+
* @param next - Post-mutation, post-normalize file data.
|
|
523
|
+
* @returns Issues found, or empty array.
|
|
524
|
+
*/
|
|
525
|
+
function validateChannelsIntegrity(prev, next) {
|
|
526
|
+
const issues = [];
|
|
527
|
+
for (const loss of detectIdentityFieldLoss(prev.channels, next.channels)) {
|
|
528
|
+
issues.push({
|
|
529
|
+
category: "identity-field-loss",
|
|
530
|
+
description: loss + " would be silently cleared without canonical fallback",
|
|
531
|
+
severity: "warning"
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
// Metadata wholesale-clear detection. Checks each top-level collection independently. Empty -> empty is a no-op (no issue); non-empty -> non-empty (any
|
|
535
|
+
// size) is a normal mutation; only non-empty -> empty is flagged.
|
|
536
|
+
if ((Object.keys(prev.serviceSelections).length > 0) && (Object.keys(next.serviceSelections).length === 0)) {
|
|
537
|
+
issues.push({
|
|
538
|
+
category: "metadata-wholesale-clear",
|
|
539
|
+
description: "serviceSelections went from " + String(Object.keys(prev.serviceSelections).length) + " entries to empty in a single mutation",
|
|
540
|
+
severity: "warning"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
if ((prev.tagRegistry.tags.length > 0) && (next.tagRegistry.tags.length === 0)) {
|
|
544
|
+
issues.push({
|
|
545
|
+
category: "metadata-wholesale-clear",
|
|
546
|
+
description: "tagRegistry.tags went from " + String(prev.tagRegistry.tags.length) + " entries to empty in a single mutation",
|
|
547
|
+
severity: "warning"
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if ((prev.tagRegistry.deletedTags.length > 0) && (next.tagRegistry.deletedTags.length === 0)) {
|
|
551
|
+
issues.push({
|
|
552
|
+
category: "metadata-wholesale-clear",
|
|
553
|
+
description: "tagRegistry.deletedTags went from " + String(prev.tagRegistry.deletedTags.length) + " entries to empty in a single mutation",
|
|
554
|
+
severity: "warning"
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return issues;
|
|
558
|
+
}
|
|
173
559
|
// Transactional store instance for channels.json.
|
|
174
560
|
const channelsStore = createFileStore({
|
|
175
561
|
beforeWrite: prepareChannelsForWrite,
|
|
176
|
-
|
|
562
|
+
currentSchemaVersion: CURRENT_CHANNELS_SCHEMA_VERSION,
|
|
563
|
+
defaultValue: () => ({
|
|
564
|
+
channels: {},
|
|
565
|
+
migrationsApplied: [],
|
|
566
|
+
schemaVersion: CURRENT_CHANNELS_SCHEMA_VERSION,
|
|
567
|
+
serviceSelections: {},
|
|
568
|
+
tagRegistry: { deletedTags: [], tags: [] }
|
|
569
|
+
}),
|
|
570
|
+
getSchemaVersion: (data) => data.schemaVersion,
|
|
177
571
|
label: "channels",
|
|
572
|
+
migrations: channelsMigrations,
|
|
178
573
|
parse: parseChannelsFile,
|
|
179
|
-
path: getChannelsFilePath
|
|
574
|
+
path: getChannelsFilePath,
|
|
575
|
+
recordMigration: (data, description) => { data.migrationsApplied.push(description); },
|
|
576
|
+
setSchemaVersion: (data, version) => { data.schemaVersion = version; },
|
|
577
|
+
validate: validateChannelsIntegrity
|
|
180
578
|
});
|
|
181
579
|
/**
|
|
182
580
|
* Reads the current channels from disk without acquiring the serialization lock. Returns the parsed channels with metadata and parse status. Use this for
|
|
@@ -189,30 +587,98 @@ export async function readChannels() {
|
|
|
189
587
|
channels: result.data.channels,
|
|
190
588
|
parseError: result.parseError,
|
|
191
589
|
parseErrorMessage: result.parseErrorMessage,
|
|
590
|
+
recoveredFromBackup: result.recoveredFromBackup,
|
|
192
591
|
serviceSelections: result.data.serviceSelections,
|
|
193
592
|
tagRegistry: result.data.tagRegistry
|
|
194
593
|
};
|
|
195
594
|
}
|
|
196
595
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
596
|
+
* Pre-write integrity guard. Compares the channels map captured immediately after parse against the channels map about to be written, and reports identity
|
|
597
|
+
* fields that have been silently dropped without a canonical fallback.
|
|
598
|
+
*
|
|
599
|
+
* Allowed transitions for any identity field:
|
|
600
|
+
* - value -> same value (no change)
|
|
601
|
+
* - value -> different value (legitimate update)
|
|
602
|
+
* - value -> null (explicit clear; null is the codebase-wide "clear this field" signal)
|
|
603
|
+
* - field absent -> any value (new data)
|
|
604
|
+
*
|
|
605
|
+
* Forbidden transition (what this guard catches):
|
|
606
|
+
* - value -> undefined / missing WHERE the canonical (for variants and predefined-overrides) does not provide the same value
|
|
607
|
+
*
|
|
608
|
+
* Variant inheritance is honored: stripping a stored field that matches the canonical's value is delta minimization, not data loss, and is allowed. The check
|
|
609
|
+
* is non-blocking: it surfaces suspicious patterns for operator review without aborting legitimate code paths.
|
|
610
|
+
* @param before - The channels map captured immediately after parse, before the caller's mutation ran.
|
|
611
|
+
* @param after - The channels map after the caller's mutation and delta normalization.
|
|
612
|
+
* @returns A list of "key.field" strings naming each detected silent drop. Empty list means no losses.
|
|
613
|
+
*/
|
|
614
|
+
function detectIdentityFieldLoss(before, after) {
|
|
615
|
+
const losses = [];
|
|
616
|
+
for (const [key, beforeEntry] of Object.entries(before)) {
|
|
617
|
+
const afterEntry = after[key];
|
|
618
|
+
// Whole entry removed - this is intentional deletion (deleteUserChannel etc.), not a silent drop.
|
|
619
|
+
if (!afterEntry) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
// Determine the canonical fallback. For variants the canonicalKey field points at it; for overrides of predefined channels the entry's key is the
|
|
623
|
+
// canonical. For standalones (no predefined match, no canonicalKey) there is no fallback - any drop is a loss.
|
|
624
|
+
const canonicalKey = afterEntry.canonicalKey ?? key;
|
|
625
|
+
const canonical = PREDEFINED_CHANNELS[canonicalKey];
|
|
626
|
+
for (const field of CHANNEL_IDENTITY_KEYS) {
|
|
627
|
+
const beforeValue = beforeEntry[field];
|
|
628
|
+
const afterValue = afterEntry[field];
|
|
629
|
+
// Field was not set before - any post state is fine.
|
|
630
|
+
if ((beforeValue === undefined) || (beforeValue === null)) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
// Field is still present after (with any value, including null which means explicit clear) - allowed.
|
|
634
|
+
if (afterValue !== undefined) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
// Field went value -> undefined. Check whether the canonical provides the same value, in which case this is variant inheritance / delta minimization
|
|
638
|
+
// and not data loss. Array-valued fields (notably tags) are compared via JSON.stringify since reference equality would always fail.
|
|
639
|
+
const canonicalValue = canonical ? canonical[field] : undefined;
|
|
640
|
+
if (canonicalValue !== undefined) {
|
|
641
|
+
const sameValue = (Array.isArray(beforeValue) && Array.isArray(canonicalValue)) ?
|
|
642
|
+
isDeepStrictEqual(beforeValue, canonicalValue) :
|
|
643
|
+
(beforeValue === canonicalValue);
|
|
644
|
+
if (sameValue) {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
losses.push(key + "." + field);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return losses;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Serialized read-modify-write operation on channels.json. The mutation function receives the full file data (channels + serviceSelections + tagRegistry +
|
|
655
|
+
* framework metadata) and modifies it in place. The store handles atomicity, serialization, corruption guard, backup, schema migrations, and pre-write
|
|
656
|
+
* integrity validation. Delta normalization is applied to data.channels after the caller's mutation and before the file write so that the normalized result is
|
|
657
|
+
* available for the post-write cache hydration.
|
|
658
|
+
*
|
|
659
|
+
* After a successful write, the in-memory caches that mirror persisted state (loadedUserChannels, loadedTagRegistry, services.ts serviceSelections Map,
|
|
660
|
+
* runtime service groups) are hydrated from the just-written normalized data. This is the single direction caches flow: data is the source of truth, caches
|
|
661
|
+
* are derived. Callers must never poke caches directly and rely on a later save to pick up the change - all mutations go through this function.
|
|
662
|
+
* @param fn - Mutation function. Receives the full file data. Modify in place; return value is ignored.
|
|
663
|
+
* @throws FileStoreParseError if channels.json contains invalid JSON and the .bak rotation is also unparseable.
|
|
202
664
|
*/
|
|
203
665
|
export async function mutateChannels(fn) {
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
let
|
|
666
|
+
// Captured from inside the callback so the post-write cache hydration sees the same normalized data the framework wrote to disk. Captured before the await
|
|
667
|
+
// returns, but the assignments below only run if the store mutation (including atomic file write) completed without error.
|
|
668
|
+
let writtenData;
|
|
207
669
|
await channelsStore.mutate((data) => {
|
|
208
|
-
fn(data
|
|
209
|
-
// Normalize deltas before the beforeWrite hook serializes the data. This ensures the in-memory cache and the on-disk representation are
|
|
210
|
-
//
|
|
670
|
+
fn(data);
|
|
671
|
+
// Normalize channel deltas before the beforeWrite hook serializes the data. This ensures the in-memory cache and the on-disk representation are
|
|
672
|
+
// identical. Other top-level fields (serviceSelections, tagRegistry, framework metadata) are not delta-shaped and pass through unchanged.
|
|
211
673
|
data.channels = normalizeChannelDeltas(data.channels);
|
|
212
|
-
|
|
674
|
+
writtenData = data;
|
|
213
675
|
});
|
|
214
|
-
// Side effects after successful write.
|
|
215
|
-
|
|
676
|
+
// Side effects after successful write. The store framework guarantees writtenData reflects exactly what was persisted.
|
|
677
|
+
if (writtenData) {
|
|
678
|
+
loadedUserChannels = { ...writtenData.channels };
|
|
679
|
+
loadedTagRegistry = writtenData.tagRegistry;
|
|
680
|
+
setServiceSelections(writtenData.serviceSelections);
|
|
681
|
+
}
|
|
216
682
|
buildServiceGroups(getMergedChannelMap());
|
|
217
683
|
userChannelsParseError = false;
|
|
218
684
|
userChannelsParseErrorMessage = undefined;
|
|
@@ -224,11 +690,48 @@ export async function mutateChannels(fn) {
|
|
|
224
690
|
* @throws If the file cannot be written.
|
|
225
691
|
*/
|
|
226
692
|
export async function deleteUserChannel(key) {
|
|
227
|
-
await mutateChannels((
|
|
228
|
-
Reflect.deleteProperty(channels, key);
|
|
693
|
+
await mutateChannels((data) => {
|
|
694
|
+
Reflect.deleteProperty(data.channels, key);
|
|
229
695
|
});
|
|
230
696
|
LOG.info("User channel '%s' deleted.", key);
|
|
231
697
|
}
|
|
698
|
+
/**
|
|
699
|
+
* Removes all user override entries for a canonical channel - both the canonical-stored entry and the active service variant's stored entry (when one resolves
|
|
700
|
+
* via explicit selection or service-filter fallback). Returns the stored entry that was deleted (canonical takes precedence; falls back to the variant entry
|
|
701
|
+
* when canonical is absent) so callers can compute downstream effects like the playlist reload hint without duplicating the lookup.
|
|
702
|
+
*
|
|
703
|
+
* Callers wrap the call in mutateChannels() and pass the resulting StoredChannelMap. The helper operates on the map directly; it does not own the mutation
|
|
704
|
+
* lifecycle, allowing callers to combine the dual-delete with other operations (e.g., setting serviceSelections) in the same atomic write.
|
|
705
|
+
* @param channels - The stored channels map to mutate in place.
|
|
706
|
+
* @param canonicalKey - The canonical channel key. The active variant is resolved internally via resolveServiceKey.
|
|
707
|
+
* @returns The stored entry that existed pre-deletion (canonical first, variant fallback). Undefined when neither entry existed.
|
|
708
|
+
*/
|
|
709
|
+
export function clearChannelOverrides(channels, canonicalKey) {
|
|
710
|
+
const resolvedKey = resolveServiceKey(canonicalKey);
|
|
711
|
+
const activeVariantKey = (resolvedKey === canonicalKey) ? undefined : resolvedKey;
|
|
712
|
+
// Canonical takes precedence for the returned entry because the canonical entry is the editing target; the variant entry is secondary cleanup. Falls back
|
|
713
|
+
// to the variant entry only when canonical is absent so callers still see a non-undefined result for variant-only revert cases.
|
|
714
|
+
const stored = channels[canonicalKey] ?? (activeVariantKey ? channels[activeVariantKey] : undefined);
|
|
715
|
+
Reflect.deleteProperty(channels, canonicalKey);
|
|
716
|
+
if (activeVariantKey) {
|
|
717
|
+
Reflect.deleteProperty(channels, activeVariantKey);
|
|
718
|
+
}
|
|
719
|
+
return stored;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Merges a partial ChannelDelta into a stored entry. Returns a new entry with the existing fields preserved and the delta's fields layered on top. Used by
|
|
723
|
+
* bulk operations that update one or more delta fields across many channels - the helper handles both the "no entry exists yet" case (uses an empty record)
|
|
724
|
+
* and the "entry exists with prior overrides" case (preserves them) without forcing each call site to write the spread + assign manually.
|
|
725
|
+
*
|
|
726
|
+
* The merged entry is returned in StoredChannel shape; callers assign it back to data.channels[key]. The post-mutation normalizer (filterToDeltaSurface +
|
|
727
|
+
* normalizeEntryAgainstBase) handles delta minimization and shape enforcement, so callers can pass arbitrary ChannelDelta keys without pre-validating them.
|
|
728
|
+
* @param stored - The existing stored entry, or undefined when the channel has no prior overrides.
|
|
729
|
+
* @param delta - Fields to assign onto the entry. Null values flow through unchanged for the delta-clear semantic.
|
|
730
|
+
* @returns The merged stored entry.
|
|
731
|
+
*/
|
|
732
|
+
export function applyChannelDelta(stored, delta) {
|
|
733
|
+
return { ...stored, ...delta };
|
|
734
|
+
}
|
|
232
735
|
/**
|
|
233
736
|
* Resets all user channels by deleting the channels file.
|
|
234
737
|
* @throws If the file exists but cannot be deleted.
|
|
@@ -249,64 +752,81 @@ export async function resetUserChannels() {
|
|
|
249
752
|
}
|
|
250
753
|
/* User channels are loaded at server startup and stored in module-level state. This avoids repeated file reads during request handling.
|
|
251
754
|
*/
|
|
755
|
+
/**
|
|
756
|
+
* Classifies which stored entries are legitimate legacy variants eligible for canonicalKey stamping. A legitimate legacy variant has a hyphenated key whose
|
|
757
|
+
* prefix matches a predefined canonical AND every stored identity field either is absent or matches the canonical's value. Entries with diverging identity
|
|
758
|
+
* are user-created standalones that happen to share the hyphenated key shape (e.g., "abc-kabc" as a local affiliate with its own channel number and station
|
|
759
|
+
* ID) and must be left alone - stamping them would mark them for delta normalization and silently destroy the user's custom identity.
|
|
760
|
+
*
|
|
761
|
+
* Array-valued identity fields (tags) compare via JSON.stringify with case-insensitive canonical sorting so historical write order does not defeat the match.
|
|
762
|
+
* @param channels - The raw stored channel entries.
|
|
763
|
+
* @returns Keys that should receive a canonicalKey stamp. Empty when nothing needs migrating.
|
|
764
|
+
*/
|
|
765
|
+
function collectLegacyVariantStamps(channels) {
|
|
766
|
+
const stamps = [];
|
|
767
|
+
for (const [key, stored] of Object.entries(channels)) {
|
|
768
|
+
// Entries that already declare canonicalKey are already on the new model - skip.
|
|
769
|
+
if (stored.canonicalKey) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const hyphenIndex = key.indexOf("-");
|
|
773
|
+
if (hyphenIndex === -1) {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const prefix = key.substring(0, hyphenIndex);
|
|
777
|
+
const canonical = PREDEFINED_CHANNELS[prefix];
|
|
778
|
+
if (!canonical) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
// Safe classifier: stamp only when every stored identity field is either absent or matches the canonical exactly. Any divergence signals a user-created
|
|
782
|
+
// standalone that happens to share the variant-shaped key; stamping would trigger delta normalization and destroy the user's custom identity.
|
|
783
|
+
let shapeCompatible = true;
|
|
784
|
+
for (const field of CHANNEL_IDENTITY_KEYS) {
|
|
785
|
+
const storedValue = stored[field];
|
|
786
|
+
if (storedValue === undefined) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const canonicalValue = canonical[field];
|
|
790
|
+
if (Array.isArray(storedValue) && Array.isArray(canonicalValue)) {
|
|
791
|
+
if (!isDeepStrictEqual(sortTags(storedValue), sortTags(canonicalValue))) {
|
|
792
|
+
shapeCompatible = false;
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (storedValue !== canonicalValue) {
|
|
798
|
+
shapeCompatible = false;
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (shapeCompatible) {
|
|
803
|
+
stamps.push(key);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return stamps;
|
|
807
|
+
}
|
|
252
808
|
/**
|
|
253
809
|
* Initializes user channels by loading them from the file. This should be called once at server startup. Also builds service groups and loads service selections.
|
|
254
810
|
*/
|
|
255
811
|
export async function initializeUserChannels() {
|
|
812
|
+
// Read the file. Schema migrations (canonicalKey stamping, foxcom -> foxone rename, etc.) run automatically inside the file store framework via the
|
|
813
|
+
// declarative channelsMigrations registry; ensureMigrated (called by the release boot coordinator at startup) persists any upgrades to disk before this
|
|
814
|
+
// function runs. The data returned here is always at CURRENT_CHANNELS_SCHEMA_VERSION.
|
|
256
815
|
const result = await readChannels();
|
|
257
|
-
// Populate module-level state from the loaded
|
|
258
|
-
// with normalized data. Setting the initial state here ensures the cache is populated even when no migrations run.
|
|
816
|
+
// Populate module-level state from the loaded (and already-migrated) data.
|
|
259
817
|
loadedUserChannels = result.channels;
|
|
260
818
|
loadedTagRegistry = result.tagRegistry;
|
|
261
819
|
userChannelsParseError = result.parseError;
|
|
262
820
|
userChannelsParseErrorMessage = result.parseErrorMessage;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
let channelsMigrated = false;
|
|
266
|
-
for (const [canonicalKey, selectedVariant] of Object.entries(result.serviceSelections)) {
|
|
267
|
-
// foxcom → foxone: original Fox service slug renamed.
|
|
268
|
-
if (selectedVariant.endsWith("-foxcom")) {
|
|
269
|
-
result.serviceSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
|
|
270
|
-
channelsMigrated = true;
|
|
271
|
-
}
|
|
272
|
-
// fox-site → fox-foxone: the "fox" channel's FoxOne variant was briefly keyed as "site" in v1.8.0 instead of "foxone" like every other Fox channel.
|
|
273
|
-
if ((canonicalKey === "fox") && (selectedVariant === "fox-site")) {
|
|
274
|
-
result.serviceSelections[canonicalKey] = "fox-foxone";
|
|
275
|
-
channelsMigrated = true;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
// Load service selections before saving so that mutateChannels (which persists both channels and selections via the beforeWrite hook) captures the
|
|
279
|
-
// migrated values from module state.
|
|
280
|
-
setServiceSelections(result.serviceSelections);
|
|
281
|
-
if (channelsMigrated) {
|
|
282
|
-
await mutateChannels((channels) => {
|
|
283
|
-
// Apply the foxcom → foxone channel key migration. The mutation reads fresh from disk, so we replay the transform rather than passing the in-memory
|
|
284
|
-
// result. The fox-site selection migration is selection-only (no channel keys to rename).
|
|
285
|
-
for (const key of Object.keys(channels)) {
|
|
286
|
-
if (key.endsWith("-foxcom")) {
|
|
287
|
-
channels[key.slice(0, -6) + "foxone"] = channels[key];
|
|
288
|
-
Reflect.deleteProperty(channels, key);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
LOG.info("Migrated stale Fox service references.");
|
|
293
|
-
}
|
|
294
|
-
// Load enabled services from the configuration, validating that each tag is recognized. Invalid tags (e.g., from hand-edited config.json typos) are stripped
|
|
295
|
-
// silently after logging a warning. Validation must happen after buildServiceGroups() because getAllServiceTags() depends on the groups being built.
|
|
296
|
-
let configuredServices = CONFIG.channels.enabledServices;
|
|
297
|
-
// Silent migration: rename "foxcom" to "foxone" in the service filter if present. Persisted to config.json immediately so the stale value doesn't remain.
|
|
298
|
-
if (configuredServices.includes("foxcom")) {
|
|
299
|
-
configuredServices = configuredServices.map((tag) => (tag === "foxcom") ? "foxone" : tag);
|
|
300
|
-
CONFIG.channels.enabledServices = configuredServices;
|
|
301
|
-
await mutateConfig((config) => {
|
|
302
|
-
if (config.channels?.enabledServices) {
|
|
303
|
-
config.channels.enabledServices = configuredServices;
|
|
304
|
-
}
|
|
305
|
-
});
|
|
306
|
-
LOG.info("Migrated service filter from foxcom to foxone.");
|
|
821
|
+
if (result.recoveredFromBackup) {
|
|
822
|
+
LOG.info("Channels were recovered from backup after a corrupt main file.");
|
|
307
823
|
}
|
|
308
|
-
//
|
|
309
|
-
|
|
824
|
+
// Load service selections so prepareChannelsForWrite captures them on subsequent writes.
|
|
825
|
+
setServiceSelections(result.serviceSelections);
|
|
826
|
+
// Inference for setupCompleted: existing users who already have services or channels configured should not see the first-run setup wizard. This is runtime
|
|
827
|
+
// inference (not a schema migration) because it depends on observed state from two stores (channels and config) and never changes the file shape - only
|
|
828
|
+
// sets a flag based on what exists. Lives here at the cross-store boundary where both stores are loaded.
|
|
829
|
+
const configuredServices = CONFIG.channels.enabledServices;
|
|
310
830
|
if (!CONFIG.channels.setupCompleted) {
|
|
311
831
|
const hasServices = configuredServices.length > 0;
|
|
312
832
|
const hasUserChannels = Object.keys(loadedUserChannels).length > 0;
|
|
@@ -318,97 +838,35 @@ export async function initializeUserChannels() {
|
|
|
318
838
|
});
|
|
319
839
|
}
|
|
320
840
|
}
|
|
321
|
-
// One-time migration: stamp canonicalKey on existing user channel variant entries that were created before explicit variant relationships were introduced.
|
|
322
|
-
// These entries have hyphenated keys where the prefix is a known predefined canonical (e.g., "espn-hulu" where "espn" exists). The heuristic runs once —
|
|
323
|
-
// new entries get canonicalKey written at creation time by the browse modal.
|
|
324
|
-
let canonicalKeyMigrated = false;
|
|
325
|
-
for (const [key, channel] of Object.entries(result.channels)) {
|
|
326
|
-
// Skip entries that already have canonicalKey.
|
|
327
|
-
if (channel.canonicalKey) {
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
const hyphenIndex = key.indexOf("-");
|
|
331
|
-
if (hyphenIndex === -1) {
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
const prefix = key.substring(0, hyphenIndex);
|
|
335
|
-
// Only stamp canonicalKey if the prefix exists as a predefined channel. This matches the old buildServiceGroups heuristic exactly.
|
|
336
|
-
if (prefix in PREDEFINED_CHANNELS) {
|
|
337
|
-
channel.canonicalKey = prefix;
|
|
338
|
-
canonicalKeyMigrated = true;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (canonicalKeyMigrated) {
|
|
342
|
-
await mutateChannels((channels) => {
|
|
343
|
-
for (const [key, channel] of Object.entries(channels)) {
|
|
344
|
-
if (channel.canonicalKey) {
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
const hyphenIndex = key.indexOf("-");
|
|
348
|
-
if (hyphenIndex === -1) {
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
const prefix = key.substring(0, hyphenIndex);
|
|
352
|
-
if (prefix in PREDEFINED_CHANNELS) {
|
|
353
|
-
channel.canonicalKey = prefix;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
LOG.info("Migrated user channel variant entries with explicit canonical key declarations.");
|
|
358
|
-
}
|
|
359
|
-
// One-time migration: strip identity fields from user channel variant entries. Identity fields (name, stationId, tags, etc.) are resolved from the canonical
|
|
360
|
-
// at runtime via applyVariantInheritance, so storing them on variants is redundant. Older versions wrote these fields on variant creation. This migration
|
|
361
|
-
// cleans them up so channels.json only contains service-specific fields on variant entries.
|
|
362
|
-
let variantFieldsMigrated = false;
|
|
363
|
-
for (const channel of Object.values(result.channels)) {
|
|
364
|
-
if (!channel.canonicalKey) {
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
for (const field of CHANNEL_IDENTITY_FIELDS) {
|
|
368
|
-
if (field in channel) {
|
|
369
|
-
Reflect.deleteProperty(channel, field);
|
|
370
|
-
variantFieldsMigrated = true;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (variantFieldsMigrated) {
|
|
375
|
-
await mutateChannels((channels) => {
|
|
376
|
-
for (const channel of Object.values(channels)) {
|
|
377
|
-
if (!channel.canonicalKey) {
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
for (const field of CHANNEL_IDENTITY_FIELDS) {
|
|
381
|
-
if (field in channel) {
|
|
382
|
-
Reflect.deleteProperty(channel, field);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
});
|
|
387
|
-
LOG.info("Stripped redundant identity fields from user channel variant entries.");
|
|
388
|
-
}
|
|
389
841
|
// Build the merged channels map and then build service groups.
|
|
390
842
|
const mergedChannels = getMergedChannelMap();
|
|
391
|
-
// buildServiceGroups validates stored service selections against the rebuilt variant structure and reverts any that are stale
|
|
392
|
-
//
|
|
843
|
+
// buildServiceGroups validates stored service selections against the rebuilt variant structure and reverts any that are stale (cleared from module state).
|
|
844
|
+
// If any were cleaned, persist the same cleanup to disk so the file matches the runtime state and the cleanup survives restarts. mutateChannels operates on
|
|
845
|
+
// the freshly-read on-disk data; explicitly deleting the stale keys from data.serviceSelections is the data-side equivalent of the module-side cleanup
|
|
846
|
+
// buildServiceGroups already performed.
|
|
393
847
|
const staleSelections = buildServiceGroups(mergedChannels);
|
|
394
848
|
if (staleSelections.length > 0) {
|
|
395
|
-
await
|
|
849
|
+
await mutateChannels((data) => {
|
|
850
|
+
for (const key of staleSelections) {
|
|
851
|
+
Reflect.deleteProperty(data.serviceSelections, key);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
396
854
|
}
|
|
397
855
|
// Now that service groups are built, validate the configured service tags. Strip any unrecognized tags and warn.
|
|
398
856
|
if (configuredServices.length > 0) {
|
|
399
857
|
const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
|
|
400
|
-
const
|
|
401
|
-
const
|
|
858
|
+
const configuredSet = new Set(configuredServices);
|
|
859
|
+
const validTags = [...configuredSet.intersection(knownTags)];
|
|
860
|
+
const invalidTags = [...configuredSet.difference(knownTags)];
|
|
402
861
|
if (invalidTags.length > 0) {
|
|
403
862
|
LOG.warn("Ignoring unrecognized service tags in configuration: %s.", invalidTags.join(", "));
|
|
404
863
|
}
|
|
405
864
|
setEnabledServices(validTags);
|
|
406
|
-
CONFIG.channels.enabledServices = validTags;
|
|
407
865
|
}
|
|
408
866
|
else {
|
|
409
867
|
setEnabledServices(configuredServices);
|
|
410
868
|
}
|
|
411
|
-
// Check for non-printable characters in loaded channel string values. These warnings are informational
|
|
869
|
+
// Check for non-printable characters in loaded channel string values. These warnings are informational - loaded data is not modified.
|
|
412
870
|
for (const [channelKey, stored] of Object.entries(loadedUserChannels)) {
|
|
413
871
|
for (const [field, value] of Object.entries(stored)) {
|
|
414
872
|
if ((typeof value === "string") && containsNonPrintable(value)) {
|
|
@@ -426,58 +884,173 @@ export async function initializeUserChannels() {
|
|
|
426
884
|
LOG.info("Loaded %d channels.", totalCount);
|
|
427
885
|
}
|
|
428
886
|
}
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
887
|
+
// Variant overlay allowlist used by the resolver (overlayVariantBinding). The resolver runs against catalog data merged with user storage, and predefined
|
|
888
|
+
// variants legitimately carry DOM-hook binding fields (dismissSelector, scrollSelector, etc.) set by the catalog flattener. So the resolver's allowlist is the
|
|
889
|
+
// FULL binding partition, NOT the delta-eligible subset - this is correct domain asymmetry: the resolver accepts catalog-domain data, while storage filtering
|
|
890
|
+
// (filterToDeltaSurface) restricts to user-domain data. Identity fields encountered in a variant entry are silently dropped here - canonical's identity always
|
|
891
|
+
// wins by construction.
|
|
892
|
+
const VARIANT_OVERLAY_ALLOWED_FIELDS = new Set(CHANNEL_BINDING_KEYS);
|
|
893
|
+
// The runtime delta surface: every field that may legitimately appear in a stored ChannelDelta override. Derived from the partition arrays in types/channels.ts
|
|
894
|
+
// so adding or removing a delta-eligible field in one place propagates everywhere. Typed as Set<string> for ergonomics at .has() call sites that take string-keyed
|
|
895
|
+
// values from Object.entries iteration; the contents are statically-known CustomizableField values via the spread above.
|
|
896
|
+
const DELTA_ALLOWED_FIELDS = new Set([...DELTA_ELIGIBLE_IDENTITY_KEYS, ...DELTA_ELIGIBLE_BINDING_KEYS]);
|
|
434
897
|
/**
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
898
|
+
* Extracts the identity-only subset of a channel. Used by resolveVariant and normalizeChannelDeltas to compute the inheritance base for variants: a variant
|
|
899
|
+
* inherits identity from its canonical but must contribute its own service binding (URL, channelSelector, profile, etc.) - the canonical's binding is for the
|
|
900
|
+
* canonical service and is structurally wrong for any other service.
|
|
901
|
+
*
|
|
902
|
+
* The fields copied are exactly those listed in CHANNEL_IDENTITY_KEYS, the single source of truth for the identity partition. Array-valued fields are shallow-
|
|
903
|
+
* copied so downstream mutation cannot leak back into the source.
|
|
904
|
+
* @param channel - The channel to extract identity from. Typed as ResolvedChannel because that is the shape after canonical resolution; CanonicalChannel is
|
|
905
|
+
* structurally compatible.
|
|
906
|
+
* @returns A new ChannelIdentity object with just the identity fields populated.
|
|
441
907
|
*/
|
|
442
|
-
export function
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (!predefined) {
|
|
448
|
-
return stored;
|
|
449
|
-
}
|
|
450
|
-
// Start with a copy of the predefined definition, then overlay allowlisted non-null delta fields. The spread creates a shallow copy — reference-type fields
|
|
451
|
-
// (like tags) share the same array instance as PREDEFINED_CHANNELS. The defensive copy below ensures the returned Channel is fully independent so callers can
|
|
452
|
-
// safely modify it without corrupting the predefined source of truth.
|
|
453
|
-
const resolved = { ...predefined };
|
|
454
|
-
for (const [field, value] of Object.entries(stored)) {
|
|
455
|
-
if (!DELTA_ALLOWED_FIELDS.has(field)) {
|
|
908
|
+
export function pickIdentity(channel) {
|
|
909
|
+
const identity = {};
|
|
910
|
+
for (const field of CHANNEL_IDENTITY_KEYS) {
|
|
911
|
+
const value = channel[field];
|
|
912
|
+
if (value === undefined) {
|
|
456
913
|
continue;
|
|
457
914
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
915
|
+
identity[field] = value;
|
|
916
|
+
}
|
|
917
|
+
identity.tags &&= identity.tags.slice();
|
|
918
|
+
return identity;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Overlays a stored channel (full definition or delta) onto a base. Allowlisted delta fields in the stored entry override the base: a null value clears the
|
|
922
|
+
* field from the resolved object, a defined value replaces it, and missing fields inherit from the base. Non-delta fields on the stored entry (e.g.,
|
|
923
|
+
* canonicalKey) pass through so the resolved channel retains its relationship metadata. The returned object is a fresh reference with a defensive copy of any
|
|
924
|
+
* array-valued fields, so callers can mutate it without corrupting the base.
|
|
925
|
+
*
|
|
926
|
+
* This is the single delta-overlay kernel used by both predefined overrides and user variants. The base may be:
|
|
927
|
+
*
|
|
928
|
+
* - A predefined CanonicalChannel for canonical-override resolution.
|
|
929
|
+
* - A ChannelIdentity-only intermediate during variant resolution (URL is supplied by the variant binding overlay).
|
|
930
|
+
* - A fully resolved canonical for variant base computation.
|
|
931
|
+
*
|
|
932
|
+
* Internally typed as ResolvedChannel for the input shape; intermediate variant-resolution states pass through a cast because url is required on
|
|
933
|
+
* ResolvedChannel but is supplied by the next overlay. The contract is: after the full resolveVariant chain, the result has every required field populated.
|
|
934
|
+
* @param base - The base channel to inherit from.
|
|
935
|
+
* @param stored - The stored entry (delta) to overlay.
|
|
936
|
+
* @returns A new ResolvedChannel with the base's fields and the stored entry's overrides applied.
|
|
937
|
+
*/
|
|
938
|
+
function overlayDelta(base, stored) {
|
|
939
|
+
return applyOverlayKernel(base, stored, { allowedFields: DELTA_ALLOWED_FIELDS, passThroughOthers: true });
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Shared overlay kernel used by overlayDelta and overlayVariantBinding. Walks the stored entry's fields and applies them to a clone of base under three rules:
|
|
943
|
+
* fields in allowedFields apply with delta semantics (null clears, undefined skips, value overrides); non-allowed fields either pass through (delta) or are
|
|
944
|
+
* silently dropped (variant overlay) per passThroughOthers; canonicalKey is always allowed regardless of the gate so resolved variants retain their relationship
|
|
945
|
+
* metadata. The kernel exists to keep the two overlays from drifting - identical control flow lived in both call sites before this consolidation.
|
|
946
|
+
* @param base - The base channel to clone and overlay onto.
|
|
947
|
+
* @param stored - The stored entry whose fields override the base.
|
|
948
|
+
* @param options - allowedFields selects which fields apply with delta semantics; passThroughOthers controls whether non-allowed fields flow through or are
|
|
949
|
+
* dropped. canonicalKey is always passed through.
|
|
950
|
+
* @returns A new ResolvedChannel with overlay applied.
|
|
951
|
+
*/
|
|
952
|
+
function applyOverlayKernel(base, stored, options) {
|
|
953
|
+
const resolved = { ...base };
|
|
954
|
+
for (const [field, value] of Object.entries(stored)) {
|
|
955
|
+
if (options.allowedFields.has(field)) {
|
|
956
|
+
if (value === null) {
|
|
957
|
+
// Explicit null means "clear this field" - delete it from the resolved object.
|
|
958
|
+
Reflect.deleteProperty(resolved, field);
|
|
959
|
+
}
|
|
960
|
+
else if (value !== undefined) {
|
|
961
|
+
// Non-null, non-undefined - override the base value.
|
|
962
|
+
resolved[field] = value;
|
|
963
|
+
}
|
|
964
|
+
continue;
|
|
461
965
|
}
|
|
462
|
-
|
|
463
|
-
|
|
966
|
+
// Non-allowed fields: pass through under delta semantics, or always pass canonicalKey as relationship metadata. Undefined values skipped per the
|
|
967
|
+
// standard "undefined inherits" rule.
|
|
968
|
+
if ((options.passThroughOthers || (field === "canonicalKey")) && (value !== undefined)) {
|
|
464
969
|
resolved[field] = value;
|
|
465
970
|
}
|
|
466
971
|
}
|
|
467
|
-
// Defensive copy of reference-type fields to break shared references with
|
|
468
|
-
//
|
|
972
|
+
// Defensive copy of reference-type fields to break shared references with the base. The overlay above may have replaced tags entirely (if the stored
|
|
973
|
+
// entry included a tags array), but when no delta is present for tags, the spread leaves the base's array reference on the resolved object.
|
|
469
974
|
resolved.tags &&= resolved.tags.slice();
|
|
470
975
|
return resolved;
|
|
471
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Variant-binding-only overlay. Like overlayDelta but applies only fields in CHANNEL_BINDING_KEYS - identity fields on the variant entry are silently dropped
|
|
979
|
+
* during resolution. This enforces the architectural principle that variants are pure tuning data: how to reach the channel via this service. Identity
|
|
980
|
+
* (channelNumber, hdhrEnabled, tags, name, etc.) is a property of the channel as a whole and lives only on the canonical entry.
|
|
981
|
+
*
|
|
982
|
+
* Used by resolveVariant for both predefined-variant and stored-variant overlays. Legacy data, hand-edited files, or future migrations that put identity
|
|
983
|
+
* fields on variant entries are inert at resolution time - canonical's identity always wins. canonicalKey passes through as relationship metadata so the
|
|
984
|
+
* resolved variant can still be identified as a variant.
|
|
985
|
+
*
|
|
986
|
+
* Null/undefined semantics match overlayDelta: null clears the field; undefined inherits.
|
|
987
|
+
* @param base - The base channel (typically canonical identity from pickIdentity).
|
|
988
|
+
* @param stored - The variant entry (predefined or user-stored).
|
|
989
|
+
* @returns A new ResolvedChannel with binding fields applied from the variant.
|
|
990
|
+
*/
|
|
991
|
+
function overlayVariantBinding(base, stored) {
|
|
992
|
+
return applyOverlayKernel(base, stored, { allowedFields: VARIANT_OVERLAY_ALLOWED_FIELDS, passThroughOthers: false });
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Resolves a stored channel entry into a fully populated ResolvedChannel. Handles the two non-variant cases:
|
|
996
|
+
*
|
|
997
|
+
* 1. Predefined override (key matches a predefined entry): the stored entry is a delta over the predefined canonical definition.
|
|
998
|
+
* 2. Standalone user channel (no predefined equivalent): the stored entry is already a full channel; a defensive copy is returned so downstream mutations
|
|
999
|
+
* cannot leak into loadedUserChannels.
|
|
1000
|
+
*
|
|
1001
|
+
* Variants are resolved by getMergedChannelMap via the layered overlay (canonical identity -> predefined variant binding -> user delta). Callers that need a
|
|
1002
|
+
* resolved variant should read from channelsRef (via getResolvedChannel) rather than calling this function with a variant key.
|
|
1003
|
+
* @param key - The channel key (must not be a variant key).
|
|
1004
|
+
* @param stored - The stored channel data.
|
|
1005
|
+
* @returns A fully resolved channel with identity and binding both populated.
|
|
1006
|
+
*/
|
|
1007
|
+
export function resolveStoredChannel(key, stored) {
|
|
1008
|
+
const predefined = PREDEFINED_CHANNELS[key];
|
|
1009
|
+
if (predefined) {
|
|
1010
|
+
return overlayDelta(predefined, stored);
|
|
1011
|
+
}
|
|
1012
|
+
// Standalone: defensive copy so callers can mutate the result without corrupting loadedUserChannels. A standalone is structurally a CanonicalChannel
|
|
1013
|
+
// (identity + binding, no canonicalKey) and therefore satisfies ResolvedChannel.
|
|
1014
|
+
const standalone = { ...stored };
|
|
1015
|
+
standalone.tags &&= standalone.tags.slice();
|
|
1016
|
+
return standalone;
|
|
1017
|
+
}
|
|
472
1018
|
/**
|
|
473
1019
|
* Returns the merged channel map (predefined + user) without filtering by enabled status or service variants. Used internally for building service groups.
|
|
474
|
-
* Resolves
|
|
1020
|
+
* Resolves deltas into fully populated channels.
|
|
1021
|
+
*
|
|
1022
|
+
* Built from buildResolvedCanonicals (Pass 1: canonicals and standalones) plus a variant pass (Pass 2: layered canonical identity -> predefined variant
|
|
1023
|
+
* binding -> user delta). The dangling-canonical path preserves whatever data exists so the user does not silently lose anything when a canonicalKey points
|
|
1024
|
+
* at a missing entry.
|
|
475
1025
|
* @returns The complete merged channel map.
|
|
476
1026
|
*/
|
|
477
1027
|
function getMergedChannelMap() {
|
|
478
|
-
const result =
|
|
479
|
-
|
|
480
|
-
|
|
1028
|
+
const result = buildResolvedCanonicals(loadedUserChannels);
|
|
1029
|
+
const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(loadedUserChannels)]);
|
|
1030
|
+
for (const key of allKeys) {
|
|
1031
|
+
const stored = loadedUserChannels[key];
|
|
1032
|
+
const classification = classifyEntry(key, stored);
|
|
1033
|
+
if (classification.kind !== "variant") {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const canonical = result[classification.canonicalKey];
|
|
1037
|
+
if (canonical) {
|
|
1038
|
+
result[key] = resolveVariant(canonical, classification.predefined, classification.stored);
|
|
1039
|
+
// Clear the dangling-warning dedup entry if one was recorded earlier: a previously-missing canonical is now present, so a future regression should
|
|
1040
|
+
// re-warn rather than staying silent because we already warned once.
|
|
1041
|
+
warnedDanglingVariants.delete(danglingToken(key, classification.canonicalKey));
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
// Dangling canonical: the variant references a canonical that is not present. Warn once per (variant, canonical) pair, then preserve the variant as best
|
|
1045
|
+
// we can so the user does not silently lose data. When a stored entry exists, funnel through resolveStoredChannel - it already overlays onto the
|
|
1046
|
+
// predefined definition (if any) or returns a standalone copy. When only the predefined entry exists, use it directly.
|
|
1047
|
+
warnDanglingCanonical(key, classification.canonicalKey);
|
|
1048
|
+
if (classification.stored) {
|
|
1049
|
+
result[key] = resolveStoredChannel(key, classification.stored);
|
|
1050
|
+
}
|
|
1051
|
+
else if (classification.predefined) {
|
|
1052
|
+
result[key] = classification.predefined;
|
|
1053
|
+
}
|
|
481
1054
|
}
|
|
482
1055
|
return result;
|
|
483
1056
|
}
|
|
@@ -485,7 +1058,7 @@ function getMergedChannelMap() {
|
|
|
485
1058
|
* classification and enabled status. All other channel retrieval functions that need merged data build on top of it.
|
|
486
1059
|
*/
|
|
487
1060
|
/**
|
|
488
|
-
* Returns the full channel listing with source classification and enabled status. This is the authoritative merge point for predefined and user channels
|
|
1061
|
+
* Returns the full channel listing with source classification and enabled status. This is the authoritative merge point for predefined and user channels - all
|
|
489
1062
|
* code that needs a merged view of channels should use this function (or getAllChannels() which delegates to it).
|
|
490
1063
|
*
|
|
491
1064
|
* For each channel key, the source is classified as:
|
|
@@ -496,7 +1069,7 @@ function getMergedChannelMap() {
|
|
|
496
1069
|
* The enabled field reflects whether the channel is available for streaming. Predefined-only channels can be disabled via configuration; user and override
|
|
497
1070
|
* channels are always enabled.
|
|
498
1071
|
*
|
|
499
|
-
* Service variants (non-canonical keys in service groups) are filtered out from this listing
|
|
1072
|
+
* Service variants (non-canonical keys in service groups) are filtered out from this listing - they are accessed via the service selection mechanism instead.
|
|
500
1073
|
*
|
|
501
1074
|
* Override entries produce a new resolved Channel object (via resolveStoredChannel()), which is a different reference from PREDEFINED_CHANNELS[key]. The service
|
|
502
1075
|
* system (services.ts) relies on this reference difference to detect user overrides via isUserOverride(). Predefined-only entries preserve the original reference.
|
|
@@ -509,7 +1082,7 @@ export function getChannelListing() {
|
|
|
509
1082
|
const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(loadedUserChannels)]);
|
|
510
1083
|
const listing = [];
|
|
511
1084
|
for (const key of allKeys) {
|
|
512
|
-
// Skip service variants
|
|
1085
|
+
// Skip service variants - they're accessed via service selection, not as separate channels.
|
|
513
1086
|
if (isServiceVariant(key)) {
|
|
514
1087
|
continue;
|
|
515
1088
|
}
|
|
@@ -528,9 +1101,19 @@ export function getChannelListing() {
|
|
|
528
1101
|
}
|
|
529
1102
|
// For user entries (including overrides), resolve the stored delta/definition into a full Channel. The resolved object is a new reference, which preserves
|
|
530
1103
|
// the isUserOverride() contract in services.ts (reference comparison against PREDEFINED_CHANNELS[key]). Predefined-only entries keep the original reference.
|
|
531
|
-
const
|
|
1104
|
+
const userEntry = loadedUserChannels[key];
|
|
1105
|
+
const predefinedEntry = PREDEFINED_CHANNELS[key];
|
|
1106
|
+
const resolvedBase = isUser && userEntry ? resolveStoredChannel(key, userEntry) : predefinedEntry;
|
|
1107
|
+
if (!resolvedBase) {
|
|
1108
|
+
// Defensive: the entry exists in at least one source per the allKeys construction, so this branch is unreachable.
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
// Variants are filtered out at the top of this loop via isServiceVariant, so resolvedBase reaching here is structurally canonical-shaped (a CanonicalChannel
|
|
1112
|
+
// from the catalog or a fully-resolved stored entry). Type as ResolvedChannel so the boundary rule in types/channels.ts holds: post-resolution values are
|
|
1113
|
+
// typed ResolvedChannel.
|
|
1114
|
+
const channel = resolvedBase;
|
|
532
1115
|
// When a non-default service is selected, resolve the variant so consumers see the correct URL, channelSelector, stationId, and channelNumber. We skip
|
|
533
|
-
// resolution when the resolved key matches the canonical key
|
|
1116
|
+
// resolution when the resolved key matches the canonical key - the channel object is already correct and preserving its reference avoids a redundant lookup.
|
|
534
1117
|
const resolvedKey = resolveServiceKey(key);
|
|
535
1118
|
const resolvedChannel = (resolvedKey !== key) ? getResolvedChannel(resolvedKey) : undefined;
|
|
536
1119
|
listing.push({
|
|
@@ -545,6 +1128,24 @@ export function getChannelListing() {
|
|
|
545
1128
|
listing.sort((a, b) => a.key.localeCompare(b.key));
|
|
546
1129
|
return listing;
|
|
547
1130
|
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Predicate for "visible" channels - entries that are both enabled and available under the current service filter. This is the single source of truth for what
|
|
1133
|
+
* "visible" means across bulk operations, the playlist, and the merged channel map. Every site that filters the listing by visibility routes through this
|
|
1134
|
+
* predicate so the definition lives in exactly one place.
|
|
1135
|
+
* @param entry - The listing entry to test.
|
|
1136
|
+
* @returns True when the channel is enabled and passes the service filter.
|
|
1137
|
+
*/
|
|
1138
|
+
export function isVisibleChannel(entry) {
|
|
1139
|
+
return entry.enabled && entry.availableByService;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Returns the subset of getChannelListing() that is enabled and available under the current service filter. Built on top of isVisibleChannel so the visibility
|
|
1143
|
+
* predicate lives in one place.
|
|
1144
|
+
* @returns Listing entries that pass the visibility predicate.
|
|
1145
|
+
*/
|
|
1146
|
+
export function getVisibleChannels() {
|
|
1147
|
+
return getChannelListing().filter(isVisibleChannel);
|
|
1148
|
+
}
|
|
548
1149
|
/**
|
|
549
1150
|
* Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded. Built on
|
|
550
1151
|
* top of getChannelListing() to ensure a single merging code path.
|
|
@@ -552,13 +1153,122 @@ export function getChannelListing() {
|
|
|
552
1153
|
*/
|
|
553
1154
|
export function getAllChannels() {
|
|
554
1155
|
const result = {};
|
|
555
|
-
for (const entry of
|
|
556
|
-
|
|
557
|
-
result[entry.key] = entry.channel;
|
|
558
|
-
}
|
|
1156
|
+
for (const entry of getVisibleChannels()) {
|
|
1157
|
+
result[entry.key] = entry.channel;
|
|
559
1158
|
}
|
|
560
1159
|
return result;
|
|
561
1160
|
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Returns a copy of the raw stored user channels map - the unresolved entries as they exist in channels.json. Used by the consistency probe to check
|
|
1163
|
+
* cross-store invariants without applying delta resolution. Callers should not rely on the shape for anything beyond key/canonicalKey inspection.
|
|
1164
|
+
* @returns Shallow copy of the loaded user channels.
|
|
1165
|
+
*/
|
|
1166
|
+
export function getStoredUserChannels() {
|
|
1167
|
+
return { ...loadedUserChannels };
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Computes the per-field reset value for a customized field. The reset value is what the field would resolve to in the user's effective view if the override
|
|
1171
|
+
* were removed - so it must apply the same effective-view rules consumers see (vocabulary-filtered tags, implicit-true hdhrEnabled). Field-specific dispatch
|
|
1172
|
+
* routes through the existing single-source helpers (getChannelEffectiveTags, getEffectiveHdhrEnabled) so the rules live in exactly one place.
|
|
1173
|
+
*
|
|
1174
|
+
* For canonical-stored fields, the reset value reads from the predefined canonical. For variant-stored fields, it reads from the predefined variant first,
|
|
1175
|
+
* falling back to the predefined canonical when the variant doesn't carry the field (variant-inheritance fallback). Returns undefined when no predefined
|
|
1176
|
+
* source has the field - a reset would clear the input.
|
|
1177
|
+
* @param field - The customized field name.
|
|
1178
|
+
* @param storedIn - Where the override is stored (canonical or variant).
|
|
1179
|
+
* @param predefinedCanonical - The predefined canonical entry (raw catalog read).
|
|
1180
|
+
* @param predefinedVariant - The predefined variant entry, if applicable (raw catalog read).
|
|
1181
|
+
* @returns The effective reset value, or undefined.
|
|
1182
|
+
*/
|
|
1183
|
+
function computeResetValue(field, storedIn, predefinedCanonical, predefinedVariant) {
|
|
1184
|
+
// Pick the right predefined source for this storage location. Variant-stored fields prefer the predefined variant; fall back to canonical when the variant
|
|
1185
|
+
// doesn't carry the field (e.g., a variant that inherits profile from canonical's URL-based detection).
|
|
1186
|
+
let source;
|
|
1187
|
+
if (storedIn === "canonical") {
|
|
1188
|
+
source = predefinedCanonical;
|
|
1189
|
+
}
|
|
1190
|
+
else if (predefinedVariant && (predefinedVariant[field] !== undefined)) {
|
|
1191
|
+
source = predefinedVariant;
|
|
1192
|
+
}
|
|
1193
|
+
else {
|
|
1194
|
+
source = predefinedCanonical;
|
|
1195
|
+
}
|
|
1196
|
+
if (!source) {
|
|
1197
|
+
return undefined;
|
|
1198
|
+
}
|
|
1199
|
+
// Delegate to the effective-view helpers for fields with view semantics. Channel structurally satisfies ResolvedChannel here (CanonicalChannel and
|
|
1200
|
+
// VariantChannel both assign), so no cast is needed at the boundary.
|
|
1201
|
+
switch (field) {
|
|
1202
|
+
case "tags": {
|
|
1203
|
+
return getChannelEffectiveTags(source);
|
|
1204
|
+
}
|
|
1205
|
+
case "hdhrEnabled": {
|
|
1206
|
+
return getEffectiveHdhrEnabled(source);
|
|
1207
|
+
}
|
|
1208
|
+
default: {
|
|
1209
|
+
return source[field];
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Returns the customization provenance for the given canonical channel. The form layer uses this as the single source of truth for which fields are
|
|
1215
|
+
* customized (instead of comparing resolved values to predefined values, which produces false positives when a non-canonical service is active and the
|
|
1216
|
+
* resolved value legitimately differs from the canonical's predefined value).
|
|
1217
|
+
*
|
|
1218
|
+
* Resolution: for each field present in the user's canonical-stored entry, mark customized with storedIn="canonical" and resetValue from computeResetValue.
|
|
1219
|
+
* For each field present in the active variant's stored entry, mark customized with storedIn="variant" and resetValue from computeResetValue (which handles
|
|
1220
|
+
* variant-inheritance fallback). Variant entries override canonical entries on the same field name because variant resolution overlays the variant's value
|
|
1221
|
+
* last.
|
|
1222
|
+
*
|
|
1223
|
+
* Pass 2 (variant-stored fields) restricts the walked field set via getAllowedFieldsForShape so identity fields encountered on a variant entry are silently
|
|
1224
|
+
* dropped from the customization map, matching the resolver's behavior. The "canonicalKey" field is similarly excluded from both passes - structural metadata,
|
|
1225
|
+
* not user-customizable.
|
|
1226
|
+
* @param canonicalKey - The canonical channel key.
|
|
1227
|
+
* @returns The customization provenance map and the active variant key (when applicable).
|
|
1228
|
+
*/
|
|
1229
|
+
export function getChannelCustomizations(canonicalKey) {
|
|
1230
|
+
const customizations = new Map();
|
|
1231
|
+
const predefinedCanonical = PREDEFINED_CHANNELS[canonicalKey];
|
|
1232
|
+
// Determine the active variant via the service resolver. resolveServiceKey returns the canonical key itself when the canonical service is selected (no
|
|
1233
|
+
// override is needed) and the variant key otherwise (whether via stored selection or filter fallback).
|
|
1234
|
+
const resolvedKey = resolveServiceKey(canonicalKey);
|
|
1235
|
+
const activeVariantKey = (resolvedKey === canonicalKey) ? undefined : resolvedKey;
|
|
1236
|
+
// Walk a stored entry against its shape's allowed-field set, recording each customized field. The shape rule is computed via getAllowedFieldsForShape so
|
|
1237
|
+
// canonical-stored entries see the full delta surface and variant-stored entries see only the variant binding-only subset (identity-on-variants is dropped).
|
|
1238
|
+
const recordCustomizations = (storedKey, stored, storedIn, predefinedVariant) => {
|
|
1239
|
+
const allowed = getAllowedFieldsForShape(storedKey, stored);
|
|
1240
|
+
for (const [field, value] of Object.entries(stored)) {
|
|
1241
|
+
// canonicalKey is structural metadata, not a customization. Other non-allowed fields (e.g., orphan identity on a variant) are silently ignored here -
|
|
1242
|
+
// the normalizer's filterToDeltaSurface will strip them on next save with a warning.
|
|
1243
|
+
if (!allowed.has(field) || (field === "canonicalKey")) {
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (value === undefined) {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
// Cast acknowledges that field is one of the allowed customizable names per the allowlist check above.
|
|
1250
|
+
customizations.set(field, {
|
|
1251
|
+
resetValue: computeResetValue(field, storedIn, predefinedCanonical, predefinedVariant),
|
|
1252
|
+
storedIn
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
// Pass 1: canonical-stored customizations. The canonical entry's allowed shape is the full delta surface (identity + binding delta-eligible).
|
|
1257
|
+
const canonicalStored = loadedUserChannels[canonicalKey];
|
|
1258
|
+
if (canonicalStored) {
|
|
1259
|
+
recordCustomizations(canonicalKey, canonicalStored, "canonical", undefined);
|
|
1260
|
+
}
|
|
1261
|
+
// Pass 2: variant-stored customizations. The variant entry's allowed shape is binding-only. Variant entries override canonical entries on the same field
|
|
1262
|
+
// name (last-overlay-wins matches resolution semantics).
|
|
1263
|
+
if (activeVariantKey) {
|
|
1264
|
+
const variantStored = loadedUserChannels[activeVariantKey];
|
|
1265
|
+
if (variantStored) {
|
|
1266
|
+
const predefinedVariant = PREDEFINED_CHANNELS[activeVariantKey];
|
|
1267
|
+
recordCustomizations(activeVariantKey, variantStored, "variant", predefinedVariant);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return { activeVariantKey, customizations };
|
|
1271
|
+
}
|
|
562
1272
|
/**
|
|
563
1273
|
* Returns the raw stored channel data (without predefined channels). Entries may be full Channel definitions or ChannelDelta overrides.
|
|
564
1274
|
* @returns The stored channel map.
|
|
@@ -575,11 +1285,17 @@ export function getTagRegistry() {
|
|
|
575
1285
|
return { deletedTags: [...loadedTagRegistry.deletedTags], tags: [...loadedTagRegistry.tags] };
|
|
576
1286
|
}
|
|
577
1287
|
/**
|
|
578
|
-
*
|
|
1288
|
+
* Persists a new tag registry state. Goes through mutateChannels so the file write, integrity validation, and post-mutate cache hydration all run uniformly -
|
|
1289
|
+
* after the call returns, both disk and module-state cache reflect the new value. The caller's input arrays are not mutated; both are sorted non-destructively
|
|
1290
|
+
* via sortTags so storage shares one canonical ordering.
|
|
579
1291
|
* @param registry - The new tag registry state.
|
|
1292
|
+
* @throws FileStoreParseError if channels.json contains invalid JSON and the .bak rotation is also unparseable.
|
|
580
1293
|
*/
|
|
581
|
-
export function setTagRegistry(registry) {
|
|
582
|
-
|
|
1294
|
+
export async function setTagRegistry(registry) {
|
|
1295
|
+
const sorted = { deletedTags: sortTags(registry.deletedTags), tags: sortTags(registry.tags) };
|
|
1296
|
+
await mutateChannels((data) => {
|
|
1297
|
+
data.tagRegistry = sorted;
|
|
1298
|
+
});
|
|
583
1299
|
}
|
|
584
1300
|
/**
|
|
585
1301
|
* Returns the active tag vocabulary: predefined tags minus user-deleted tags, plus user-created tags, sorted alphabetically. This is the single source of truth
|
|
@@ -601,10 +1317,11 @@ export function getActiveTagVocabulary() {
|
|
|
601
1317
|
return [...seen.values()].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
602
1318
|
}
|
|
603
1319
|
/**
|
|
604
|
-
* Returns a channel's effective tags
|
|
605
|
-
* the active vocabulary are filtered out, ensuring only assignable and queryable tags are visible in the UI and playlist responses.
|
|
1320
|
+
* Returns a channel's effective tags - the intersection of the channel's assigned tags with the active vocabulary. Tags that exist on the channel but are not in
|
|
1321
|
+
* the active vocabulary are filtered out, ensuring only assignable and queryable tags are visible in the UI and playlist responses. Output preserves the source
|
|
1322
|
+
* order of channel.tags; every write path (sortTags) keeps that source order canonical, so in practice the returned array is sorted.
|
|
606
1323
|
* @param channel - The channel to get effective tags for.
|
|
607
|
-
* @returns
|
|
1324
|
+
* @returns Effective tag strings in source order, or empty array if the channel has no tags or none are in the active vocabulary.
|
|
608
1325
|
*/
|
|
609
1326
|
export function getChannelEffectiveTags(channel) {
|
|
610
1327
|
if (!channel.tags || (channel.tags.length === 0)) {
|
|
@@ -613,9 +1330,20 @@ export function getChannelEffectiveTags(channel) {
|
|
|
613
1330
|
const vocabulary = getActiveTagVocabulary();
|
|
614
1331
|
return channel.tags.filter((tag) => vocabulary.some((v) => tagsMatch(v, tag)));
|
|
615
1332
|
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Returns whether a channel is effectively included in the HDHomeRun lineup. Codifies the sparse-storage convention that absent or true means included, and only
|
|
1335
|
+
* an explicit `false` means excluded. This is the single source of truth for the convention - every site that needs to ask "is HDHR enabled for this channel?"
|
|
1336
|
+
* routes through this helper rather than reaching for the inline `channel.hdhrEnabled !== false` pattern. Storage-shape inspections that ask a different
|
|
1337
|
+
* question ("did the client send literal false?", "did the user override?") stay inline because they are not asking about effective state.
|
|
1338
|
+
* @param channel - The channel to evaluate.
|
|
1339
|
+
* @returns True when the channel is effectively included in the HDHR lineup.
|
|
1340
|
+
*/
|
|
1341
|
+
export function getEffectiveHdhrEnabled(channel) {
|
|
1342
|
+
return channel.hdhrEnabled !== false;
|
|
1343
|
+
}
|
|
616
1344
|
/**
|
|
617
1345
|
* Case-insensitive tag comparison. Tags are freeform strings with preserved casing, but all matching throughout the system is case-insensitive. This function
|
|
618
|
-
* is the single source of truth for that policy
|
|
1346
|
+
* is the single source of truth for that policy - all tag identity checks should use it rather than inline toLowerCase() calls.
|
|
619
1347
|
* @param a - The first tag.
|
|
620
1348
|
* @param b - The second tag.
|
|
621
1349
|
* @returns True if the tags match case-insensitively.
|
|
@@ -624,10 +1352,20 @@ export function tagsMatch(a, b) {
|
|
|
624
1352
|
return a.toLowerCase() === b.toLowerCase();
|
|
625
1353
|
}
|
|
626
1354
|
/**
|
|
627
|
-
*
|
|
1355
|
+
* Checks whether a tag is present in the active vocabulary under case-insensitive identity. This is the single source of truth for "does this tag exist?"
|
|
1356
|
+
* questions - tag endpoints (create/rename collision, bulk-tags vocabulary check) route through this instead of ad-hoc Set+toLowerCase comparisons so the
|
|
1357
|
+
* case-insensitive policy lives in exactly one place (tagsMatch), not in parallel implementations.
|
|
1358
|
+
* @param tag - The tag to check.
|
|
1359
|
+
* @returns True if the active vocabulary contains a tag matching case-insensitively.
|
|
1360
|
+
*/
|
|
1361
|
+
export function isInVocabulary(tag) {
|
|
1362
|
+
return getActiveTagVocabulary().some((v) => tagsMatch(v, tag));
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Applies a tag transformation across channels and persists the result. This is the single source of truth for batch tag mutations - delete, rename, and bulk
|
|
628
1366
|
* toggle all route through this function. The caller provides a filter (which channels to transform) and a transform (how to modify each channel's tags). This
|
|
629
1367
|
* function handles loading stored channel data, applying the transform, and saving. Delta normalization in mutateChannels() handles predefined channel
|
|
630
|
-
* delta computation automatically
|
|
1368
|
+
* delta computation automatically - callers do not need to reason about deltas vs. full definitions.
|
|
631
1369
|
* @param filter - Predicate selecting which listing entries to transform. Receives each ChannelListingEntry from getChannelListing().
|
|
632
1370
|
* @param transform - Pure function mapping a channel's current resolved tags to its new tags. Receives the channel's current tags array (may be empty, ordering
|
|
633
1371
|
* not guaranteed). Must return the desired tags array (may be empty to clear all tags). The returned array is sorted before storage.
|
|
@@ -636,22 +1374,24 @@ export function tagsMatch(a, b) {
|
|
|
636
1374
|
export async function transformChannelTags(filter, transform) {
|
|
637
1375
|
const affectedKeys = [];
|
|
638
1376
|
try {
|
|
639
|
-
await mutateChannels((
|
|
1377
|
+
await mutateChannels((data) => {
|
|
640
1378
|
for (const entry of getChannelListing()) {
|
|
641
1379
|
if (!filter(entry)) {
|
|
642
1380
|
continue;
|
|
643
1381
|
}
|
|
644
1382
|
const currentTags = entry.channel.tags ?? [];
|
|
645
|
-
|
|
646
|
-
//
|
|
647
|
-
|
|
1383
|
+
// Route both the transform result and the "no change" comparison through sortTags so tag storage shares one canonical ordering with the rest of the
|
|
1384
|
+
// system. sortTags is non-mutating, which matters here because for predefined-only listing entries, entry.channel is the PREDEFINED_CHANNELS reference
|
|
1385
|
+
// directly - a raw .sort() on entry.channel.tags would rearrange the predefined array in process memory.
|
|
1386
|
+
const newTags = sortTags(transform(currentTags));
|
|
1387
|
+
if (isDeepStrictEqual(newTags, sortTags(currentTags))) {
|
|
648
1388
|
continue;
|
|
649
1389
|
}
|
|
650
1390
|
// Set the new tags on the stored entry. Callers use null uniformly for "clear/empty" - the normalizer in mutateChannels() handles the storage
|
|
651
1391
|
// conventions: delta normalization for predefined channels (comparing against raw definitions), null-stripping for user channels.
|
|
652
|
-
const existing = channels[entry.key] ?? {};
|
|
653
|
-
existing
|
|
654
|
-
channels[entry.key] = existing;
|
|
1392
|
+
const existing = data.channels[entry.key] ?? {};
|
|
1393
|
+
existing["tags"] = (newTags.length > 0) ? newTags : null;
|
|
1394
|
+
data.channels[entry.key] = existing;
|
|
655
1395
|
affectedKeys.push(entry.key);
|
|
656
1396
|
}
|
|
657
1397
|
});
|
|
@@ -664,13 +1404,48 @@ export async function transformChannelTags(filter, transform) {
|
|
|
664
1404
|
}
|
|
665
1405
|
return { affectedKeys };
|
|
666
1406
|
}
|
|
1407
|
+
/* Dangling-canonical warning dedup for getPredefinedChannel. The catalog flattener should make a missing canonical structurally impossible, so observing one
|
|
1408
|
+
* indicates real data corruption that an operator needs to know about. We log once per variant key for the lifetime of this process - a tight loop calling
|
|
1409
|
+
* getPredefinedChannel on the corrupt entry would otherwise spam the log on every form render.
|
|
1410
|
+
*/
|
|
1411
|
+
const warnedMissingCanonicalForPredefined = new Set();
|
|
667
1412
|
/**
|
|
668
|
-
* Returns the predefined channel definition for a key.
|
|
1413
|
+
* Returns the predefined channel definition for a key as a fully-resolved view. For predefined variant entries (which carry only service-specific fields plus
|
|
1414
|
+
* canonicalKey), overlays the variant's binding onto its predefined canonical so callers receive the resolved view - canonical identity inherited onto the
|
|
1415
|
+
* variant. This matches what the user sees in the form/table and is the correct base for delta-from-predefined computations in crud.ts and channelForm.ts.
|
|
1416
|
+
*
|
|
1417
|
+
* Always returns ResolvedChannel-shaped data: identity fields (name, tags, hdhrEnabled, etc.) are always available, sourced from the canonical when this key is
|
|
1418
|
+
* a variant. CanonicalChannel and VariantChannel both assign structurally to ResolvedChannel since the identity fields are optional in both types.
|
|
1419
|
+
*
|
|
1420
|
+
* Returns undefined when the key is not predefined OR when the catalog is internally inconsistent (variant references a missing canonical). The latter case is
|
|
1421
|
+
* structurally impossible by construction of the flattener, so observing it is a real corruption indicator: the function logs once per offending key and
|
|
1422
|
+
* returns undefined rather than fabricating a half-resolved view that would lie about its identity fields.
|
|
669
1423
|
* @param key - The channel key to look up.
|
|
670
|
-
* @returns The predefined channel, or undefined if the key is not predefined.
|
|
1424
|
+
* @returns The resolved predefined channel, or undefined if the key is not predefined or the catalog is inconsistent.
|
|
671
1425
|
*/
|
|
672
1426
|
export function getPredefinedChannel(key) {
|
|
673
|
-
|
|
1427
|
+
const entry = PREDEFINED_CHANNELS[key];
|
|
1428
|
+
if (!entry) {
|
|
1429
|
+
return undefined;
|
|
1430
|
+
}
|
|
1431
|
+
// Canonical entries have full identity already; return as-is. CanonicalChannel assigns to ResolvedChannel structurally (canonicalKey: never is assignable to
|
|
1432
|
+
// canonicalKey?: string).
|
|
1433
|
+
if (!entry.canonicalKey || (entry.canonicalKey === key)) {
|
|
1434
|
+
return entry;
|
|
1435
|
+
}
|
|
1436
|
+
// Predefined variant: overlay its binding fields onto the canonical so callers see the resolved view with canonical identity. Variant overlays apply
|
|
1437
|
+
// binding fields only per the architectural principle - matches resolveVariant's behavior so callers get the same view regardless of which path produced it.
|
|
1438
|
+
const canonical = PREDEFINED_CHANNELS[entry.canonicalKey];
|
|
1439
|
+
if (!canonical) {
|
|
1440
|
+
// Catalog inconsistency. Log once per offending key, then return undefined. Returning a half-resolved view (the variant typed as ResolvedChannel) would
|
|
1441
|
+
// lie about which identity fields are reachable and silently hide the corruption from consumers.
|
|
1442
|
+
if (!warnedMissingCanonicalForPredefined.has(key)) {
|
|
1443
|
+
warnedMissingCanonicalForPredefined.add(key);
|
|
1444
|
+
LOG.warn("Catalog inconsistency: predefined variant '%s' references missing canonical '%s'. Returning undefined; the flattener should make this impossible.", key, entry.canonicalKey);
|
|
1445
|
+
}
|
|
1446
|
+
return undefined;
|
|
1447
|
+
}
|
|
1448
|
+
return overlayVariantBinding(canonical, entry);
|
|
674
1449
|
}
|
|
675
1450
|
/**
|
|
676
1451
|
* Checks if a channel key exists in the predefined channels.
|
|
@@ -733,11 +1508,17 @@ export function getChannelStationId(channelKey) {
|
|
|
733
1508
|
// Pacific channels are regional feeds of the same brand as their east counterparts, so we resolve to the east variant's station ID for logo purposes.
|
|
734
1509
|
const effectiveKey = getEastCanonicalKey(channelKey) ?? channelKey;
|
|
735
1510
|
// User channels take precedence - resolveStoredChannel merges deltas with the predefined base, so overrides that change the stationId are reflected here.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1511
|
+
const userEntry = loadedUserChannels[effectiveKey];
|
|
1512
|
+
if (userEntry) {
|
|
1513
|
+
return resolveStoredChannel(effectiveKey, userEntry).stationId;
|
|
1514
|
+
}
|
|
1515
|
+
// PREDEFINED_CHANNELS is a discriminated union; only canonicals carry stationId. The lookup here is by canonical-equivalent key (Pacific resolved to East),
|
|
1516
|
+
// so a hit is structurally a canonical. Narrow via the canonicalKey discriminator: a defined canonicalKey marks a VariantChannel, which has no stationId.
|
|
1517
|
+
const predefined = PREDEFINED_CHANNELS[effectiveKey];
|
|
1518
|
+
if (!predefined || (predefined.canonicalKey !== undefined)) {
|
|
1519
|
+
return undefined;
|
|
1520
|
+
}
|
|
1521
|
+
return predefined.stationId;
|
|
741
1522
|
}
|
|
742
1523
|
/**
|
|
743
1524
|
* Returns the logo URL for a channel from the cache. Pacific channels (e.g., "bravop") are resolved to their east counterpart automatically. The cache is
|
|
@@ -764,7 +1545,7 @@ export function isUserChannel(key) {
|
|
|
764
1545
|
* re-enable. This is useful for users who don't want certain predefined channels cluttering their channel list.
|
|
765
1546
|
*/
|
|
766
1547
|
/**
|
|
767
|
-
* Checks if a predefined channel is disabled. The disabled state is determined solely by the disabledPredefined list in config
|
|
1548
|
+
* Checks if a predefined channel is disabled. The disabled state is determined solely by the disabledPredefined list in config - the user's explicit visibility
|
|
768
1549
|
* intent. Property overrides (HDHR toggle, name change, tag edits) stored as deltas in channels.json are orthogonal and do not affect the enabled/disabled state.
|
|
769
1550
|
* @param key - The channel key to check.
|
|
770
1551
|
* @returns True if the channel is predefined and disabled.
|
|
@@ -782,6 +1563,84 @@ export function isPredefinedChannelDisabled(key) {
|
|
|
782
1563
|
export function getDisabledPredefinedChannels() {
|
|
783
1564
|
return [...CONFIG.channels.disabledPredefined];
|
|
784
1565
|
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Applies a set operation (add or delete) to the disabledPredefined list in user config. Persists the change to config.json and syncs the runtime CONFIG object
|
|
1568
|
+
* so subsequent reads see the updated state immediately. This is the internal implementation shared by disablePredefinedChannels and enablePredefinedChannels so
|
|
1569
|
+
* the mutateConfig scaffolding, sort, and CONFIG sync live in exactly one place.
|
|
1570
|
+
* @param op - "add" to insert keys into the disabled list, "delete" to remove them.
|
|
1571
|
+
* @param keys - The predefined channel keys to apply the operation to.
|
|
1572
|
+
*/
|
|
1573
|
+
async function mutateDisabledPredefined(op, keys) {
|
|
1574
|
+
if (keys.length === 0) {
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
let updatedList = [];
|
|
1578
|
+
await mutateConfig((config) => {
|
|
1579
|
+
config.channels ??= {};
|
|
1580
|
+
config.channels.disabledPredefined ??= [];
|
|
1581
|
+
const disabledSet = new Set(config.channels.disabledPredefined);
|
|
1582
|
+
for (const key of keys) {
|
|
1583
|
+
disabledSet[op](key);
|
|
1584
|
+
}
|
|
1585
|
+
config.channels.disabledPredefined = [...disabledSet].toSorted();
|
|
1586
|
+
updatedList = config.channels.disabledPredefined;
|
|
1587
|
+
});
|
|
1588
|
+
CONFIG.channels.disabledPredefined = updatedList;
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Disables one or more predefined channels by adding their keys to the disabledPredefined list in user config. Idempotent.
|
|
1592
|
+
* @param keys - The predefined channel keys to disable. Duplicates within the input array and pre-existing disabled entries are handled idempotently.
|
|
1593
|
+
*/
|
|
1594
|
+
export async function disablePredefinedChannels(keys) {
|
|
1595
|
+
await mutateDisabledPredefined("add", keys);
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Enables one or more predefined channels by removing their keys from the disabledPredefined list in user config. Idempotent.
|
|
1599
|
+
* @param keys - The predefined channel keys to enable. Entries that aren't in the disabled list are ignored.
|
|
1600
|
+
*/
|
|
1601
|
+
export async function enablePredefinedChannels(keys) {
|
|
1602
|
+
await mutateDisabledPredefined("delete", keys);
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Persists a partial update of channel-table display preferences (sort field, sort direction, visible columns) to config.json. Only fields present in `prefs`
|
|
1606
|
+
* are written; absent fields are left untouched. The mutate fn copies the new values into data and the runtime CONFIG cache is updated post-write to match.
|
|
1607
|
+
* Single-call replacement for the prior set-then-save pattern, consistent with mutateServiceSelections, mutateEnabledServices, and setTagRegistry.
|
|
1608
|
+
* @param prefs - Subset of display preferences to update.
|
|
1609
|
+
* @throws FileStoreParseError if config.json contains invalid JSON and the .bak rotation is also unparseable.
|
|
1610
|
+
*/
|
|
1611
|
+
export async function mutateChannelDisplayPrefs(prefs) {
|
|
1612
|
+
const next = {
|
|
1613
|
+
channelSortDirection: prefs.channelSortDirection ?? CONFIG.channels.channelSortDirection,
|
|
1614
|
+
channelSortField: prefs.channelSortField ?? CONFIG.channels.channelSortField,
|
|
1615
|
+
visibleColumns: prefs.visibleColumns ? [...prefs.visibleColumns] : [...CONFIG.channels.visibleColumns]
|
|
1616
|
+
};
|
|
1617
|
+
await mutateConfig((config) => {
|
|
1618
|
+
config.channels ??= {};
|
|
1619
|
+
config.channels.channelSortDirection = next.channelSortDirection;
|
|
1620
|
+
config.channels.channelSortField = next.channelSortField;
|
|
1621
|
+
config.channels.visibleColumns = next.visibleColumns;
|
|
1622
|
+
});
|
|
1623
|
+
// Hydrate the runtime CONFIG cache from the values just written.
|
|
1624
|
+
CONFIG.channels.channelSortDirection = next.channelSortDirection;
|
|
1625
|
+
CONFIG.channels.channelSortField = next.channelSortField;
|
|
1626
|
+
CONFIG.channels.visibleColumns = next.visibleColumns;
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Marks the first-run Service Setup wizard as completed. Writes the flag to runtime CONFIG and persists to config.json in one call - the operation is a single
|
|
1630
|
+
* one-way transition (setupCompleted goes from false/absent to true once, never back), so splitting into set+save would be ceremony without benefit.
|
|
1631
|
+
*
|
|
1632
|
+
* Persistence note: setupCompleted is intentionally absent from CONFIG_METADATA and PRESERVED_FIELDS in userConfig.ts, so filterDefaults strips it from the
|
|
1633
|
+
* on-disk shape during the write below. This is by design - the flag is observably derived from "has the user configured any services or channels" and
|
|
1634
|
+
* initializeUserChannels() re-infers it at startup from that observable state. The mutateConfig call here is a no-op on the file but keeps the runtime
|
|
1635
|
+
* mutation pathway uniform with every other CONFIG.channels writer; the flag survives across restarts via inference, not persistence.
|
|
1636
|
+
*/
|
|
1637
|
+
export async function markSetupCompleted() {
|
|
1638
|
+
CONFIG.channels.setupCompleted = true;
|
|
1639
|
+
await mutateConfig((config) => {
|
|
1640
|
+
config.channels ??= {};
|
|
1641
|
+
config.channels.setupCompleted = true;
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
785
1644
|
/**
|
|
786
1645
|
* Returns all predefined channels regardless of disabled state, excluding service variants. Used by the UI to show all predefined channels including disabled ones.
|
|
787
1646
|
* Service variants are internal implementation details of channel delivery and are not channels themselves.
|
|
@@ -803,14 +1662,14 @@ export function getPredefinedChannels() {
|
|
|
803
1662
|
/**
|
|
804
1663
|
* Filters predefined channel keys by their relationship to the Pacific timezone naming convention. For "pacific" mode, returns keys that end in "p" and
|
|
805
1664
|
* whose East counterpart (key minus trailing "p") exists. For "east" mode, returns keys that do NOT end in "p" and whose Pacific counterpart (key plus "p")
|
|
806
|
-
* exists. Service variants are excluded
|
|
1665
|
+
* exists. Service variants are excluded - only canonical keys are returned.
|
|
807
1666
|
* @param side - Which side of the East/Pacific pair to select.
|
|
808
1667
|
* @returns Sorted array of matching canonical predefined channel keys.
|
|
809
1668
|
*/
|
|
810
1669
|
function filterPredefinedKeysByTimezone(side) {
|
|
811
1670
|
const keys = [];
|
|
812
1671
|
for (const key of Object.keys(PREDEFINED_CHANNELS)) {
|
|
813
|
-
// Skip service variants
|
|
1672
|
+
// Skip service variants - they are internal implementation details, not channels.
|
|
814
1673
|
if (isServiceVariant(key)) {
|
|
815
1674
|
continue;
|
|
816
1675
|
}
|
|
@@ -1004,29 +1863,29 @@ export function validateImportedChannels(data, validProfiles) {
|
|
|
1004
1863
|
}
|
|
1005
1864
|
const channelData = value;
|
|
1006
1865
|
// Validate required name field. Sanitize after type check to strip non-printable characters from imported data.
|
|
1007
|
-
if ((typeof channelData
|
|
1866
|
+
if ((typeof channelData["name"] !== "string") || (channelData["name"].trim() === "")) {
|
|
1008
1867
|
errors.push("Channel '" + key + "': name is required.");
|
|
1009
1868
|
continue;
|
|
1010
1869
|
}
|
|
1011
|
-
const cleanName = sanitizeString(channelData
|
|
1870
|
+
const cleanName = sanitizeString(channelData["name"]);
|
|
1012
1871
|
const nameError = validateChannelName(cleanName);
|
|
1013
1872
|
if (nameError) {
|
|
1014
1873
|
errors.push("Channel '" + key + "': " + nameError);
|
|
1015
1874
|
continue;
|
|
1016
1875
|
}
|
|
1017
1876
|
// Validate required url field. Sanitize after type check.
|
|
1018
|
-
if ((typeof channelData
|
|
1877
|
+
if ((typeof channelData["url"] !== "string") || (channelData["url"].trim() === "")) {
|
|
1019
1878
|
errors.push("Channel '" + key + "': url is required.");
|
|
1020
1879
|
continue;
|
|
1021
1880
|
}
|
|
1022
|
-
const cleanUrl = sanitizeString(channelData
|
|
1881
|
+
const cleanUrl = sanitizeString(channelData["url"]);
|
|
1023
1882
|
const urlError = validateChannelUrl(cleanUrl);
|
|
1024
1883
|
if (urlError) {
|
|
1025
1884
|
errors.push("Channel '" + key + "': " + urlError);
|
|
1026
1885
|
continue;
|
|
1027
1886
|
}
|
|
1028
1887
|
// Validate optional profile field. Sanitize after type check.
|
|
1029
|
-
const profile = (typeof channelData
|
|
1888
|
+
const profile = (typeof channelData["profile"] === "string") ? sanitizeString(channelData["profile"]) : undefined;
|
|
1030
1889
|
const profileError = validateChannelProfile(profile, validProfiles);
|
|
1031
1890
|
if (profileError) {
|
|
1032
1891
|
errors.push("Channel '" + key + "': " + profileError);
|
|
@@ -1041,15 +1900,15 @@ export function validateImportedChannels(data, validProfiles) {
|
|
|
1041
1900
|
channel.profile = profile;
|
|
1042
1901
|
}
|
|
1043
1902
|
// Include optional fields if present. Sanitize string values to strip non-printable characters.
|
|
1044
|
-
if (typeof channelData
|
|
1045
|
-
channel.stationId = sanitizeString(channelData
|
|
1903
|
+
if (typeof channelData["stationId"] === "string") {
|
|
1904
|
+
channel.stationId = sanitizeString(channelData["stationId"]);
|
|
1046
1905
|
}
|
|
1047
|
-
if (typeof channelData
|
|
1048
|
-
channel.channelSelector = sanitizeString(channelData
|
|
1906
|
+
if (typeof channelData["channelSelector"] === "string") {
|
|
1907
|
+
channel.channelSelector = sanitizeString(channelData["channelSelector"]);
|
|
1049
1908
|
}
|
|
1050
1909
|
// Validate optional channelNumber field (range and type).
|
|
1051
|
-
if (channelData
|
|
1052
|
-
const num = Number(channelData
|
|
1910
|
+
if (channelData["channelNumber"] !== undefined) {
|
|
1911
|
+
const num = Number(channelData["channelNumber"]);
|
|
1053
1912
|
if (!Number.isInteger(num) || (num < 1) || (num > 99999)) {
|
|
1054
1913
|
errors.push("Channel '" + key + "': channelNumber must be an integer between 1 and 99999.");
|
|
1055
1914
|
continue;
|
|
@@ -1057,8 +1916,8 @@ export function validateImportedChannels(data, validProfiles) {
|
|
|
1057
1916
|
channel.channelNumber = num;
|
|
1058
1917
|
}
|
|
1059
1918
|
// Validate optional tvgShift field (must be a finite number). Negative values are valid (e.g., a West Coast feed viewed from the East).
|
|
1060
|
-
if (channelData
|
|
1061
|
-
const shift = Number(channelData
|
|
1919
|
+
if (channelData["tvgShift"] !== undefined) {
|
|
1920
|
+
const shift = Number(channelData["tvgShift"]);
|
|
1062
1921
|
if (!Number.isFinite(shift)) {
|
|
1063
1922
|
errors.push("Channel '" + key + "': tvgShift must be a finite number.");
|
|
1064
1923
|
continue;
|
|
@@ -1067,10 +1926,12 @@ export function validateImportedChannels(data, validProfiles) {
|
|
|
1067
1926
|
}
|
|
1068
1927
|
channels[key] = channel;
|
|
1069
1928
|
}
|
|
1070
|
-
// Validate channelNumber uniqueness across all imported channels. We check after building the full map so that all duplicates are reported.
|
|
1929
|
+
// Validate channelNumber uniqueness across all imported channels. We check after building the full map so that all duplicates are reported. channelNumber
|
|
1930
|
+
// is identity (canonical-only by architectural principle); the validator above only constructs canonical-shaped entries, but the StoredChannelMap is typed
|
|
1931
|
+
// as the union, so we narrow with an in-check before reading.
|
|
1071
1932
|
const numberToKey = new Map();
|
|
1072
1933
|
for (const [key, channel] of Object.entries(channels)) {
|
|
1073
|
-
if (channel.channelNumber === undefined) {
|
|
1934
|
+
if (!("channelNumber" in channel) || (channel.channelNumber === undefined)) {
|
|
1074
1935
|
continue;
|
|
1075
1936
|
}
|
|
1076
1937
|
const existing = numberToKey.get(channel.channelNumber);
|
|
@@ -1083,25 +1944,53 @@ export function validateImportedChannels(data, validProfiles) {
|
|
|
1083
1944
|
}
|
|
1084
1945
|
return { channels, errors, valid: errors.length === 0 };
|
|
1085
1946
|
}
|
|
1086
|
-
/* Service selections are stored in the channels.json file alongside user channels. When a selection changes, we save the entire file (channels + selections)
|
|
1087
|
-
* to persist the change.
|
|
1088
|
-
*/
|
|
1089
1947
|
/**
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1948
|
+
* Sorts tags case-insensitively using locale-aware comparison. This is the single source of truth for tag ordering. Every write path (parseTagInput, PATCH
|
|
1949
|
+
* handlers, computePredefinedDelta, transformChannelTags, setTagRegistry) routes through this so stored tag arrays share one canonical ordering. The channels
|
|
1950
|
+
* normalizer is a READER of that invariant - it uses sortTags on both sides when comparing a delta's tags against a predefined's tags, making the JSON.stringify
|
|
1951
|
+
* equality check canonical regardless of how either side was originally populated.
|
|
1952
|
+
* @param tags - Any iterable of tag strings (array, Set, generator). Not mutated.
|
|
1953
|
+
* @returns A new array with the same elements in canonical case-insensitive order.
|
|
1093
1954
|
*/
|
|
1094
|
-
export
|
|
1095
|
-
|
|
1096
|
-
await mutateChannels(() => { });
|
|
1955
|
+
export function sortTags(tags) {
|
|
1956
|
+
return Array.from(tags).toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
1097
1957
|
}
|
|
1098
1958
|
/**
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
* @
|
|
1959
|
+
* Parses a comma-separated tag input string into a normalized tag array. Trims whitespace, drops empty entries, deduplicates case-sensitively via Set (so
|
|
1960
|
+
* distinct casings survive as distinct tags), then sorts via sortTags so every caller produces identical normalized arrays.
|
|
1961
|
+
* @param raw - The comma-separated tag input from a form field or inline editor. An empty or whitespace-only string returns an empty array.
|
|
1962
|
+
* @returns Sorted, case-sensitively-deduplicated array of trimmed tag strings.
|
|
1102
1963
|
*/
|
|
1103
|
-
export
|
|
1104
|
-
|
|
1105
|
-
|
|
1964
|
+
export function parseTagInput(raw) {
|
|
1965
|
+
if (!raw) {
|
|
1966
|
+
return [];
|
|
1967
|
+
}
|
|
1968
|
+
return sortTags(new Set(raw.split(",").map((t) => t.trim()).filter((t) => t.length > 0)));
|
|
1106
1969
|
}
|
|
1970
|
+
/* Test-only export barrel. The helpers below are intentionally file-private for production code - the resolver, normalizer, and migration classifier are
|
|
1971
|
+
* internal building blocks, and exposing them as public API would invite misuse from routes and streaming code that should be going through the higher-level
|
|
1972
|
+
* functions above. Unit tests need direct access to exercise each building block in isolation, so they reach in through this named export. Production code
|
|
1973
|
+
* must not import from this barrel; its shape is not stable and its presence in the module carries no API guarantee.
|
|
1974
|
+
*/
|
|
1975
|
+
export const __internalForTests = {
|
|
1976
|
+
applyOverlayKernel,
|
|
1977
|
+
buildResolvedCanonicals,
|
|
1978
|
+
channelsMigrations,
|
|
1979
|
+
classifyEntry,
|
|
1980
|
+
collectLegacyVariantStamps,
|
|
1981
|
+
detectIdentityFieldLoss,
|
|
1982
|
+
filterToDeltaSurface,
|
|
1983
|
+
getAllowedFieldsForShape,
|
|
1984
|
+
getMergedChannelMap,
|
|
1985
|
+
normalizeChannelDeltas,
|
|
1986
|
+
normalizeEntryAgainstBase,
|
|
1987
|
+
overlayDelta,
|
|
1988
|
+
overlayVariantBinding,
|
|
1989
|
+
parseChannelsFile,
|
|
1990
|
+
prepareChannelsForWrite,
|
|
1991
|
+
resolveVariant,
|
|
1992
|
+
stripNulls,
|
|
1993
|
+
validateChannelsIntegrity,
|
|
1994
|
+
warnDanglingCanonical
|
|
1995
|
+
};
|
|
1107
1996
|
//# sourceMappingURL=userChannels.js.map
|