prismcast 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/app.js +24 -13
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +4 -6
- package/dist/browser/cdp.js +13 -17
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/index.js +3 -3
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.js +11 -8
- package/dist/browser/login.js.map +1 -1
- package/dist/browser/precaching.d.ts +1 -1
- package/dist/browser/precaching.js +23 -23
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/directv.js +4 -7
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +1 -1
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +3 -3
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.js +2 -2
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.js +438 -252
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +13 -18
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +11 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/persistence.d.ts +59 -0
- package/dist/config/persistence.js +127 -0
- package/dist/config/persistence.js.map +1 -0
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/servicePacks.d.ts +46 -0
- package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
- package/dist/config/servicePacks.js.map +1 -0
- package/dist/config/services.d.ts +202 -0
- package/dist/config/services.js +758 -0
- package/dist/config/services.js.map +1 -0
- package/dist/config/sites.js +61 -61
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +89 -27
- package/dist/config/userChannels.js +400 -184
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +13 -9
- package/dist/config/userConfig.js +78 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +154 -123
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -7
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +17 -27
- package/dist/native/index.js.map +1 -1
- package/dist/native/intercept.js +86 -89
- package/dist/native/intercept.js.map +1 -1
- package/dist/native/probe.js +2 -2
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -2
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +29 -51
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/auth.js +3 -3
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +0 -8
- package/dist/routes/components.js +0 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +1 -1
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +748 -420
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +21 -6
- package/dist/routes/config/channels/table.js +456 -193
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +2 -2
- package/dist/routes/config/index.js +3 -3
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/{providers.js → services.js} +47 -42
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +88 -49
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +5 -7
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/icons.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +4 -4
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +98 -45
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +94 -55
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +620 -478
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +884 -964
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +468 -188
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +252 -207
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +57 -8
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/services.d.ts +6 -0
- package/dist/routes/{providers.js → services.js} +43 -43
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +3 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +8 -43
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +1 -1
- package/dist/service/generators.js +143 -41
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +35 -29
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +3 -3
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/monitor.d.ts +2 -2
- package/dist/streaming/monitor.js +19 -13
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/preroll.d.ts +15 -14
- package/dist/streaming/preroll.js +32 -12
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +4 -4
- package/dist/streaming/setup.js +28 -34
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +8 -10
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -3
- package/dist/streaming/statusEmitter.js +3 -3
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +18 -11
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +9 -9
- package/dist/types/selection.d.ts +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +8 -16
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/debugFilter.js +3 -3
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/fileLogger.js +4 -3
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +21 -4
- package/dist/utils/format.js +45 -7
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logger.js +17 -27
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/morganStream.js +2 -2
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/platform.js +3 -2
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/retry.js +2 -4
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/streamContext.d.ts +7 -0
- package/dist/utils/streamContext.js +10 -1
- package/dist/utils/streamContext.js.map +1 -1
- package/dist/utils/version.js +4 -4
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -7
- package/dist/config/providerPacks.d.ts +0 -46
- package/dist/config/providerPacks.js.map +0 -1
- package/dist/config/providers.d.ts +0 -175
- package/dist/config/providers.js +0 -678
- package/dist/config/providers.js.map +0 -1
- package/dist/routes/config/providers.js.map +0 -1
- package/dist/routes/providers.d.ts +0 -6
- package/dist/routes/providers.js.map +0 -1
- /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* services.ts: Service group management for multi-service channels.
|
|
4
|
+
*/
|
|
5
|
+
import { CHANNEL_IDENTITY_FIELDS, PREDEFINED_CHANNELS } from "../channels/index.js";
|
|
6
|
+
import { DOMAIN_CONFIG, getDomainConfig } from "./sites.js";
|
|
7
|
+
import { LOG, extractDomain } from "../utils/index.js";
|
|
8
|
+
import { getChannelEffectiveTags } from "./userChannels.js";
|
|
9
|
+
import { getProfileForChannel } from "./profiles.js";
|
|
10
|
+
import { getUserDomains } from "./userProfiles.js";
|
|
11
|
+
/* Service groups allow multiple streaming services to offer the same content. For example, ESPN can be watched via ESPN.com (native) or Disney+.
|
|
12
|
+
*
|
|
13
|
+
* All variant relationships — predefined and user-defined — are expressed via the canonicalKey field on Channel. The flattener sets canonicalKey on predefined
|
|
14
|
+
* variant entries, the browse modal sets it on user variant entries, and the one-time migration stamps it on pre-existing user entries. buildServiceGroups
|
|
15
|
+
* scans all channels once and groups by canonicalKey. One field, one mechanism, one code path.
|
|
16
|
+
*
|
|
17
|
+
* User overrides: When a user defines a channel with the same key as a predefined channel, both versions appear in the service dropdown. The user's custom version
|
|
18
|
+
* is shown first (labeled "Custom") and is the default. The original predefined version uses a special key suffix (PREDEFINED_SUFFIX) to distinguish it from the
|
|
19
|
+
* user's version. This allows users to switch between their custom definition and the original at any time.
|
|
20
|
+
*
|
|
21
|
+
* User selections are stored in channels.json (in the data directory) under the `serviceSelections` key and persist across restarts.
|
|
22
|
+
*/
|
|
23
|
+
// Suffix appended to channel keys to reference the original predefined channel when a user has overridden it. For example, "espn:predefined" references the original
|
|
24
|
+
// predefined ESPN channel when the user has created a custom "espn" entry.
|
|
25
|
+
const PREDEFINED_SUFFIX = ":predefined";
|
|
26
|
+
/**
|
|
27
|
+
* Strips the :predefined suffix from a channel key if present, returning the base key. Synthetic keys like "pbs:predefined" are created when a user overrides a
|
|
28
|
+
* predefined channel — the original predefined entry gets this suffix to coexist with the user's custom version in the service dropdown. Functions that look up
|
|
29
|
+
* channel data by key must strip the suffix to find the actual channel entry.
|
|
30
|
+
* @param key - The channel key, possibly with :predefined suffix.
|
|
31
|
+
* @returns The base key without the suffix.
|
|
32
|
+
*/
|
|
33
|
+
function stripPredefinedSuffix(key) {
|
|
34
|
+
return key.endsWith(PREDEFINED_SUFFIX) ? key.slice(0, -PREDEFINED_SUFFIX.length) : key;
|
|
35
|
+
}
|
|
36
|
+
// Module-level storage for service groups, keyed by canonical channel key.
|
|
37
|
+
const serviceGroups = new Map();
|
|
38
|
+
// Reference to the channels map for inheritance resolution.
|
|
39
|
+
let channelsRef = {};
|
|
40
|
+
// User's service selections, keyed by canonical channel key. Values are the selected service key (e.g., "espn-disneyplus").
|
|
41
|
+
let serviceSelections = new Map();
|
|
42
|
+
// Service Tag System.
|
|
43
|
+
// Module-level state for the service filter. Empty array means "no filter" (all services shown). Non-empty means only these tags are active.
|
|
44
|
+
let enabledServices = [];
|
|
45
|
+
/**
|
|
46
|
+
* Derives the service tag for a channel from its URL domain, falling back to "direct" if no service tag is configured. Checks the channel's explicit profile
|
|
47
|
+
* first (for user-defined profiles with custom serviceTag), then the URL domain via getDomainConfig().
|
|
48
|
+
* @param channel - The channel to derive a tag for.
|
|
49
|
+
* @returns The service tag string.
|
|
50
|
+
*/
|
|
51
|
+
function resolveServiceTag(channel) {
|
|
52
|
+
// If the channel specifies a user-defined profile, use that profile's serviceTag rather than deriving from the URL. This ensures channels with explicit profile
|
|
53
|
+
// assignments are grouped under the correct service filter even when their URL domain has a different built-in serviceTag.
|
|
54
|
+
if (channel.profile) {
|
|
55
|
+
const profileService = resolveUserProfileService(channel.profile);
|
|
56
|
+
if (profileService?.serviceTag) {
|
|
57
|
+
return profileService.serviceTag;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const config = getDomainConfig(channel.url);
|
|
61
|
+
return config?.serviceTag ?? "direct";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Gets the service tag for a channel key. For channels in a service group, reads the pre-computed tag from the group variant entry (computed at group-building
|
|
65
|
+
* time by buildServiceGroups). For standalone channels not in any group, derives the tag from the channel's URL domain. This function should not be called with
|
|
66
|
+
* :predefined synthetic keys — those only exist inside service groups and their tags are available via the group's variant entries.
|
|
67
|
+
* @param key - The channel key.
|
|
68
|
+
* @returns The service tag string.
|
|
69
|
+
*/
|
|
70
|
+
export function getServiceTagForChannel(key) {
|
|
71
|
+
const effectiveKey = stripPredefinedSuffix(key);
|
|
72
|
+
const group = serviceGroups.get(effectiveKey);
|
|
73
|
+
// For channels in a group, read the pre-computed tag from the variant entry.
|
|
74
|
+
if (group) {
|
|
75
|
+
const variant = group.variants.find((v) => (v.key === key) || (v.key === effectiveKey));
|
|
76
|
+
if (variant) {
|
|
77
|
+
return variant.tag;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// For standalone channels not in any group, derive from the channel's URL domain.
|
|
81
|
+
const channel = channelsRef[effectiveKey] ?? PREDEFINED_CHANNELS[effectiveKey];
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
83
|
+
if (!channel) {
|
|
84
|
+
return "direct";
|
|
85
|
+
}
|
|
86
|
+
return resolveServiceTag(channel);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Returns the auth domain for a channel key. Domain is the natural auth boundary — browser cookies and sessions scope to it. Multi-channel services work correctly
|
|
90
|
+
* because all their channels share one domain, and canonical channels work correctly because each has its own domain.
|
|
91
|
+
* @param key - The channel key.
|
|
92
|
+
* @returns The extracted domain from the channel's URL, or empty string if the channel or URL cannot be resolved.
|
|
93
|
+
*/
|
|
94
|
+
export function getAuthDomainForChannel(key) {
|
|
95
|
+
const effectiveKey = stripPredefinedSuffix(key);
|
|
96
|
+
const channel = channelsRef[effectiveKey] ?? PREDEFINED_CHANNELS[effectiveKey];
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
98
|
+
if (!channel?.url) {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
return extractDomain(channel.url);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns all service tags for a channel (canonical tag + all variant suffix tags). Used to determine which services offer this channel.
|
|
105
|
+
* @param canonicalKey - The canonical channel key.
|
|
106
|
+
* @returns Array of service tag strings.
|
|
107
|
+
*/
|
|
108
|
+
export function getChannelServiceTags(canonicalKey) {
|
|
109
|
+
const group = serviceGroups.get(canonicalKey);
|
|
110
|
+
// For grouped channels, collect tags directly from the pre-computed variant entries.
|
|
111
|
+
if (group) {
|
|
112
|
+
const tags = new Set();
|
|
113
|
+
for (const variant of group.variants) {
|
|
114
|
+
// Skip predefined suffix variants — they share the canonical's tag (but derived from the predefined URL, which may differ from the user override).
|
|
115
|
+
if (variant.key.endsWith(PREDEFINED_SUFFIX)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
tags.add(variant.tag);
|
|
119
|
+
}
|
|
120
|
+
return [...tags];
|
|
121
|
+
}
|
|
122
|
+
// Standalone channel — derive tag from the channel directly.
|
|
123
|
+
return [getServiceTagForChannel(canonicalKey)];
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Scans all service groups and collects unique service tags with display names. Display names are derived from the service field in DOMAIN_CONFIG entries that
|
|
127
|
+
* have a serviceTag.
|
|
128
|
+
* @returns Array of { displayName, domain, iconUrl, tag } objects sorted alphabetically by display name, with "direct" always first.
|
|
129
|
+
*/
|
|
130
|
+
export function getAllServiceTags() {
|
|
131
|
+
const tags = new Set();
|
|
132
|
+
// Scan all channels (not just grouped ones) to find all service tags.
|
|
133
|
+
const allKeys = new Set([...Object.keys(channelsRef), ...Object.keys(PREDEFINED_CHANNELS)]);
|
|
134
|
+
for (const key of allKeys) {
|
|
135
|
+
// Skip variant keys — they are covered by getChannelServiceTags() on the canonical.
|
|
136
|
+
const group = serviceGroups.get(key);
|
|
137
|
+
if (group && (group.canonicalKey !== key)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const channelTags = getChannelServiceTags(key);
|
|
141
|
+
for (const tag of channelTags) {
|
|
142
|
+
tags.add(tag);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Scan user domain mappings for service tags that may not appear in any channel yet (e.g., newly created profiles with no channels assigned).
|
|
146
|
+
const userDomains = getUserDomains();
|
|
147
|
+
for (const config of Object.values(userDomains)) {
|
|
148
|
+
if (config.serviceTag) {
|
|
149
|
+
tags.add(config.serviceTag);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Build tag metadata maps from DOMAIN_CONFIG entries. Collects display name, domain, and icon URL for each service tag. First match wins for each tag.
|
|
153
|
+
const tagMeta = new Map();
|
|
154
|
+
tagMeta.set("direct", { displayName: "Channel Website" });
|
|
155
|
+
for (const [domain, config] of Object.entries(DOMAIN_CONFIG)) {
|
|
156
|
+
if (config.serviceTag && config.service && !tagMeta.has(config.serviceTag)) {
|
|
157
|
+
tagMeta.set(config.serviceTag, { displayName: config.service, domain, iconUrl: config.iconUrl });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Scan user domain mappings for metadata not covered by built-in DOMAIN_CONFIG.
|
|
161
|
+
for (const [domain, config] of Object.entries(userDomains)) {
|
|
162
|
+
if (config.serviceTag && config.service && !tagMeta.has(config.serviceTag)) {
|
|
163
|
+
tagMeta.set(config.serviceTag, { displayName: config.service, domain, iconUrl: config.iconUrl });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Build result with metadata.
|
|
167
|
+
const result = [];
|
|
168
|
+
for (const tag of tags) {
|
|
169
|
+
const meta = tagMeta.get(tag);
|
|
170
|
+
result.push({ displayName: meta?.displayName ?? tag, domain: meta?.domain, iconUrl: meta?.iconUrl, tag });
|
|
171
|
+
}
|
|
172
|
+
// Sort alphabetically by display name, but keep "direct" first.
|
|
173
|
+
result.sort((a, b) => {
|
|
174
|
+
if (a.tag === "direct") {
|
|
175
|
+
return -1;
|
|
176
|
+
}
|
|
177
|
+
if (b.tag === "direct") {
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
return a.displayName.localeCompare(b.displayName);
|
|
181
|
+
});
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Gets the current enabled service tags.
|
|
186
|
+
* @returns Copy of the enabled services array. Empty means no filter (all shown).
|
|
187
|
+
*/
|
|
188
|
+
export function getEnabledServices() {
|
|
189
|
+
return [...enabledServices];
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Sets the enabled service tags. Empty array means "no filter" (all services shown).
|
|
193
|
+
* @param tags - The service tags to enable.
|
|
194
|
+
*/
|
|
195
|
+
export function setEnabledServices(tags) {
|
|
196
|
+
enabledServices = [...tags];
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Checks if a service tag is currently enabled. Returns true if the tag is enabled, if no filter is active (empty set), or if the tag is "direct".
|
|
200
|
+
* @param tag - The service tag to check.
|
|
201
|
+
* @returns True if the service is available.
|
|
202
|
+
*/
|
|
203
|
+
export function isServiceTagEnabled(tag) {
|
|
204
|
+
// No filter active — all services are enabled.
|
|
205
|
+
if (enabledServices.length === 0) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
// "direct" is always enabled.
|
|
209
|
+
if (tag === "direct") {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return enabledServices.includes(tag);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Centralized availability check for the service filter. Returns true if the channel has at least one variant whose service tag is enabled.
|
|
216
|
+
* @param canonicalKey - The canonical channel key.
|
|
217
|
+
* @returns True if the channel passes the service filter.
|
|
218
|
+
*/
|
|
219
|
+
export function isChannelAvailableByService(canonicalKey) {
|
|
220
|
+
// No filter active — all channels are available.
|
|
221
|
+
if (enabledServices.length === 0) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
const tags = getChannelServiceTags(canonicalKey);
|
|
225
|
+
return tags.some((tag) => isServiceTagEnabled(tag));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Checks if a channel in the merged map is a user override of a predefined channel. This uses object reference comparison — getAllChannels() spreads
|
|
229
|
+
* PREDEFINED_CHANNELS directly into the result, so if the reference differs, a user channel has replaced the predefined one.
|
|
230
|
+
* @param key - The channel key to check.
|
|
231
|
+
* @param channels - The merged channel map.
|
|
232
|
+
* @returns True if the channel is a user override of a predefined channel.
|
|
233
|
+
*/
|
|
234
|
+
function isUserOverride(key, channels) {
|
|
235
|
+
const predefined = PREDEFINED_CHANNELS[key];
|
|
236
|
+
// A channel is an override if: (1) a predefined version exists, and (2) the merged map has a different object reference.
|
|
237
|
+
return Boolean(predefined) && (channels[key] !== predefined);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Builds service groups by scanning all channels and grouping by canonicalKey. Variant entries declare their canonical via the canonicalKey field (set by the
|
|
241
|
+
* flattener for predefined channels, by the browse modal for user channels, and by the one-time migration for pre-existing user entries). This is a single
|
|
242
|
+
* pass over the merged channel map — one field, one mechanism for both predefined and user-defined variant relationships.
|
|
243
|
+
*
|
|
244
|
+
* User overrides of predefined channels (same key, different object reference) produce a two-entry group with "Custom" and the original predefined version,
|
|
245
|
+
* even for single-service channels that don't have canonicalKey-based variants.
|
|
246
|
+
*
|
|
247
|
+
* After building groups, validates stored service selections against the new variant structure and reverts any stale selections to the canonical default. This
|
|
248
|
+
* catches all staleness sources in one place: service key renames across versions, variant removal in code, user-deleted custom variants, and reverted
|
|
249
|
+
* predefined overrides.
|
|
250
|
+
* @param channels - The merged channel map (predefined + user channels).
|
|
251
|
+
* @returns Canonical keys whose service selections were stale and reverted. Empty array if all selections are valid. The caller decides whether to persist.
|
|
252
|
+
*/
|
|
253
|
+
export function buildServiceGroups(channels) {
|
|
254
|
+
channelsRef = channels;
|
|
255
|
+
serviceGroups.clear();
|
|
256
|
+
// Pass 1: Collect variant keys grouped by their canonical key. Entries without canonicalKey are canonicals or standalone channels.
|
|
257
|
+
const variantsByCanonical = new Map();
|
|
258
|
+
for (const [key, channel] of Object.entries(channels)) {
|
|
259
|
+
if (!channel.canonicalKey) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const existing = variantsByCanonical.get(channel.canonicalKey);
|
|
263
|
+
if (existing) {
|
|
264
|
+
existing.push(key);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
variantsByCanonical.set(channel.canonicalKey, [key]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Pass 2: Build groups from the collected variants.
|
|
271
|
+
for (const [canonicalKey, variantKeys] of variantsByCanonical) {
|
|
272
|
+
const canonical = channels[canonicalKey];
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
274
|
+
if (!canonical) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const variants = [];
|
|
278
|
+
// Handle user override of the canonical entry. Two scenarios: (A) the user customized properties (station ID, tags, etc.) but the URL still matches a known
|
|
279
|
+
// service domain — no "Custom" variant needed, just the normal service label with a visual override indicator in the table; (B) the user set a genuinely
|
|
280
|
+
// non-standard URL — "Custom (domain)" is a real service variant and the :predefined entry gives access to the original service URL.
|
|
281
|
+
if (isUserOverride(canonicalKey, channels)) {
|
|
282
|
+
const predefined = PREDEFINED_CHANNELS[canonicalKey];
|
|
283
|
+
const userDomain = extractDomain(canonical.url);
|
|
284
|
+
const knownDomains = new Set([extractDomain(predefined.url)]);
|
|
285
|
+
for (const variantKey of variantKeys) {
|
|
286
|
+
knownDomains.add(extractDomain(channels[variantKey].url));
|
|
287
|
+
}
|
|
288
|
+
if (knownDomains.has(userDomain)) {
|
|
289
|
+
// Scenario A: property override on a known service. The canonical gets the same label as if it weren't overridden. The modified-dot indicator in the
|
|
290
|
+
// table renderer handles the visual distinction.
|
|
291
|
+
variants.push({ key: canonicalKey, label: getChannelServiceLabel(canonical), tag: resolveServiceTag(canonical) });
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// Scenario B: genuinely custom URL. "Custom (domain)" is a real service variant. The :predefined entry gives the user a path back to the original
|
|
295
|
+
// predefined service URL without permanently reverting their other customizations.
|
|
296
|
+
variants.push({ key: canonicalKey, label: "Custom (" + extractDomain(canonical.url) + ")", tag: resolveServiceTag(canonical) });
|
|
297
|
+
variants.push({ key: canonicalKey + PREDEFINED_SUFFIX, label: predefined.service ?? getServiceDisplayName(predefined.url),
|
|
298
|
+
tag: resolveServiceTag(predefined) });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
variants.push({ key: canonicalKey, label: getChannelServiceLabel(canonical), tag: resolveServiceTag(canonical) });
|
|
303
|
+
}
|
|
304
|
+
variantKeys.sort();
|
|
305
|
+
for (const variantKey of variantKeys) {
|
|
306
|
+
const variant = channels[variantKey];
|
|
307
|
+
variants.push({ key: variantKey, label: getChannelServiceLabel(variant), tag: resolveServiceTag(variant) });
|
|
308
|
+
}
|
|
309
|
+
const group = { canonicalKey, variants };
|
|
310
|
+
// Map canonical and all variant keys to this group for easy lookup.
|
|
311
|
+
serviceGroups.set(canonicalKey, group);
|
|
312
|
+
for (const variantKey of variantKeys) {
|
|
313
|
+
serviceGroups.set(variantKey, group);
|
|
314
|
+
}
|
|
315
|
+
LOG.debug("config:general", "Service group '%s': variants=%s.", canonicalKey, variants.map((v) => v.key).join(", "));
|
|
316
|
+
}
|
|
317
|
+
// Pass 3: Create groups for user overrides of single-service predefined channels. Only Scenario B (genuinely custom URL) gets a service group — the user needs
|
|
318
|
+
// a dropdown to switch between their custom URL and the predefined service. Scenario A (property override on the same domain) skips group creation entirely
|
|
319
|
+
// because there is only one service and no dropdown is needed; the modified-dot indicator in the table renderer signals the override.
|
|
320
|
+
for (const key of Object.keys(channels)) {
|
|
321
|
+
if (serviceGroups.has(key)) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!isUserOverride(key, channels)) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const userChannel = channels[key];
|
|
328
|
+
const predefined = PREDEFINED_CHANNELS[key];
|
|
329
|
+
// Scenario A: URL domain matches the predefined service. No service group needed — renders as a single-service channel with a modified-dot indicator.
|
|
330
|
+
if (extractDomain(userChannel.url) === extractDomain(predefined.url)) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Scenario B: genuinely custom URL. Create a 2-entry group so the user can switch between their custom URL and the predefined service.
|
|
334
|
+
const variants = [
|
|
335
|
+
{ key, label: "Custom (" + extractDomain(userChannel.url) + ")", tag: resolveServiceTag(userChannel) },
|
|
336
|
+
{ key: key + PREDEFINED_SUFFIX, label: predefined.service ?? getServiceDisplayName(predefined.url), tag: resolveServiceTag(predefined) }
|
|
337
|
+
];
|
|
338
|
+
const group = { canonicalKey: key, variants };
|
|
339
|
+
serviceGroups.set(key, group);
|
|
340
|
+
LOG.debug("config:general", "Service group '%s' (override): variants=%s.", key, variants.map((v) => v.key).join(", "));
|
|
341
|
+
}
|
|
342
|
+
// Build the domain-to-predefined-channel reverse index. This enables the manual add form to show an inline hint when the entered URL matches a predefined
|
|
343
|
+
// channel. Scans only canonical entries in PREDEFINED_CHANNELS (variants share the canonical's URL domain and would produce duplicate results).
|
|
344
|
+
predefinedByDomain.clear();
|
|
345
|
+
for (const [key, channel] of Object.entries(PREDEFINED_CHANNELS)) {
|
|
346
|
+
if (channel.canonicalKey) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const domain = extractDomain(channel.url);
|
|
350
|
+
const group = serviceGroups.get(key);
|
|
351
|
+
const entry = { canonicalKey: key, name: channel.name ?? key, serviceCount: group?.variants.length ?? 1 };
|
|
352
|
+
const existing = predefinedByDomain.get(domain);
|
|
353
|
+
if (existing) {
|
|
354
|
+
existing.push(entry);
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
predefinedByDomain.set(domain, [entry]);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Validate stored service selections against the rebuilt groups. Any selection whose variant key no longer exists in the group is stale and reverted to the
|
|
361
|
+
// canonical default. This handles all staleness sources: service key renames across versions, variant removal, user-deleted custom variants, and reverted
|
|
362
|
+
// predefined overrides. The caller decides whether to persist based on whether any keys were cleaned.
|
|
363
|
+
const staleKeys = [];
|
|
364
|
+
for (const [canonicalKey, selection] of serviceSelections) {
|
|
365
|
+
const group = serviceGroups.get(canonicalKey);
|
|
366
|
+
if (!group?.variants.some((v) => v.key === selection)) {
|
|
367
|
+
LOG.warn("Service selection '%s' for channel '%s' is no longer valid. Reverting to default.", selection, canonicalKey);
|
|
368
|
+
serviceSelections.delete(canonicalKey);
|
|
369
|
+
staleKeys.push(canonicalKey);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return staleKeys;
|
|
373
|
+
}
|
|
374
|
+
// Reverse index mapping concise domains to predefined channel summaries. Built by buildServiceGroups() and queried by findPredefinedByDomain() and
|
|
375
|
+
// getPredefinedDomainMap().
|
|
376
|
+
const predefinedByDomain = new Map();
|
|
377
|
+
/**
|
|
378
|
+
* Returns predefined channels whose canonical URL domain matches the given URL's domain. Used by the manual add form to show an inline hint when the user
|
|
379
|
+
* enters a URL that has predefined channels available. Returns an empty array when no predefined channels match.
|
|
380
|
+
* @param url - The URL to match against predefined channel domains.
|
|
381
|
+
* @returns Array of matching predefined channel summaries with canonical key, display name, and available service count.
|
|
382
|
+
*/
|
|
383
|
+
export function findPredefinedByDomain(url) {
|
|
384
|
+
try {
|
|
385
|
+
const domain = extractDomain(url);
|
|
386
|
+
return predefinedByDomain.get(domain) ?? [];
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Returns the full domain-to-predefined-channel index for client-side embedding. Used by the channels panel to embed predefined match data so the manual add
|
|
394
|
+
* form can show inline hints without a server round-trip. The returned object is keyed by concise domain with arrays of channel summaries as values.
|
|
395
|
+
* @returns Record mapping domains to predefined channel summaries.
|
|
396
|
+
*/
|
|
397
|
+
export function getPredefinedDomainMap() {
|
|
398
|
+
return Object.fromEntries(predefinedByDomain);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Resolves a URL to a friendly service display name. Checks built-in DOMAIN_CONFIG first for a stable, well-known service name, then falls back to
|
|
402
|
+
* getDomainConfig() which includes user domain mappings. This ordering prevents user domain overrides from corrupting display labels for predefined channel
|
|
403
|
+
* variants — a user mapping a built-in domain to a custom profile should not rename every service dropdown entry that uses that domain.
|
|
404
|
+
* @param url - The URL to resolve a service display name for.
|
|
405
|
+
* @returns The service display name, or the concise domain if no service name is configured.
|
|
406
|
+
*/
|
|
407
|
+
export function getServiceDisplayName(url) {
|
|
408
|
+
// Prefer built-in DOMAIN_CONFIG service names for stable display. Check by full hostname first (for subdomain-specific entries like tv.youtube.com), then by
|
|
409
|
+
// concise domain (e.g., disneyplus.com).
|
|
410
|
+
try {
|
|
411
|
+
const hostname = new URL(url).hostname;
|
|
412
|
+
const builtinFull = DOMAIN_CONFIG[hostname];
|
|
413
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
414
|
+
if (builtinFull?.service) {
|
|
415
|
+
return builtinFull.service;
|
|
416
|
+
}
|
|
417
|
+
const concise = extractDomain(url);
|
|
418
|
+
const builtinConcise = DOMAIN_CONFIG[concise];
|
|
419
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
420
|
+
if (builtinConcise?.service) {
|
|
421
|
+
return builtinConcise.service;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// Invalid URL — fall through to getDomainConfig.
|
|
426
|
+
}
|
|
427
|
+
// For domains not in DOMAIN_CONFIG, fall back to getDomainConfig() which includes user domain mappings.
|
|
428
|
+
const config = getDomainConfig(url);
|
|
429
|
+
return config?.service ?? extractDomain(url);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Resolves service identity (tag and display name) for a user-defined profile by scanning its domain mappings. Returns the first matching domain config's
|
|
433
|
+
* serviceTag and service name. This is the single source of truth for "profile key → service identity" resolution, used by both tag and label lookups to avoid
|
|
434
|
+
* duplicating the domain scan logic.
|
|
435
|
+
* @param profileKey - The user profile key to resolve.
|
|
436
|
+
* @returns The service identity from the profile's domain mappings, or undefined if no matching domain mapping exists.
|
|
437
|
+
*/
|
|
438
|
+
function resolveUserProfileService(profileKey) {
|
|
439
|
+
const userDomains = getUserDomains();
|
|
440
|
+
for (const config of Object.values(userDomains)) {
|
|
441
|
+
if (config.profile === profileKey) {
|
|
442
|
+
return { service: config.service, serviceTag: config.serviceTag };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Resolves the service display label for a channel. Checks in order: explicit `service` field on the channel, the channel's explicit profile resolved via
|
|
449
|
+
* user domain mappings, then URL-based built-in display name. This ensures channels assigned to user-defined profiles show the profile's service name rather
|
|
450
|
+
* than the built-in name for the URL domain.
|
|
451
|
+
* @param channel - The channel to resolve a label for.
|
|
452
|
+
* @returns The service display label.
|
|
453
|
+
*/
|
|
454
|
+
export function getChannelServiceLabel(channel) {
|
|
455
|
+
if (channel.service) {
|
|
456
|
+
return channel.service;
|
|
457
|
+
}
|
|
458
|
+
// If the channel specifies a user-defined profile, use that profile's service name from domain mappings.
|
|
459
|
+
if (channel.profile) {
|
|
460
|
+
const profileService = resolveUserProfileService(channel.profile);
|
|
461
|
+
if (profileService?.service) {
|
|
462
|
+
return profileService.service;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return getServiceDisplayName(channel.url);
|
|
466
|
+
}
|
|
467
|
+
// Valid sort field values for the channels table. Exported as the single source of truth for sort field validation, shared by the config POST handler and the
|
|
468
|
+
// playlist endpoint's query parameter validation.
|
|
469
|
+
export const VALID_SORT_FIELDS = new Set(["channelNumber", "channelSelector", "hdhrEnabled", "key", "name", "profile", "service", "stationId", "tags"]);
|
|
470
|
+
/**
|
|
471
|
+
* Extracts a sortable string value from a channel for the specified sort field. Channel numbers are zero-padded to 6 digits for correct numeric ordering within a
|
|
472
|
+
* string comparison. Service values use the display label for human-meaningful sort order. This is the single source of truth for channel sort key extraction,
|
|
473
|
+
* shared by both the server-side table renderer and the M3U playlist generator.
|
|
474
|
+
* @param channel - Fallback channel definition, used only when the selected service variant cannot be resolved (e.g., key not in the merged channel map).
|
|
475
|
+
* @param key - The canonical channel key. Used for key-based sorting and to resolve the selected service variant internally.
|
|
476
|
+
* @param field - The sort field to extract.
|
|
477
|
+
* @returns A lowercase string suitable for comparison-based sorting.
|
|
478
|
+
*/
|
|
479
|
+
export function getChannelSortKey(channel, key, field) {
|
|
480
|
+
// Resolve the selected service variant so all sort keys reflect the user's service selection. For URL-dependent fields (profile, service), this is essential —
|
|
481
|
+
// a canonical's URL may differ from the selected variant's (e.g., bbcnews canonical uses cox but the user selected the directv variant). For identity fields
|
|
482
|
+
// (name, stationId, channelNumber), the flattener eagerly sets these on all entries, so the resolved channel has identical values regardless of variant.
|
|
483
|
+
const effective = getResolvedChannel(resolveServiceKey(key)) ?? channel;
|
|
484
|
+
switch (field) {
|
|
485
|
+
case "channelNumber": {
|
|
486
|
+
const num = effective.channelNumber;
|
|
487
|
+
return num ? String(num).padStart(6, "0") : "zzzzzz";
|
|
488
|
+
}
|
|
489
|
+
case "channelSelector": {
|
|
490
|
+
return (effective.channelSelector ?? "").toLowerCase();
|
|
491
|
+
}
|
|
492
|
+
case "hdhrEnabled": {
|
|
493
|
+
// Sort enabled channels before disabled. "0" (enabled/absent) sorts before "1" (disabled).
|
|
494
|
+
return (effective.hdhrEnabled === false) ? "1" : "0";
|
|
495
|
+
}
|
|
496
|
+
case "key": {
|
|
497
|
+
return key.toLowerCase();
|
|
498
|
+
}
|
|
499
|
+
case "name": {
|
|
500
|
+
return (effective.name ?? key).toLowerCase();
|
|
501
|
+
}
|
|
502
|
+
case "profile": {
|
|
503
|
+
// Explicit profile: sort by its name.
|
|
504
|
+
if (effective.profile) {
|
|
505
|
+
return effective.profile.toLowerCase();
|
|
506
|
+
}
|
|
507
|
+
// Auto-detected: check whether the profile resolves to a real service or falls back to default. Only apply the ! prefix for non-default auto profiles so
|
|
508
|
+
// they sort between explicit profiles and empty profiles.
|
|
509
|
+
const resolved = getProfileForChannel(effective);
|
|
510
|
+
if (resolved.profileName === "default") {
|
|
511
|
+
return "";
|
|
512
|
+
}
|
|
513
|
+
const label = getChannelServiceLabel(effective);
|
|
514
|
+
return label ? ("!" + label.toLowerCase()) : "";
|
|
515
|
+
}
|
|
516
|
+
case "service": {
|
|
517
|
+
return getChannelServiceLabel(effective).toLowerCase();
|
|
518
|
+
}
|
|
519
|
+
case "stationId": {
|
|
520
|
+
const id = effective.stationId;
|
|
521
|
+
return id ? id.padStart(6, "0") : "zzzzzz";
|
|
522
|
+
}
|
|
523
|
+
case "tags": {
|
|
524
|
+
const effectiveTags = getChannelEffectiveTags(effective);
|
|
525
|
+
return (effectiveTags.length > 0) ? effectiveTags.join(",") : "zz";
|
|
526
|
+
}
|
|
527
|
+
default: {
|
|
528
|
+
return key.toLowerCase();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Compares two channels for sorting by the specified field and direction with a built-in channel name tiebreaker. The tiebreaker is always ascending so that rows
|
|
534
|
+
* within each group maintain a consistent alphabetical order regardless of the primary sort direction. This is the single comparator for all sort sites — server HTML
|
|
535
|
+
* render, client re-sort, and M3U playlist — to prevent ordering divergence.
|
|
536
|
+
* @param channelA - First channel definition.
|
|
537
|
+
* @param keyA - First channel key.
|
|
538
|
+
* @param channelB - Second channel definition.
|
|
539
|
+
* @param keyB - Second channel key.
|
|
540
|
+
* @param field - The sort field to compare.
|
|
541
|
+
* @param direction - Sort direction for the primary field.
|
|
542
|
+
* @returns A negative, zero, or positive number for sort ordering.
|
|
543
|
+
*/
|
|
544
|
+
export function compareChannelSort(channelA, keyA, channelB, keyB, field, direction) {
|
|
545
|
+
const valA = getChannelSortKey(channelA, keyA, field);
|
|
546
|
+
const valB = getChannelSortKey(channelB, keyB, field);
|
|
547
|
+
const cmp = (direction === "asc") ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
|
548
|
+
if (cmp !== 0) {
|
|
549
|
+
return cmp;
|
|
550
|
+
}
|
|
551
|
+
// Tiebreaker: channel name ascending regardless of primary direction.
|
|
552
|
+
const nameA = (channelA.name ?? keyA).toLowerCase();
|
|
553
|
+
const nameB = (channelB.name ?? keyB).toLowerCase();
|
|
554
|
+
return nameA.localeCompare(nameB);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Gets the service group for a channel key. Works with both canonical and variant keys.
|
|
558
|
+
* @param key - Any channel key in the group.
|
|
559
|
+
* @returns The service group if the channel is part of a multi-service group, undefined otherwise.
|
|
560
|
+
*/
|
|
561
|
+
export function getServiceGroup(key) {
|
|
562
|
+
return serviceGroups.get(key);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Checks if a channel key is a non-canonical service variant. Used to filter variants from channel listings.
|
|
566
|
+
* @param key - The channel key to check.
|
|
567
|
+
* @returns True if the key is a variant (not canonical) in a service group.
|
|
568
|
+
*/
|
|
569
|
+
export function isServiceVariant(key) {
|
|
570
|
+
const group = serviceGroups.get(key);
|
|
571
|
+
return (group !== undefined) && (group.canonicalKey !== key);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Checks if a channel has multiple service options. Used to determine whether to show a service dropdown in the UI.
|
|
575
|
+
* @param key - The channel key to check.
|
|
576
|
+
* @returns True if the channel has more than one service variant.
|
|
577
|
+
*/
|
|
578
|
+
export function hasMultipleServices(key) {
|
|
579
|
+
const group = serviceGroups.get(key);
|
|
580
|
+
return (group !== undefined) && (group.variants.length > 1);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Gets the canonical key for any channel key. For variant keys, returns the canonical key. For non-grouped or canonical keys, returns the input unchanged.
|
|
584
|
+
* Handles the PREDEFINED_SUFFIX used when a user has overridden a predefined channel.
|
|
585
|
+
* @param key - Any channel key.
|
|
586
|
+
* @returns The canonical key for the channel's service group, or the input key if not part of a group.
|
|
587
|
+
*/
|
|
588
|
+
export function getCanonicalKey(key) {
|
|
589
|
+
// Strip predefined suffix if present before looking up the group.
|
|
590
|
+
const baseKey = key.endsWith(PREDEFINED_SUFFIX) ? key.slice(0, -PREDEFINED_SUFFIX.length) : key;
|
|
591
|
+
const group = serviceGroups.get(baseKey);
|
|
592
|
+
return group?.canonicalKey ?? baseKey;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Sets the user's service selections. Called when loading from channels.json.
|
|
596
|
+
* @param selections - Service selections keyed by canonical channel key.
|
|
597
|
+
*/
|
|
598
|
+
export function setServiceSelections(selections) {
|
|
599
|
+
serviceSelections = new Map(Object.entries(selections));
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Gets all service selections.
|
|
603
|
+
* @returns Copy of the service selections object.
|
|
604
|
+
*/
|
|
605
|
+
export function getServiceSelections() {
|
|
606
|
+
return Object.fromEntries(serviceSelections);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Gets the service selection for a specific channel.
|
|
610
|
+
* @param canonicalKey - The canonical channel key.
|
|
611
|
+
* @returns The selected service key, or undefined if using the default.
|
|
612
|
+
*/
|
|
613
|
+
export function getServiceSelection(canonicalKey) {
|
|
614
|
+
return serviceSelections.get(canonicalKey);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Sets the service selection for a channel.
|
|
618
|
+
* @param canonicalKey - The canonical channel key.
|
|
619
|
+
* @param serviceKey - The selected service key.
|
|
620
|
+
*/
|
|
621
|
+
export function setServiceSelection(canonicalKey, serviceKey) {
|
|
622
|
+
// If selecting the canonical (default), remove the selection instead of storing it.
|
|
623
|
+
if (serviceKey === canonicalKey) {
|
|
624
|
+
serviceSelections.delete(canonicalKey);
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
serviceSelections.set(canonicalKey, serviceKey);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Resolves a canonical channel key to the actual channel key based on user selection. If the user has selected a specific service for this channel, returns that
|
|
632
|
+
* service's key. Otherwise returns the canonical key (default service). When the service filter is active, falls back to the first enabled variant if the stored
|
|
633
|
+
* selection's service is filtered out.
|
|
634
|
+
*
|
|
635
|
+
* This function is a pure resolver with no side effects. Stale selection cleanup is handled by buildServiceGroups(), which validates all stored selections
|
|
636
|
+
* against the rebuilt variant structure every time groups are rebuilt - both at startup and after runtime channel mutations.
|
|
637
|
+
* @param canonicalKey - The canonical channel key.
|
|
638
|
+
* @returns The resolved service key to use for streaming.
|
|
639
|
+
*/
|
|
640
|
+
export function resolveServiceKey(canonicalKey) {
|
|
641
|
+
const selection = serviceSelections.get(canonicalKey);
|
|
642
|
+
// No selection stored — use the canonical key (default service). If the canonical's service tag is filtered out, fall back to the first enabled variant.
|
|
643
|
+
if (!selection) {
|
|
644
|
+
if ((enabledServices.length > 0) && !isServiceTagEnabled(getServiceTagForChannel(canonicalKey))) {
|
|
645
|
+
return findFirstEnabledVariant(canonicalKey) ?? canonicalKey;
|
|
646
|
+
}
|
|
647
|
+
return canonicalKey;
|
|
648
|
+
}
|
|
649
|
+
// Valid selection — if its service tag is filtered out, fall back to the first enabled variant.
|
|
650
|
+
if ((enabledServices.length > 0) && !isServiceTagEnabled(getServiceTagForChannel(selection))) {
|
|
651
|
+
return findFirstEnabledVariant(canonicalKey) ?? selection;
|
|
652
|
+
}
|
|
653
|
+
return selection;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Finds the first enabled variant for a channel when the current selection's service is filtered out. Iterates the group's variants and returns the first whose
|
|
657
|
+
* service tag is enabled.
|
|
658
|
+
* @param canonicalKey - The canonical channel key.
|
|
659
|
+
* @returns The first enabled variant key, or undefined if none are enabled.
|
|
660
|
+
*/
|
|
661
|
+
function findFirstEnabledVariant(canonicalKey) {
|
|
662
|
+
const group = serviceGroups.get(canonicalKey);
|
|
663
|
+
if (!group) {
|
|
664
|
+
return undefined;
|
|
665
|
+
}
|
|
666
|
+
for (const variant of group.variants) {
|
|
667
|
+
if (variant.key.endsWith(PREDEFINED_SUFFIX)) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (isServiceTagEnabled(variant.tag)) {
|
|
671
|
+
return variant.key;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Applies variant inheritance: the variant contributes service-specific fields (url, channelSelector, profile, etc.) while identity fields always come from the
|
|
678
|
+
* canonical base. Identity fields describe what the channel IS — name, station ID, tags, channel number — and are independent of which service serves it. The
|
|
679
|
+
* spread brings in all variant fields, then identity fields are unconditionally overwritten from the canonical. This ensures user overrides on the canonical
|
|
680
|
+
* (e.g., renaming a channel or adding tags) propagate to all service variants, rather than being masked by stale flattener-copied values on the variant.
|
|
681
|
+
* The field list comes from CHANNEL_IDENTITY_FIELDS — the single source of truth for the identity/service-specific separation.
|
|
682
|
+
* @param variant - The variant channel definition (service-specific fields).
|
|
683
|
+
* @param base - The canonical (base) channel with user overrides applied (identity fields).
|
|
684
|
+
* @returns A new Channel with identity fields from the canonical and service-specific fields from the variant.
|
|
685
|
+
*/
|
|
686
|
+
function applyVariantInheritance(variant, base) {
|
|
687
|
+
const result = { ...variant };
|
|
688
|
+
for (const field of CHANNEL_IDENTITY_FIELDS) {
|
|
689
|
+
copyField(result, base, field);
|
|
690
|
+
}
|
|
691
|
+
return result;
|
|
692
|
+
}
|
|
693
|
+
// Type-safe field copy using a generic to preserve the field-value relationship. TypeScript can't prove that result[field] and base[field] have compatible types
|
|
694
|
+
// when field is a union of literal keys (each key maps to a different type). The generic K narrows to a single key per call, making the assignment type-safe.
|
|
695
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- K is necessary to correlate the key type between target and source assignments.
|
|
696
|
+
function copyField(target, source, key) {
|
|
697
|
+
target[key] = source[key];
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Gets a channel with inheritance applied. For service variants, this merges the variant's properties with inherited properties from the canonical entry
|
|
701
|
+
* using the live channel data (which includes user overrides). Use `resolvePredefinedVariant()` when you need resolution against pure predefined data.
|
|
702
|
+
* @param key - The channel key (canonical or variant).
|
|
703
|
+
* @returns The complete channel with inheritance applied, or undefined if the channel doesn't exist.
|
|
704
|
+
*/
|
|
705
|
+
export function getResolvedChannel(key) {
|
|
706
|
+
// Handle predefined suffix — return the original predefined channel when user has overridden the canonical but selects the predefined service.
|
|
707
|
+
if (key.endsWith(PREDEFINED_SUFFIX)) {
|
|
708
|
+
const baseKey = key.slice(0, -PREDEFINED_SUFFIX.length);
|
|
709
|
+
return PREDEFINED_CHANNELS[baseKey];
|
|
710
|
+
}
|
|
711
|
+
const channel = channelsRef[key];
|
|
712
|
+
// Runtime check needed even though TypeScript thinks channel is always defined (Record indexing quirk).
|
|
713
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
714
|
+
if (!channel) {
|
|
715
|
+
return undefined;
|
|
716
|
+
}
|
|
717
|
+
const group = serviceGroups.get(key);
|
|
718
|
+
// If not part of a group or is the canonical entry, return as-is.
|
|
719
|
+
if (!group || (group.canonicalKey === key)) {
|
|
720
|
+
return channel;
|
|
721
|
+
}
|
|
722
|
+
// This is a variant — merge with canonical entry.
|
|
723
|
+
const canonical = channelsRef[group.canonicalKey];
|
|
724
|
+
// Runtime check — canonical entry should exist if the group exists, but we check defensively.
|
|
725
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
726
|
+
if (!canonical) {
|
|
727
|
+
// Canonical entry missing (shouldn't happen), return variant as-is.
|
|
728
|
+
return channel;
|
|
729
|
+
}
|
|
730
|
+
return applyVariantInheritance(channel, canonical);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Resolves a variant channel key against pure predefined data (ignoring user overrides). This is used for revert detection — when the user's edits match a
|
|
734
|
+
* variant's predefined definition, the custom override can be removed and the service selection switched to that variant. For canonical keys, returns the raw
|
|
735
|
+
* predefined channel. For variant keys, applies the same inheritance rules as `getResolvedChannel()` but against `PREDEFINED_CHANNELS` instead of `channelsRef`.
|
|
736
|
+
* @param key - The channel key (canonical or variant).
|
|
737
|
+
* @returns The channel with inheritance applied against predefined data, or undefined if the key has no predefined definition.
|
|
738
|
+
*/
|
|
739
|
+
export function resolvePredefinedVariant(key) {
|
|
740
|
+
const channel = PREDEFINED_CHANNELS[key];
|
|
741
|
+
// Runtime check — the key may not exist in PREDEFINED_CHANNELS.
|
|
742
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
743
|
+
if (!channel) {
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
746
|
+
const group = serviceGroups.get(key);
|
|
747
|
+
// If not part of a group or is the canonical entry, return the predefined channel as-is.
|
|
748
|
+
if (!group || (group.canonicalKey === key)) {
|
|
749
|
+
return channel;
|
|
750
|
+
}
|
|
751
|
+
const canonical = PREDEFINED_CHANNELS[group.canonicalKey];
|
|
752
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
753
|
+
if (!canonical) {
|
|
754
|
+
return channel;
|
|
755
|
+
}
|
|
756
|
+
return applyVariantInheritance(channel, canonical);
|
|
757
|
+
}
|
|
758
|
+
//# sourceMappingURL=services.js.map
|