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
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* userChannels.ts: User channel file management for PrismCast.
|
|
4
|
+
*/
|
|
5
|
+
import { CHANNEL_IDENTITY_FIELDS, PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../channels/index.js";
|
|
6
|
+
import { FileStoreParseError, createFileStore } from "./persistence.js";
|
|
1
7
|
import { LOG, containsNonPrintable, sanitizeString } from "../utils/index.js";
|
|
2
|
-
import {
|
|
3
|
-
import { getChannelsFilePath, getDataDir } from "./paths.js";
|
|
4
|
-
import { loadUserConfig, saveUserConfig } from "./userConfig.js";
|
|
8
|
+
import { buildServiceGroups, getAllServiceTags, getResolvedChannel, getServiceSelections, isChannelAvailableByService, isServiceVariant, resolveServiceKey, setEnabledServices, setServiceSelections } from "./services.js";
|
|
5
9
|
import { CONFIG } from "./index.js";
|
|
6
|
-
import { PREDEFINED_CHANNELS } from "../channels/index.js";
|
|
7
10
|
import fs from "node:fs";
|
|
11
|
+
import { getChannelsFilePath } from "./paths.js";
|
|
12
|
+
import { mutateConfig } from "./userConfig.js";
|
|
8
13
|
const { promises: fsPromises } = fs;
|
|
9
14
|
/* The channels file path is resolved via the centralized paths module (config/paths.ts). The data directory is initialized at startup before channel loading.
|
|
10
15
|
*/
|
|
@@ -22,6 +27,8 @@ export function getUserChannelsFilePath() {
|
|
|
22
27
|
let loadedUserChannels = {};
|
|
23
28
|
let userChannelsParseError = false;
|
|
24
29
|
let userChannelsParseErrorMessage;
|
|
30
|
+
// Module-level tag registry state. Tracks user-created tags and user-deleted predefined tags. The runtime vocabulary is computed by getActiveTagVocabulary().
|
|
31
|
+
let loadedTagRegistry = { deletedTags: [], tags: [] };
|
|
25
32
|
/**
|
|
26
33
|
* Returns whether the user channels file had a parse error.
|
|
27
34
|
* @returns True if the channels file exists but contains invalid JSON.
|
|
@@ -37,67 +44,63 @@ export function getChannelsParseErrorMessage() {
|
|
|
37
44
|
return userChannelsParseErrorMessage;
|
|
38
45
|
}
|
|
39
46
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* @
|
|
47
|
+
* Parses raw channels.json content into the compound data type. Extracts channel entries, service selections, and tag registry from the top-level JSON object.
|
|
48
|
+
* Also applies the legacy "provider" to "service" field migration.
|
|
49
|
+
* @param raw - The raw JSON string from the file.
|
|
50
|
+
* @returns The parsed compound data.
|
|
43
51
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
for (const [selKey, selValue] of Object.entries(value)) {
|
|
57
|
-
if (typeof selValue === "string") {
|
|
58
|
-
providerSelections[selKey] = selValue;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
52
|
+
function parseChannelsFile(raw) {
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
const channels = {};
|
|
55
|
+
const serviceSelections = {};
|
|
56
|
+
const tagRegistry = { deletedTags: [], tags: [] };
|
|
57
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
58
|
+
if ((key === "serviceSelections") || (key === "providerSelections")) {
|
|
59
|
+
// Copy service selections if it's an object. Accepts the legacy "providerSelections" key for backward compatibility.
|
|
60
|
+
if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
|
|
61
|
+
for (const [selKey, selValue] of Object.entries(value)) {
|
|
62
|
+
if (typeof selValue === "string") {
|
|
63
|
+
serviceSelections[selKey] = selValue;
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if (key === "tagRegistry") {
|
|
69
|
+
// Extract tag registry with defensive validation. String array fields (tags, deletedTags) drop non-string elements silently.
|
|
70
|
+
if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
|
|
71
|
+
const rawRegistry = value;
|
|
72
|
+
if (Array.isArray(rawRegistry.tags)) {
|
|
73
|
+
tagRegistry.tags = rawRegistry.tags.filter((t) => typeof t === "string").sort();
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(rawRegistry.deletedTags)) {
|
|
76
|
+
tagRegistry.deletedTags = rawRegistry.deletedTags.filter((t) => typeof t === "string").sort();
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
|
-
return { channels, parseError: false, providerSelections };
|
|
69
79
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return { channels: {}, parseError: true, parseErrorMessage: message, providerSelections: {} };
|
|
80
|
+
else if ((typeof value === "object") && (value !== null) && !Array.isArray(value)) {
|
|
81
|
+
// It's a channel definition or delta override.
|
|
82
|
+
channels[key] = value;
|
|
74
83
|
}
|
|
75
84
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
// Silent migration: rename legacy "provider" field to "service" on channel entries. The old field was used as a display name override for the service selection
|
|
86
|
+
// dropdown. New installs always write "service". This migration ensures existing channels.json files are upgraded transparently.
|
|
87
|
+
for (const channel of Object.values(channels)) {
|
|
88
|
+
const legacy = channel;
|
|
89
|
+
if (("provider" in legacy) && !("service" in legacy)) {
|
|
90
|
+
legacy.service = legacy.provider;
|
|
80
91
|
}
|
|
81
|
-
|
|
82
|
-
LOG.warn("Failed to read channels file %s: %s. Using predefined channels only.", getChannelsFilePath(), (error instanceof Error) ? error.message : String(error));
|
|
83
|
-
return { channels: {}, parseError: false, providerSelections: {} };
|
|
92
|
+
Reflect.deleteProperty(legacy, "provider");
|
|
84
93
|
}
|
|
94
|
+
return { channels, serviceSelections, tagRegistry };
|
|
85
95
|
}
|
|
86
96
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* @param channels - The channels to save (full definitions or delta overrides).
|
|
93
|
-
* @throws If the file cannot be written.
|
|
97
|
+
* Normalizes predefined channel deltas by stripping no-op fields and strips null fields from user channels. A delta field is a no-op if: (1) its value is null
|
|
98
|
+
* and the predefined doesn't have the field, (2) its value is undefined, or (3) its value matches the predefined's value exactly. After stripping, deltas with
|
|
99
|
+
* no remaining fields are removed entirely.
|
|
100
|
+
* @param channels - The raw channel entries to normalize.
|
|
101
|
+
* @returns The normalized channel map.
|
|
94
102
|
*/
|
|
95
|
-
|
|
96
|
-
// Ensure data directory exists.
|
|
97
|
-
await fsPromises.mkdir(getDataDir(), { recursive: true });
|
|
98
|
-
// Normalize predefined channel deltas by stripping no-op fields. A delta field is a no-op if: (1) its value is null and the predefined doesn't have the
|
|
99
|
-
// field (clearing a nonexistent field), (2) its value is undefined, or (3) its value matches the predefined's value exactly (redundant copy). After stripping,
|
|
100
|
-
// deltas with no remaining fields are removed entirely.
|
|
103
|
+
function normalizeChannelDeltas(channels) {
|
|
101
104
|
const filtered = {};
|
|
102
105
|
for (const [key, stored] of Object.entries(channels)) {
|
|
103
106
|
if (key in PREDEFINED_CHANNELS) {
|
|
@@ -109,17 +112,21 @@ export async function saveUserChannels(channels) {
|
|
|
109
112
|
if (!DELTA_ALLOWED_FIELDS.has(field)) {
|
|
110
113
|
continue;
|
|
111
114
|
}
|
|
112
|
-
// Skip undefined values
|
|
115
|
+
// Skip undefined values - they have no effect.
|
|
113
116
|
if (value === undefined) {
|
|
114
117
|
continue;
|
|
115
118
|
}
|
|
116
|
-
// Skip null values when the predefined doesn't have the field
|
|
119
|
+
// Skip null values when the predefined doesn't have the field - clearing a nonexistent field is a no-op.
|
|
117
120
|
if ((value === null) && !(field in predefined)) {
|
|
118
121
|
continue;
|
|
119
122
|
}
|
|
120
|
-
// Skip values that match the predefined exactly
|
|
121
|
-
|
|
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
|
+
}
|
|
123
130
|
}
|
|
124
131
|
cleaned[field] = value;
|
|
125
132
|
hasFields = true;
|
|
@@ -136,52 +143,90 @@ export async function saveUserChannels(channels) {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
else {
|
|
139
|
-
|
|
146
|
+
// User channels: strip null fields. Null is a delta convention for predefined channels ("clear this field") and has no meaning on full Channel
|
|
147
|
+
// definitions...the Channel type uses T | undefined, never T | null. This allows callers to uniformly use null for "empty/clear" without needing to
|
|
148
|
+
// know the storage convention.
|
|
149
|
+
filtered[key] = Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
|
|
140
150
|
}
|
|
141
151
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
152
|
+
return filtered;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Prepares channels data for writing to disk. Injects metadata from module state (serviceSelections, tagRegistry) into the serializable output. The metadata
|
|
156
|
+
* is always pulled from module state rather than from the file data, because route handlers may have modified metadata in memory since the last file read.
|
|
157
|
+
* Delta normalization is handled by mutateChannels() before the data reaches this hook.
|
|
158
|
+
* @param data - The compound channels data with already-normalized channels.
|
|
159
|
+
* @returns The serializable output with channels and current metadata.
|
|
160
|
+
*/
|
|
161
|
+
function prepareChannelsForWrite(data) {
|
|
162
|
+
// Build the serializable output with metadata from module state.
|
|
163
|
+
const output = { ...data.channels };
|
|
164
|
+
const selections = getServiceSelections();
|
|
150
165
|
if (Object.keys(selections).length > 0) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
output.serviceSelections = selections;
|
|
167
|
+
}
|
|
168
|
+
if ((loadedTagRegistry.tags.length > 0) || (loadedTagRegistry.deletedTags.length > 0)) {
|
|
169
|
+
output.tagRegistry = loadedTagRegistry;
|
|
170
|
+
}
|
|
171
|
+
return output;
|
|
172
|
+
}
|
|
173
|
+
// Transactional store instance for channels.json.
|
|
174
|
+
const channelsStore = createFileStore({
|
|
175
|
+
beforeWrite: prepareChannelsForWrite,
|
|
176
|
+
defaultValue: () => ({ channels: {}, serviceSelections: {}, tagRegistry: { deletedTags: [], tags: [] } }),
|
|
177
|
+
label: "channels",
|
|
178
|
+
parse: parseChannelsFile,
|
|
179
|
+
path: getChannelsFilePath
|
|
180
|
+
});
|
|
181
|
+
/**
|
|
182
|
+
* Reads the current channels from disk without acquiring the serialization lock. Returns the parsed channels with metadata and parse status. Use this for
|
|
183
|
+
* read-only access and startup initialization. For modifications, use mutateChannels() instead.
|
|
184
|
+
* @returns The loaded channels with parse status, service selections, and tag registry.
|
|
185
|
+
*/
|
|
186
|
+
export async function readChannels() {
|
|
187
|
+
const result = await channelsStore.read();
|
|
188
|
+
return {
|
|
189
|
+
channels: result.data.channels,
|
|
190
|
+
parseError: result.parseError,
|
|
191
|
+
parseErrorMessage: result.parseErrorMessage,
|
|
192
|
+
serviceSelections: result.data.serviceSelections,
|
|
193
|
+
tagRegistry: result.data.tagRegistry
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Serialized read-modify-write operation on channels.json. The mutation function receives the current StoredChannelMap and modifies it in place. The store
|
|
198
|
+
* handles atomicity, serialization, corruption guard, backup, and metadata injection. Delta normalization is applied after the caller's mutation and before the
|
|
199
|
+
* file write so that the normalized result is available for the post-write cache update.
|
|
200
|
+
* @param fn - Mutation function. Receives current channels. Modify in place; return value is ignored.
|
|
201
|
+
* @throws FileStoreParseError if channels.json contains invalid JSON.
|
|
202
|
+
*/
|
|
203
|
+
export async function mutateChannels(fn) {
|
|
204
|
+
// Normalized channels captured from inside the mutation callback for post-write cache update. Normalization runs inside the callback (under the
|
|
205
|
+
// serialization lock) so the same normalized data is written to disk and assigned to the cache.
|
|
206
|
+
let normalizedChannels = {};
|
|
207
|
+
await channelsStore.mutate((data) => {
|
|
208
|
+
fn(data.channels);
|
|
209
|
+
// Normalize deltas before the beforeWrite hook serializes the data. This ensures the in-memory cache and the on-disk representation are identical.
|
|
210
|
+
// The beforeWrite hook (prepareChannelsForWrite) handles metadata injection only.
|
|
211
|
+
data.channels = normalizeChannelDeltas(data.channels);
|
|
212
|
+
normalizedChannels = data.channels;
|
|
213
|
+
});
|
|
214
|
+
// Side effects after successful write. These only run if the store mutation (including atomic file write) completed without error.
|
|
215
|
+
loadedUserChannels = { ...normalizedChannels };
|
|
216
|
+
buildServiceGroups(getMergedChannelMap());
|
|
167
217
|
userChannelsParseError = false;
|
|
168
218
|
userChannelsParseErrorMessage = undefined;
|
|
169
219
|
}
|
|
170
220
|
/**
|
|
171
221
|
* Deletes a user channel by key.
|
|
172
222
|
* @param key - The channel key to delete.
|
|
173
|
-
* @throws
|
|
223
|
+
* @throws FileStoreParseError if the channels file contains invalid JSON.
|
|
224
|
+
* @throws If the file cannot be written.
|
|
174
225
|
*/
|
|
175
226
|
export async function deleteUserChannel(key) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
throw new Error("Cannot delete channel: channels file contains invalid JSON.");
|
|
180
|
-
}
|
|
181
|
-
// Remove the channel.
|
|
182
|
-
Reflect.deleteProperty(result.channels, key);
|
|
183
|
-
// Save the modified channels.
|
|
184
|
-
await saveUserChannels(result.channels);
|
|
227
|
+
await mutateChannels((channels) => {
|
|
228
|
+
Reflect.deleteProperty(channels, key);
|
|
229
|
+
});
|
|
185
230
|
LOG.info("User channel '%s' deleted.", key);
|
|
186
231
|
}
|
|
187
232
|
/**
|
|
@@ -205,61 +250,72 @@ export async function resetUserChannels() {
|
|
|
205
250
|
/* User channels are loaded at server startup and stored in module-level state. This avoids repeated file reads during request handling.
|
|
206
251
|
*/
|
|
207
252
|
/**
|
|
208
|
-
* Initializes user channels by loading them from the file. This should be called once at server startup. Also builds
|
|
253
|
+
* 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.
|
|
209
254
|
*/
|
|
210
255
|
export async function initializeUserChannels() {
|
|
211
|
-
const result = await
|
|
212
|
-
//
|
|
213
|
-
//
|
|
256
|
+
const result = await readChannels();
|
|
257
|
+
// Populate module-level state from the loaded file. Migrations below may call mutateChannels(), which updates loadedUserChannels via its side effects
|
|
258
|
+
// with normalized data. Setting the initial state here ensures the cache is populated even when no migrations run.
|
|
259
|
+
loadedUserChannels = result.channels;
|
|
260
|
+
loadedTagRegistry = result.tagRegistry;
|
|
261
|
+
userChannelsParseError = result.parseError;
|
|
262
|
+
userChannelsParseErrorMessage = result.parseErrorMessage;
|
|
263
|
+
// Silent migrations: rename stale service keys to their current equivalents. Migrates service selections (channels.json) and user channel variant keys.
|
|
264
|
+
// The service filter (config.json) is handled separately below since it's already loaded into CONFIG at this point.
|
|
214
265
|
let channelsMigrated = false;
|
|
215
|
-
for (const [canonicalKey, selectedVariant] of Object.entries(result.
|
|
266
|
+
for (const [canonicalKey, selectedVariant] of Object.entries(result.serviceSelections)) {
|
|
267
|
+
// foxcom → foxone: original Fox service slug renamed.
|
|
216
268
|
if (selectedVariant.endsWith("-foxcom")) {
|
|
217
|
-
result.
|
|
269
|
+
result.serviceSelections[canonicalKey] = selectedVariant.slice(0, -6) + "foxone";
|
|
218
270
|
channelsMigrated = true;
|
|
219
271
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
result.channels[key.slice(0, -6) + "foxone"] = result.channels[key];
|
|
224
|
-
Reflect.deleteProperty(result.channels, key);
|
|
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";
|
|
225
275
|
channelsMigrated = true;
|
|
226
276
|
}
|
|
227
277
|
}
|
|
228
|
-
// Load
|
|
229
|
-
|
|
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);
|
|
230
281
|
if (channelsMigrated) {
|
|
231
|
-
await
|
|
232
|
-
|
|
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.");
|
|
233
293
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
LOG.info("Migrated provider filter from foxcom to foxone.");
|
|
250
|
-
}
|
|
251
|
-
// Upgrade inference for setupCompleted: existing users who already have providers or channels configured should not see the first-run setup wizard. If the
|
|
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.");
|
|
307
|
+
}
|
|
308
|
+
// Upgrade inference for setupCompleted: existing users who already have services or channels configured should not see the first-run setup wizard. If the
|
|
252
309
|
// flag is not set in the config file and evidence of prior configuration exists, infer true and persist.
|
|
253
310
|
if (!CONFIG.channels.setupCompleted) {
|
|
254
|
-
const
|
|
311
|
+
const hasServices = configuredServices.length > 0;
|
|
255
312
|
const hasUserChannels = Object.keys(loadedUserChannels).length > 0;
|
|
256
|
-
if (
|
|
313
|
+
if (hasServices || hasUserChannels) {
|
|
257
314
|
CONFIG.channels.setupCompleted = true;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
LOG.info("Inferred Provider Setup as completed from existing configuration.");
|
|
315
|
+
await mutateConfig((config) => {
|
|
316
|
+
config.channels ??= {};
|
|
317
|
+
config.channels.setupCompleted = true;
|
|
318
|
+
});
|
|
263
319
|
}
|
|
264
320
|
}
|
|
265
321
|
// One-time migration: stamp canonicalKey on existing user channel variant entries that were created before explicit variant relationships were introduced.
|
|
@@ -276,32 +332,81 @@ export async function initializeUserChannels() {
|
|
|
276
332
|
continue;
|
|
277
333
|
}
|
|
278
334
|
const prefix = key.substring(0, hyphenIndex);
|
|
279
|
-
// Only stamp canonicalKey if the prefix exists as a predefined channel. This matches the old
|
|
335
|
+
// Only stamp canonicalKey if the prefix exists as a predefined channel. This matches the old buildServiceGroups heuristic exactly.
|
|
280
336
|
if (prefix in PREDEFINED_CHANNELS) {
|
|
281
337
|
channel.canonicalKey = prefix;
|
|
282
338
|
canonicalKeyMigrated = true;
|
|
283
339
|
}
|
|
284
340
|
}
|
|
285
341
|
if (canonicalKeyMigrated) {
|
|
286
|
-
await
|
|
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
|
+
});
|
|
287
357
|
LOG.info("Migrated user channel variant entries with explicit canonical key declarations.");
|
|
288
358
|
}
|
|
289
|
-
//
|
|
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
|
+
// Build the merged channels map and then build service groups.
|
|
290
390
|
const mergedChannels = getMergedChannelMap();
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
391
|
+
// buildServiceGroups validates stored service selections against the rebuilt variant structure and reverts any that are stale. If any were cleaned, persist
|
|
392
|
+
// once so the cleanup survives restarts. At runtime (via mutateChannels), the in-memory cleanup is sufficient and persists naturally on the next write.
|
|
393
|
+
const staleSelections = buildServiceGroups(mergedChannels);
|
|
394
|
+
if (staleSelections.length > 0) {
|
|
395
|
+
await saveServiceSelections();
|
|
396
|
+
}
|
|
397
|
+
// Now that service groups are built, validate the configured service tags. Strip any unrecognized tags and warn.
|
|
398
|
+
if (configuredServices.length > 0) {
|
|
399
|
+
const knownTags = new Set(getAllServiceTags().map((t) => t.tag));
|
|
400
|
+
const validTags = configuredServices.filter((tag) => knownTags.has(tag));
|
|
401
|
+
const invalidTags = configuredServices.filter((tag) => !knownTags.has(tag));
|
|
297
402
|
if (invalidTags.length > 0) {
|
|
298
|
-
LOG.warn("Ignoring unrecognized
|
|
403
|
+
LOG.warn("Ignoring unrecognized service tags in configuration: %s.", invalidTags.join(", "));
|
|
299
404
|
}
|
|
300
|
-
|
|
301
|
-
CONFIG.channels.
|
|
405
|
+
setEnabledServices(validTags);
|
|
406
|
+
CONFIG.channels.enabledServices = validTags;
|
|
302
407
|
}
|
|
303
408
|
else {
|
|
304
|
-
|
|
409
|
+
setEnabledServices(configuredServices);
|
|
305
410
|
}
|
|
306
411
|
// Check for non-printable characters in loaded channel string values. These warnings are informational — loaded data is not modified.
|
|
307
412
|
for (const [channelKey, stored] of Object.entries(loadedUserChannels)) {
|
|
@@ -321,9 +426,11 @@ export async function initializeUserChannels() {
|
|
|
321
426
|
LOG.info("Loaded %d channels.", totalCount);
|
|
322
427
|
}
|
|
323
428
|
}
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
429
|
+
// User-editable fields for predefined channel delta overrides. Derived from CHANNEL_IDENTITY_FIELDS (identity fields like name, stationId, tags) plus the
|
|
430
|
+
// service-specific fields exposed in the edit form (channelSelector, profile, url). This derivation ensures that adding a new identity field to
|
|
431
|
+
// CHANNEL_IDENTITY_FIELDS automatically includes it in the delta allowlist.
|
|
432
|
+
const SERVICE_SPECIFIC_EDITABLE_FIELDS = ["channelSelector", "profile", "url"];
|
|
433
|
+
const DELTA_ALLOWED_FIELDS = new Set([...CHANNEL_IDENTITY_FIELDS, ...SERVICE_SPECIFIC_EDITABLE_FIELDS]);
|
|
327
434
|
/**
|
|
328
435
|
* Resolves a stored channel entry (full definition or delta) into a fully resolved Channel. For user-defined channels with no predefined equivalent, the stored
|
|
329
436
|
* entry is returned as-is (it must be a full Channel). For overrides of predefined channels, the predefined definition is used as a base and only allowlisted
|
|
@@ -340,7 +447,9 @@ export function resolveStoredChannel(key, stored) {
|
|
|
340
447
|
if (!predefined) {
|
|
341
448
|
return stored;
|
|
342
449
|
}
|
|
343
|
-
// Start with a copy of the predefined definition, then overlay allowlisted non-null delta fields.
|
|
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.
|
|
344
453
|
const resolved = { ...predefined };
|
|
345
454
|
for (const [field, value] of Object.entries(stored)) {
|
|
346
455
|
if (!DELTA_ALLOWED_FIELDS.has(field)) {
|
|
@@ -355,10 +464,13 @@ export function resolveStoredChannel(key, stored) {
|
|
|
355
464
|
resolved[field] = value;
|
|
356
465
|
}
|
|
357
466
|
}
|
|
467
|
+
// Defensive copy of reference-type fields to break shared references with PREDEFINED_CHANNELS. The delta overlay above may have replaced tags entirely (if
|
|
468
|
+
// the delta included a tags array), but when no delta is present for tags, the spread leaves the predefined's array reference on the resolved object.
|
|
469
|
+
resolved.tags &&= resolved.tags.slice();
|
|
358
470
|
return resolved;
|
|
359
471
|
}
|
|
360
472
|
/**
|
|
361
|
-
* Returns the merged channel map (predefined + user) without filtering by enabled status or
|
|
473
|
+
* Returns the merged channel map (predefined + user) without filtering by enabled status or service variants. Used internally for building service groups.
|
|
362
474
|
* Resolves any delta overrides into full Channel objects so the result contains only complete definitions.
|
|
363
475
|
* @returns The complete merged channel map.
|
|
364
476
|
*/
|
|
@@ -384,12 +496,12 @@ function getMergedChannelMap() {
|
|
|
384
496
|
* The enabled field reflects whether the channel is available for streaming. Predefined-only channels can be disabled via configuration; user and override
|
|
385
497
|
* channels are always enabled.
|
|
386
498
|
*
|
|
387
|
-
*
|
|
499
|
+
* Service variants (non-canonical keys in service groups) are filtered out from this listing — they are accessed via the service selection mechanism instead.
|
|
388
500
|
*
|
|
389
|
-
* Override entries produce a new resolved Channel object (via resolveStoredChannel()), which is a different reference from PREDEFINED_CHANNELS[key]. The
|
|
390
|
-
* system (
|
|
501
|
+
* Override entries produce a new resolved Channel object (via resolveStoredChannel()), which is a different reference from PREDEFINED_CHANNELS[key]. The service
|
|
502
|
+
* system (services.ts) relies on this reference difference to detect user overrides via isUserOverride(). Predefined-only entries preserve the original reference.
|
|
391
503
|
*
|
|
392
|
-
* The returned channel field is
|
|
504
|
+
* The returned channel field is service-resolved: when a non-default service is selected for a channel, the entry's channel reflects the selected variant's URL,
|
|
393
505
|
* channelSelector, stationId, and channelNumber. The entry's key always remains the canonical key.
|
|
394
506
|
* @returns Sorted array of channel listing entries.
|
|
395
507
|
*/
|
|
@@ -397,8 +509,8 @@ export function getChannelListing() {
|
|
|
397
509
|
const allKeys = new Set([...Object.keys(PREDEFINED_CHANNELS), ...Object.keys(loadedUserChannels)]);
|
|
398
510
|
const listing = [];
|
|
399
511
|
for (const key of allKeys) {
|
|
400
|
-
// Skip
|
|
401
|
-
if (
|
|
512
|
+
// Skip service variants — they're accessed via service selection, not as separate channels.
|
|
513
|
+
if (isServiceVariant(key)) {
|
|
402
514
|
continue;
|
|
403
515
|
}
|
|
404
516
|
const isPredefined = key in PREDEFINED_CHANNELS;
|
|
@@ -415,14 +527,14 @@ export function getChannelListing() {
|
|
|
415
527
|
source = "predefined";
|
|
416
528
|
}
|
|
417
529
|
// For user entries (including overrides), resolve the stored delta/definition into a full Channel. The resolved object is a new reference, which preserves
|
|
418
|
-
// the isUserOverride() contract in
|
|
530
|
+
// the isUserOverride() contract in services.ts (reference comparison against PREDEFINED_CHANNELS[key]). Predefined-only entries keep the original reference.
|
|
419
531
|
const channel = isUser ? resolveStoredChannel(key, loadedUserChannels[key]) : PREDEFINED_CHANNELS[key];
|
|
420
|
-
// When a non-default
|
|
532
|
+
// When a non-default service is selected, resolve the variant so consumers see the correct URL, channelSelector, stationId, and channelNumber. We skip
|
|
421
533
|
// resolution when the resolved key matches the canonical key — the channel object is already correct and preserving its reference avoids a redundant lookup.
|
|
422
|
-
const resolvedKey =
|
|
534
|
+
const resolvedKey = resolveServiceKey(key);
|
|
423
535
|
const resolvedChannel = (resolvedKey !== key) ? getResolvedChannel(resolvedKey) : undefined;
|
|
424
536
|
listing.push({
|
|
425
|
-
|
|
537
|
+
availableByService: isChannelAvailableByService(key),
|
|
426
538
|
channel: resolvedChannel ?? channel,
|
|
427
539
|
enabled: !isPredefinedChannelDisabled(key),
|
|
428
540
|
key,
|
|
@@ -434,14 +546,14 @@ export function getChannelListing() {
|
|
|
434
546
|
return listing;
|
|
435
547
|
}
|
|
436
548
|
/**
|
|
437
|
-
* Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded
|
|
438
|
-
*
|
|
549
|
+
* Returns all available channels (predefined + user), with user channels taking precedence on key conflicts. Disabled predefined channels are excluded. Built on
|
|
550
|
+
* top of getChannelListing() to ensure a single merging code path.
|
|
439
551
|
* @returns The merged channel map with disabled predefined channels filtered out.
|
|
440
552
|
*/
|
|
441
553
|
export function getAllChannels() {
|
|
442
554
|
const result = {};
|
|
443
555
|
for (const entry of getChannelListing()) {
|
|
444
|
-
if (entry.enabled && entry.
|
|
556
|
+
if (entry.enabled && entry.availableByService) {
|
|
445
557
|
result[entry.key] = entry.channel;
|
|
446
558
|
}
|
|
447
559
|
}
|
|
@@ -454,6 +566,104 @@ export function getAllChannels() {
|
|
|
454
566
|
export function getUserChannels() {
|
|
455
567
|
return { ...loadedUserChannels };
|
|
456
568
|
}
|
|
569
|
+
// Tag Registry.
|
|
570
|
+
/**
|
|
571
|
+
* Returns the current tag registry state.
|
|
572
|
+
* @returns A copy of the tag registry with user-created tags and deleted predefined tags.
|
|
573
|
+
*/
|
|
574
|
+
export function getTagRegistry() {
|
|
575
|
+
return { deletedTags: [...loadedTagRegistry.deletedTags], tags: [...loadedTagRegistry.tags] };
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Updates the tag registry state in memory. Call saveTagRegistry() after to persist the change.
|
|
579
|
+
* @param registry - The new tag registry state.
|
|
580
|
+
*/
|
|
581
|
+
export function setTagRegistry(registry) {
|
|
582
|
+
loadedTagRegistry = { deletedTags: registry.deletedTags.sort(), tags: registry.tags.sort() };
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Returns the active tag vocabulary: predefined tags minus user-deleted tags, plus user-created tags, sorted alphabetically. This is the single source of truth
|
|
586
|
+
* for which tags are visible, assignable, and queryable throughout the system. Tags not in this list are invisible to the UI and rejected by the ?tag= query
|
|
587
|
+
* parameter, even if they exist on channel definitions (vocabulary-as-lens model).
|
|
588
|
+
* @returns Sorted array of active tag strings.
|
|
589
|
+
*/
|
|
590
|
+
export function getActiveTagVocabulary() {
|
|
591
|
+
const active = PREDEFINED_TAGS.filter((tag) => !loadedTagRegistry.deletedTags.some((d) => tagsMatch(d, tag)));
|
|
592
|
+
// Merge user tags, deduplicate case-insensitively (in case a user tag matches a non-deleted predefined tag), and sort. When a predefined and user tag
|
|
593
|
+
// collide case-insensitively, the predefined form wins since it appears first in the Map.
|
|
594
|
+
const seen = new Map();
|
|
595
|
+
for (const tag of [...active, ...loadedTagRegistry.tags]) {
|
|
596
|
+
const lower = tag.toLowerCase();
|
|
597
|
+
if (!seen.has(lower)) {
|
|
598
|
+
seen.set(lower, tag);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return [...seen.values()].toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* 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
|
|
605
|
+
* the active vocabulary are filtered out, ensuring only assignable and queryable tags are visible in the UI and playlist responses.
|
|
606
|
+
* @param channel - The channel to get effective tags for.
|
|
607
|
+
* @returns Sorted array of effective tag strings, or empty array if the channel has no tags or none are in the active vocabulary.
|
|
608
|
+
*/
|
|
609
|
+
export function getChannelEffectiveTags(channel) {
|
|
610
|
+
if (!channel.tags || (channel.tags.length === 0)) {
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
const vocabulary = getActiveTagVocabulary();
|
|
614
|
+
return channel.tags.filter((tag) => vocabulary.some((v) => tagsMatch(v, tag)));
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* 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 — all tag identity checks should use it rather than inline toLowerCase() calls.
|
|
619
|
+
* @param a - The first tag.
|
|
620
|
+
* @param b - The second tag.
|
|
621
|
+
* @returns True if the tags match case-insensitively.
|
|
622
|
+
*/
|
|
623
|
+
export function tagsMatch(a, b) {
|
|
624
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* 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
|
+
* 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
|
+
* function handles loading stored channel data, applying the transform, and saving. Delta normalization in mutateChannels() handles predefined channel
|
|
630
|
+
* delta computation automatically — callers do not need to reason about deltas vs. full definitions.
|
|
631
|
+
* @param filter - Predicate selecting which listing entries to transform. Receives each ChannelListingEntry from getChannelListing().
|
|
632
|
+
* @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
|
+
* not guaranteed). Must return the desired tags array (may be empty to clear all tags). The returned array is sorted before storage.
|
|
634
|
+
* @returns Object with the affected channel keys and success status. On parse error, returns an error message and empty affected keys.
|
|
635
|
+
*/
|
|
636
|
+
export async function transformChannelTags(filter, transform) {
|
|
637
|
+
const affectedKeys = [];
|
|
638
|
+
try {
|
|
639
|
+
await mutateChannels((channels) => {
|
|
640
|
+
for (const entry of getChannelListing()) {
|
|
641
|
+
if (!filter(entry)) {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const currentTags = entry.channel.tags ?? [];
|
|
645
|
+
const newTags = transform(currentTags).sort();
|
|
646
|
+
// Skip channels where the transform produced no change.
|
|
647
|
+
if (JSON.stringify(newTags) === JSON.stringify(currentTags.toSorted())) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
// Set the new tags on the stored entry. Callers use null uniformly for "clear/empty" - the normalizer in mutateChannels() handles the storage
|
|
651
|
+
// conventions: delta normalization for predefined channels (comparing against raw definitions), null-stripping for user channels.
|
|
652
|
+
const existing = channels[entry.key] ?? {};
|
|
653
|
+
existing.tags = (newTags.length > 0) ? newTags : null;
|
|
654
|
+
channels[entry.key] = existing;
|
|
655
|
+
affectedKeys.push(entry.key);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (error instanceof FileStoreParseError) {
|
|
661
|
+
return { affectedKeys: [], error: "Cannot update tags: channels file contains invalid JSON." };
|
|
662
|
+
}
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
return { affectedKeys };
|
|
666
|
+
}
|
|
457
667
|
/**
|
|
458
668
|
* Returns the predefined channel definition for a key.
|
|
459
669
|
* @param key - The channel key to look up.
|
|
@@ -554,19 +764,15 @@ export function isUserChannel(key) {
|
|
|
554
764
|
* re-enable. This is useful for users who don't want certain predefined channels cluttering their channel list.
|
|
555
765
|
*/
|
|
556
766
|
/**
|
|
557
|
-
* Checks if a predefined channel is disabled.
|
|
767
|
+
* 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
|
+
* 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.
|
|
558
769
|
* @param key - The channel key to check.
|
|
559
770
|
* @returns True if the channel is predefined and disabled.
|
|
560
771
|
*/
|
|
561
772
|
export function isPredefinedChannelDisabled(key) {
|
|
562
|
-
// Only predefined channels can be disabled via this mechanism.
|
|
563
773
|
if (!isPredefinedChannel(key)) {
|
|
564
774
|
return false;
|
|
565
775
|
}
|
|
566
|
-
// If a user channel overrides this predefined channel, the predefined channel's disabled state is irrelevant.
|
|
567
|
-
if (isUserChannel(key)) {
|
|
568
|
-
return false;
|
|
569
|
-
}
|
|
570
776
|
return CONFIG.channels.disabledPredefined.includes(key);
|
|
571
777
|
}
|
|
572
778
|
/**
|
|
@@ -577,14 +783,14 @@ export function getDisabledPredefinedChannels() {
|
|
|
577
783
|
return [...CONFIG.channels.disabledPredefined];
|
|
578
784
|
}
|
|
579
785
|
/**
|
|
580
|
-
* Returns all predefined channels regardless of disabled state, excluding
|
|
581
|
-
*
|
|
786
|
+
* Returns all predefined channels regardless of disabled state, excluding service variants. Used by the UI to show all predefined channels including disabled ones.
|
|
787
|
+
* Service variants are internal implementation details of channel delivery and are not channels themselves.
|
|
582
788
|
* @returns The predefined channel map with canonical entries only.
|
|
583
789
|
*/
|
|
584
790
|
export function getPredefinedChannels() {
|
|
585
791
|
const result = {};
|
|
586
792
|
for (const [key, channel] of Object.entries(PREDEFINED_CHANNELS)) {
|
|
587
|
-
if (
|
|
793
|
+
if (isServiceVariant(key)) {
|
|
588
794
|
continue;
|
|
589
795
|
}
|
|
590
796
|
result[key] = channel;
|
|
@@ -597,15 +803,15 @@ export function getPredefinedChannels() {
|
|
|
597
803
|
/**
|
|
598
804
|
* Filters predefined channel keys by their relationship to the Pacific timezone naming convention. For "pacific" mode, returns keys that end in "p" and
|
|
599
805
|
* 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")
|
|
600
|
-
* exists.
|
|
806
|
+
* exists. Service variants are excluded — only canonical keys are returned.
|
|
601
807
|
* @param side - Which side of the East/Pacific pair to select.
|
|
602
808
|
* @returns Sorted array of matching canonical predefined channel keys.
|
|
603
809
|
*/
|
|
604
810
|
function filterPredefinedKeysByTimezone(side) {
|
|
605
811
|
const keys = [];
|
|
606
812
|
for (const key of Object.keys(PREDEFINED_CHANNELS)) {
|
|
607
|
-
// Skip
|
|
608
|
-
if (
|
|
813
|
+
// Skip service variants — they are internal implementation details, not channels.
|
|
814
|
+
if (isServiceVariant(key)) {
|
|
609
815
|
continue;
|
|
610
816
|
}
|
|
611
817
|
if (side === "pacific") {
|
|
@@ -641,14 +847,14 @@ export function getEastWithPacificPredefinedKeys() {
|
|
|
641
847
|
}
|
|
642
848
|
/**
|
|
643
849
|
* Computes enabled/total counts for all three predefined channel scopes (all, east, pacific) against the current disabled set. Both the enabled count and
|
|
644
|
-
* the total are filtered by
|
|
850
|
+
* the total are filtered by service availability so that the displayed counts match the visible channel table. When no service filter is active,
|
|
645
851
|
* all channels pass and the counts are unaffected. Used by the server-side HTML renderer and both toggle endpoints to provide consistent counts to the client.
|
|
646
852
|
* @returns An object with `all`, `east`, and `pacific` keys, each containing `{ enabled, total }`.
|
|
647
853
|
*/
|
|
648
854
|
export function getPredefinedScopeCounts() {
|
|
649
|
-
const allKeys = Object.keys(getPredefinedChannels()).filter((k) =>
|
|
650
|
-
const eastKeys = getEastWithPacificPredefinedKeys().filter((k) =>
|
|
651
|
-
const pacificKeys = getPacificPredefinedKeys().filter((k) =>
|
|
855
|
+
const allKeys = Object.keys(getPredefinedChannels()).filter((k) => isChannelAvailableByService(k));
|
|
856
|
+
const eastKeys = getEastWithPacificPredefinedKeys().filter((k) => isChannelAvailableByService(k));
|
|
857
|
+
const pacificKeys = getPacificPredefinedKeys().filter((k) => isChannelAvailableByService(k));
|
|
652
858
|
const disabled = new Set(CONFIG.channels.disabledPredefined);
|
|
653
859
|
return {
|
|
654
860
|
all: { enabled: allKeys.filter((k) => !disabled.has(k)).length, total: allKeys.length },
|
|
@@ -877,15 +1083,25 @@ export function validateImportedChannels(data, validProfiles) {
|
|
|
877
1083
|
}
|
|
878
1084
|
return { channels, errors, valid: errors.length === 0 };
|
|
879
1085
|
}
|
|
880
|
-
/*
|
|
1086
|
+
/* Service selections are stored in the channels.json file alongside user channels. When a selection changes, we save the entire file (channels + selections)
|
|
881
1087
|
* to persist the change.
|
|
882
1088
|
*/
|
|
883
1089
|
/**
|
|
884
|
-
* Saves the current
|
|
1090
|
+
* Saves the current service selections to the channels file. The no-op mutation triggers a write that picks up current serviceSelections from module state via the
|
|
1091
|
+
* beforeWrite hook.
|
|
1092
|
+
* @throws If the file cannot be written.
|
|
1093
|
+
*/
|
|
1094
|
+
export async function saveServiceSelections() {
|
|
1095
|
+
// No-op mutation: the beforeWrite hook injects current serviceSelections from module state.
|
|
1096
|
+
await mutateChannels(() => { });
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Saves the current tag registry to the channels file. The no-op mutation triggers a write that picks up the current tag registry from module state via the
|
|
1100
|
+
* beforeWrite hook.
|
|
885
1101
|
* @throws If the file cannot be written.
|
|
886
1102
|
*/
|
|
887
|
-
export async function
|
|
888
|
-
//
|
|
889
|
-
await
|
|
1103
|
+
export async function saveTagRegistry() {
|
|
1104
|
+
// No-op mutation: the beforeWrite hook injects the current tag registry from module state.
|
|
1105
|
+
await mutateChannels(() => { });
|
|
890
1106
|
}
|
|
891
1107
|
//# sourceMappingURL=userChannels.js.map
|