prismcast 1.10.2 → 1.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +5 -3
- package/dist/app.js +42 -41
- package/dist/app.js.map +1 -1
- package/dist/browser/hlsPlaylistObserver.d.ts +38 -0
- package/dist/browser/hlsPlaylistObserver.js +113 -0
- package/dist/browser/hlsPlaylistObserver.js.map +1 -0
- package/dist/browser/index.d.ts +26 -16
- package/dist/browser/index.js +95 -70
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/manifestInterceptor.d.ts +29 -29
- package/dist/browser/manifestInterceptor.js +106 -212
- package/dist/browser/manifestInterceptor.js.map +1 -1
- package/dist/browser/tabNetworkObserver.d.ts +48 -0
- package/dist/browser/tabNetworkObserver.js +140 -0
- package/dist/browser/tabNetworkObserver.js.map +1 -0
- package/dist/config/consistencyProbe.js +13 -11
- package/dist/config/consistencyProbe.js.map +1 -1
- package/dist/config/paths.d.ts +3 -8
- package/dist/config/paths.js +3 -10
- package/dist/config/paths.js.map +1 -1
- package/dist/config/profiles.d.ts +1 -1
- package/dist/config/profiles.js +6 -6
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/services.d.ts +5 -5
- package/dist/config/services.js +8 -8
- package/dist/config/services.js.map +1 -1
- package/dist/config/sites.d.ts +2 -2
- package/dist/config/sites.js +6 -6
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +108 -14
- package/dist/config/userChannels.js +291 -37
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.js +1 -1
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +3 -3
- package/dist/config/userProfiles.js +19 -19
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/index.js +7 -9
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/native/index.js +9 -18
- package/dist/native/index.js.map +1 -1
- package/dist/native/proxy.d.ts +1 -4
- package/dist/native/proxy.js +0 -9
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/clientActions.d.ts +97 -0
- package/dist/routes/clientActions.js +113 -0
- package/dist/routes/clientActions.js.map +1 -0
- package/dist/routes/components.d.ts +4 -3
- package/dist/routes/components.js +36 -39
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/endpoints/browse.js.map +1 -1
- package/dist/routes/config/channels/endpoints/crud.js +37 -72
- package/dist/routes/config/channels/endpoints/crud.js.map +1 -1
- package/dist/routes/config/channels/endpoints/importExport.js.map +1 -1
- package/dist/routes/config/channels/healthBridge.d.ts +23 -0
- package/dist/routes/config/channels/healthBridge.js +71 -0
- package/dist/routes/config/channels/healthBridge.js.map +1 -0
- package/dist/routes/config/channels/table.js +97 -84
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/services.js +25 -22
- package/dist/routes/config/services.js.map +1 -1
- package/dist/routes/config/settings.js +9 -6
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +277 -139
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/logs.js +1 -1
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/root/content.js +18 -14
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +17 -14
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +50 -21
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +72 -3
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +60 -0
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.handlers.d.ts +5 -27
- package/dist/routes/root/scripts/status.handlers.js +8 -60
- package/dist/routes/root/scripts/status.handlers.js.map +1 -1
- package/dist/routes/root/scripts/status.js +6 -1
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/streams.js +5 -3
- package/dist/routes/streams.js.map +1 -1
- package/dist/service/commands.context.js +1 -1
- package/dist/service/commands.context.js.map +1 -1
- package/dist/service/generators.context.js +1 -1
- package/dist/service/generators.context.js.map +1 -1
- package/dist/service/generators.d.ts +1 -1
- package/dist/service/generators.js +3 -11
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/hls.js +1 -5
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +1 -1
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/preroll.d.ts +1 -1
- package/dist/streaming/preroll.js +1 -5
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +5 -8
- package/dist/streaming/statusEmitter.js +0 -5
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +9 -0
- package/dist/types/channels.js.map +1 -1
- package/dist/types/profiles.d.ts +2 -2
- package/dist/upgrade/commands.context.js +14 -10
- package/dist/upgrade/commands.context.js.map +1 -1
- package/dist/upgrade/commands.d.ts +7 -15
- package/dist/upgrade/commands.js +27 -28
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/upgrade/detection.context.js +2 -1
- package/dist/upgrade/detection.context.js.map +1 -1
- package/dist/upgrade/detection.d.ts +2 -1
- package/dist/upgrade/detection.js +20 -51
- package/dist/upgrade/detection.js.map +1 -1
- package/dist/upgrade/index.d.ts +1 -0
- package/dist/upgrade/index.js +1 -0
- package/dist/upgrade/index.js.map +1 -1
- package/dist/upgrade/lifecycle.context.d.ts +19 -0
- package/dist/upgrade/lifecycle.context.js +47 -0
- package/dist/upgrade/lifecycle.context.js.map +1 -0
- package/dist/upgrade/lifecycle.d.ts +72 -0
- package/dist/upgrade/lifecycle.js +176 -0
- package/dist/upgrade/lifecycle.js.map +1 -0
- package/dist/upgrade/pathHandle.d.ts +28 -0
- package/dist/upgrade/pathHandle.js +101 -0
- package/dist/upgrade/pathHandle.js.map +1 -0
- package/dist/utils/bootSession.context.d.ts +15 -0
- package/dist/utils/bootSession.context.js +59 -0
- package/dist/utils/bootSession.context.js.map +1 -0
- package/dist/utils/bootSession.d.ts +16 -0
- package/dist/utils/bootSession.js +20 -0
- package/dist/utils/bootSession.js.map +1 -0
- package/dist/utils/clock.js +1 -1
- package/dist/utils/ffmpeg.js +1 -1
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markup.d.ts +10 -0
- package/dist/utils/markup.js +23 -0
- package/dist/utils/markup.js.map +1 -1
- package/dist/utils/pid.d.ts +2 -25
- package/dist/utils/pid.js +10 -49
- package/dist/utils/pid.js.map +1 -1
- package/dist/utils/processInspector.context.d.ts +7 -0
- package/dist/utils/processInspector.context.js +104 -0
- package/dist/utils/processInspector.context.js.map +1 -0
- package/dist/utils/processInspector.d.ts +54 -0
- package/dist/utils/processInspector.js +125 -0
- package/dist/utils/processInspector.js.map +1 -0
- package/dist/utils/retry.d.ts +1 -1
- package/dist/utils/retry.js +1 -1
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/runtimeIdentity.context.d.ts +6 -0
- package/dist/utils/runtimeIdentity.context.js +13 -0
- package/dist/utils/runtimeIdentity.context.js.map +1 -0
- package/dist/utils/runtimeIdentity.d.ts +96 -0
- package/dist/utils/runtimeIdentity.js +171 -0
- package/dist/utils/runtimeIdentity.js.map +1 -0
- package/dist/utils/timing.d.ts +1 -1
- package/dist/utils/timing.js +0 -5
- package/dist/utils/timing.js.map +1 -1
- package/dist/utils/version.js +40 -10
- package/dist/utils/version.js.map +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { CHANNEL_BINDING_KEYS, CHANNEL_IDENTITY_KEYS, DELTA_ELIGIBLE_BINDING_KEYS, DELTA_ELIGIBLE_IDENTITY_KEYS } from "../types/index.js";
|
|
6
6
|
import { FileStoreParseError, createFileStore } from "./persistence.js";
|
|
7
|
-
import { LOG, containsNonPrintable, sanitizeString } from "../utils/index.js";
|
|
7
|
+
import { LOG, containsNonPrintable, extractDomain, sanitizeString } from "../utils/index.js";
|
|
8
8
|
import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../channels/index.js";
|
|
9
9
|
import { buildServiceGroups, getAllServiceTags, getResolvedChannel, isChannelAvailableByService, isServiceVariant, resolveServiceKey, setEnabledServices, setServiceSelections } from "./services.js";
|
|
10
10
|
import { CONFIG } from "./index.js";
|
|
@@ -136,18 +136,18 @@ function parseChannelsFile(raw) {
|
|
|
136
136
|
}
|
|
137
137
|
return { channels, migrationsApplied, schemaVersion, serviceSelections, tagRegistry };
|
|
138
138
|
}
|
|
139
|
-
/* Array-valued ChannelDelta fields whose equality requires canonical case-insensitive ordering before
|
|
140
|
-
* unordered-set semantics rather than order-significant lists; equality for these fields
|
|
141
|
-
* not defeat the match. Tags are the only such field today, but the property-based framing is the architectural truth:
|
|
142
|
-
* this set. The `satisfies` constraint keeps the list in sync with ChannelDelta's actual keys at compile time -
|
|
143
|
-
* to be updated.
|
|
139
|
+
/* Array-valued ChannelDelta fields whose equality requires canonical case-insensitive ordering before structural comparison via isDeepStrictEqual. The set
|
|
140
|
+
* declares which array fields have unordered-set semantics rather than order-significant lists; equality for these fields runs both sides through sortTags so
|
|
141
|
+
* authoring-order or case differences do not defeat the match. Tags are the only such field today, but the property-based framing is the architectural truth:
|
|
142
|
+
* any unordered-set array field belongs in this set. The `satisfies` constraint keeps the list in sync with ChannelDelta's actual keys at compile time -
|
|
143
|
+
* renaming or removing a field forces this tuple to be updated.
|
|
144
144
|
*/
|
|
145
145
|
const CANONICAL_SORTED_ARRAY_FIELDS = new Set(["tags"]);
|
|
146
146
|
/**
|
|
147
147
|
* Normalizes a single stored entry as a delta against a base channel. Delta fields are dropped when they are no-ops: undefined values, nulls against a base
|
|
148
|
-
* that has no such field, and values that match the base exactly. Array-valued fields use
|
|
149
|
-
* for the fields listed in CANONICAL_SORTED_ARRAY_FIELDS so authoring-order differences do not defeat the match. Non-delta fields (e.g.,
|
|
150
|
-
* through unchanged so the stored entry retains its relationship metadata.
|
|
148
|
+
* that has no such field, and values that match the base exactly. Array-valued fields use isDeepStrictEqual for structural equality, with sortTags applied to
|
|
149
|
+
* both sides for the fields listed in CANONICAL_SORTED_ARRAY_FIELDS so authoring-order differences do not defeat the match. Non-delta fields (e.g.,
|
|
150
|
+
* canonicalKey) pass through unchanged so the stored entry retains its relationship metadata.
|
|
151
151
|
* @param stored - The raw stored entry.
|
|
152
152
|
* @param base - The base Channel to diff against (predefined definition for overrides, resolved canonical for variants).
|
|
153
153
|
* @returns The normalized entry, or null when every field is a no-op and the entry would carry no information.
|
|
@@ -183,8 +183,8 @@ function normalizeEntryAgainstBase(stored, base) {
|
|
|
183
183
|
}
|
|
184
184
|
continue;
|
|
185
185
|
}
|
|
186
|
-
// Values that match the base exactly are redundant. Arrays compare
|
|
187
|
-
// first so the equality check is order-independent.
|
|
186
|
+
// Values that match the base exactly are redundant. Arrays compare structurally via isDeepStrictEqual; fields in CANONICAL_SORTED_ARRAY_FIELDS are routed
|
|
187
|
+
// through sortTags on both sides first so the equality check is order-independent.
|
|
188
188
|
const baseValue = base[field];
|
|
189
189
|
if (Array.isArray(value)) {
|
|
190
190
|
const canonical = CANONICAL_SORTED_ARRAY_FIELDS.has(field);
|
|
@@ -378,26 +378,216 @@ function buildResolvedCanonicals(stored) {
|
|
|
378
378
|
function stripNulls(stored) {
|
|
379
379
|
return Object.fromEntries(Object.entries(stored).filter(([, v]) => v !== null));
|
|
380
380
|
}
|
|
381
|
+
/* Module-private partition Sets, derived once from the type-system source of truth. The Sets back the public picker functions below; callers never reference
|
|
382
|
+
* the Sets directly. Adding or renaming a field in CHANNEL_IDENTITY_KEYS / CHANNEL_BINDING_KEYS automatically updates both Sets at runtime - the partition
|
|
383
|
+
* lives in types/channels.ts and these are the single derived runtime form.
|
|
384
|
+
*/
|
|
385
|
+
const IDENTITY_FIELDS = new Set(CHANNEL_IDENTITY_KEYS);
|
|
386
|
+
const BINDING_FIELDS = new Set(CHANNEL_BINDING_KEYS);
|
|
387
|
+
/**
|
|
388
|
+
* Internal: filters a delta to fields in the supplied allowlist. Backs pickIdentityFields and pickBindingFields. Not exported - the public surface is the
|
|
389
|
+
* named pickers, which hide the partition Sets so consumers never have to know how the partition is enumerated.
|
|
390
|
+
*/
|
|
391
|
+
function filterDeltaFields(delta, allowlist) {
|
|
392
|
+
const filtered = {};
|
|
393
|
+
for (const [field, value] of Object.entries(delta)) {
|
|
394
|
+
if (allowlist.has(field)) {
|
|
395
|
+
filtered[field] = value;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return filtered;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Returns the identity-only subset of a ChannelDelta - the fields enumerated by CHANNEL_IDENTITY_KEYS. Used by the per-field write router (PUT handler) and
|
|
402
|
+
* the storage normalizer's heal path to split a full delta into identity-only and binding-only halves so each half is routed to the correct stored entry.
|
|
403
|
+
* @param delta - The delta to project.
|
|
404
|
+
* @returns A new delta with only identity fields retained.
|
|
405
|
+
*/
|
|
406
|
+
export function pickIdentityFields(delta) {
|
|
407
|
+
return filterDeltaFields(delta, IDENTITY_FIELDS);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Returns the binding-only subset of a ChannelDelta - the fields enumerated by CHANNEL_BINDING_KEYS. Peer to pickIdentityFields; together they cover the
|
|
411
|
+
* delta surface and partition it cleanly.
|
|
412
|
+
* @param delta - The delta to project.
|
|
413
|
+
* @returns A new delta with only binding fields retained.
|
|
414
|
+
*/
|
|
415
|
+
export function pickBindingFields(delta) {
|
|
416
|
+
return filterDeltaFields(delta, BINDING_FIELDS);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Applies a binding delta to an existing variant entry with replace semantics: any prior binding fields on the existing entry are stripped first, then the
|
|
420
|
+
* new delta's binding fields are applied. Non-binding fields (notably canonicalKey) on the existing entry are preserved. Returns null when the resulting
|
|
421
|
+
* entry would be empty.
|
|
422
|
+
*
|
|
423
|
+
* Used by the producer (handlePredefinedEdit) when the user submits a form with explicit values - the form values are the authoritative declaration of the
|
|
424
|
+
* variant's binding state, so any pre-existing binding override on the variant entry is wiped.
|
|
425
|
+
*
|
|
426
|
+
* Pair with mergeVariantBinding (preserve-existing semantics) below. The two functions name the precedence rule explicitly so future callers pick the right
|
|
427
|
+
* semantic for their context.
|
|
428
|
+
* @param existing - The existing variant entry, if any.
|
|
429
|
+
* @param delta - The new binding-only delta. The producer is asserting these are the authoritative values.
|
|
430
|
+
* @returns The merged stored entry, or null when the result would be empty.
|
|
431
|
+
*/
|
|
432
|
+
export function replaceVariantBinding(existing, delta) {
|
|
433
|
+
const next = { ...existing };
|
|
434
|
+
// Strip prior binding-field overrides; the new delta is authoritative for this category.
|
|
435
|
+
for (const field of CHANNEL_BINDING_KEYS) {
|
|
436
|
+
Reflect.deleteProperty(next, field);
|
|
437
|
+
}
|
|
438
|
+
// Apply the new binding-field overrides.
|
|
439
|
+
Object.assign(next, delta);
|
|
440
|
+
return (Object.keys(next).length > 0) ? next : null;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Merges a binding delta into an existing variant entry with preserve-existing semantics: existing variant fields win on conflicts; the delta only fills
|
|
444
|
+
* fields the variant doesn't already declare. Always returns a non-empty entry (caller is expected to invoke this only when delta has something to contribute).
|
|
445
|
+
*
|
|
446
|
+
* Used by the storage normalizer's canonical-binding overlap heal. The heal context is healing legacy data, not recording a new explicit user intent - the
|
|
447
|
+
* variant's prior customization is more recent or at least equally legitimate as the canonical-side stored binding being moved over, so the variant wins.
|
|
448
|
+
*
|
|
449
|
+
* Pair with replaceVariantBinding (replace semantics) above. The two functions name the precedence rule explicitly.
|
|
450
|
+
* @param existing - The existing variant entry, if any. Existing fields take precedence on conflicts.
|
|
451
|
+
* @param delta - The binding-only delta whose fields fill any gaps the existing entry doesn't declare.
|
|
452
|
+
* @returns The merged stored entry. Always non-empty (delta contributes at least the keys the existing didn't have).
|
|
453
|
+
*/
|
|
454
|
+
export function mergeVariantBinding(existing, delta) {
|
|
455
|
+
const merged = { ...delta };
|
|
456
|
+
if (existing) {
|
|
457
|
+
Object.assign(merged, existing);
|
|
458
|
+
}
|
|
459
|
+
return merged;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Intersects a binding delta with a criterion delta by key, returning a new binding-only delta containing fields that appear in BOTH and using values from
|
|
463
|
+
* the primary delta. Used by the URL-inferred branch of the per-field write router: when a user submits a form with no pre-selected variant but a URL that
|
|
464
|
+
* implies a sibling, the producer must avoid treating unset form fields as explicit clears against the inferred variant's predefined defaults. Restricting
|
|
465
|
+
* the variant-relative delta to fields the user actually changed (relative to the canonical) prevents that silent destruction of tuning data.
|
|
466
|
+
*
|
|
467
|
+
* Pure function - operates on two deltas independently of any storage state. Generic over the criterion: any binding field present on `criterion` makes
|
|
468
|
+
* `primary`'s value for that field eligible to land on the result.
|
|
469
|
+
* @param primary - The delta whose values populate the result.
|
|
470
|
+
* @param criterion - The delta whose key set restricts which fields propagate.
|
|
471
|
+
* @returns A new binding-only delta with primary's values for keys present in both, restricted to CHANNEL_BINDING_KEYS.
|
|
472
|
+
*/
|
|
473
|
+
export function intersectBindingDeltas(primary, criterion) {
|
|
474
|
+
const restricted = {};
|
|
475
|
+
for (const field of CHANNEL_BINDING_KEYS) {
|
|
476
|
+
if ((field in criterion) && (field in primary)) {
|
|
477
|
+
restricted[field] = primary[field];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return restricted;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Given a canonical key and a URL, returns the sibling variant key whose effective URL extracts to the same domain. Returns undefined when no sibling matches -
|
|
484
|
+
* that is the legitimate "Custom URL" case where the user genuinely has a non-predefined URL.
|
|
485
|
+
*
|
|
486
|
+
* Used by both the producer (handlePredefinedEdit in routes/config/channels/endpoints/crud.ts) and the storage normalizer (normalizeChannelDeltas) to enforce
|
|
487
|
+
* the rule that a canonical override's binding fields must not duplicate a sibling variant's domain. When a sibling matches, the user's intent is "default this
|
|
488
|
+
* channel to the sibling's service" - architecturally expressed via serviceSelections, not via overriding the canonical URL. Both call sites share this single
|
|
489
|
+
* helper so the inference rule lives in exactly one place.
|
|
490
|
+
*
|
|
491
|
+
* Pure function. Walks predefined and user-stored siblings, sorts by key for determinism (multiple variants with the same domain pick the alphabetically-first
|
|
492
|
+
* one), and returns the first match. Effective URL is the user-stored override (if any string-valued) layered onto the predefined variant URL - same precedence
|
|
493
|
+
* as resolveVariant.
|
|
494
|
+
* @param canonicalKey - The canonical channel key whose siblings should be searched.
|
|
495
|
+
* @param submittedUrl - The URL being matched. Empty/undefined returns undefined.
|
|
496
|
+
* @param channels - The stored channels map snapshot for finding user-defined sibling variants.
|
|
497
|
+
* @returns The matching variant key, or undefined when no sibling's URL domain matches.
|
|
498
|
+
*/
|
|
499
|
+
export function inferTargetVariant(canonicalKey, submittedUrl, channels) {
|
|
500
|
+
if ((submittedUrl === undefined) || (submittedUrl === null) || (submittedUrl === "")) {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
const userDomain = extractDomain(submittedUrl);
|
|
504
|
+
// Enumerate sibling variant keys: predefined variants and user-stored variants whose canonicalKey points at this canonical. Excludes the canonical itself.
|
|
505
|
+
// Dedupe via Set since a key may appear in both maps (user override of a predefined variant).
|
|
506
|
+
const siblingKeys = new Set();
|
|
507
|
+
for (const [predefinedKey, predefinedChannel] of Object.entries(PREDEFINED_CHANNELS)) {
|
|
508
|
+
if ((predefinedKey !== canonicalKey) && (predefinedChannel.canonicalKey === canonicalKey)) {
|
|
509
|
+
siblingKeys.add(predefinedKey);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
for (const [storedKey, storedChannel] of Object.entries(channels)) {
|
|
513
|
+
if ((storedKey !== canonicalKey) && (storedChannel.canonicalKey === canonicalKey)) {
|
|
514
|
+
siblingKeys.add(storedKey);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// Sort for deterministic pick when multiple siblings share a domain.
|
|
518
|
+
const sortedKeys = [...siblingKeys].sort();
|
|
519
|
+
for (const siblingKey of sortedKeys) {
|
|
520
|
+
// Effective URL: user override (when string-valued) takes precedence over the predefined variant's URL. A null delta value clears the field, falling back
|
|
521
|
+
// to the predefined; an absent field inherits, also falling back. Both collapse to "use the predefined URL."
|
|
522
|
+
const stored = channels[siblingKey];
|
|
523
|
+
const predefined = PREDEFINED_CHANNELS[siblingKey];
|
|
524
|
+
const storedUrl = stored ? stored.url : undefined;
|
|
525
|
+
const effectiveUrl = (typeof storedUrl === "string") ? storedUrl : predefined?.url;
|
|
526
|
+
if (!effectiveUrl) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (extractDomain(effectiveUrl) === userDomain) {
|
|
530
|
+
return siblingKey;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Detects whether any canonical override in the stored map carries binding whose URL extracts to a sibling variant's domain. Used by initializeUserChannels to
|
|
537
|
+
* conditionally trigger a one-shot startup heal pass via mutateChannels - the normalizer does the actual work, this probe just decides whether the trigger is
|
|
538
|
+
* needed so unaffected users incur no boot-time write.
|
|
539
|
+
*
|
|
540
|
+
* Cheap walk: classify, check stored URL, call inferTargetVariant. Returns on the first match - we only need to know whether at least one entry needs healing.
|
|
541
|
+
* @param channels - The stored channels map snapshot to probe.
|
|
542
|
+
* @returns True when at least one canonical override would be redirected by the normalizer.
|
|
543
|
+
*/
|
|
544
|
+
function hasCanonicalBindingOverlap(channels) {
|
|
545
|
+
for (const [key, stored] of Object.entries(channels)) {
|
|
546
|
+
const classification = classifyEntry(key, stored);
|
|
547
|
+
if (classification.kind !== "canonical") {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const url = stored.url;
|
|
551
|
+
if (typeof url !== "string") {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (inferTargetVariant(key, url, channels)) {
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
381
560
|
/**
|
|
382
561
|
* Normalizes the stored channels map to its minimal delta form. Each entry is classified, diffed against its applicable base, and any no-op fields are
|
|
383
|
-
* stripped. Standalone user channels have no base, so nulls are stripped but everything else is preserved.
|
|
562
|
+
* stripped. Standalone user channels have no base, so nulls are stripped but everything else is preserved. Mutates the data in place so the caller's reference
|
|
563
|
+
* (the file store framework's data envelope) reflects normalized state on return - matching the migration `apply` convention so the same shape is used wherever
|
|
564
|
+
* the framework runs writes.
|
|
384
565
|
*
|
|
385
566
|
* The flow is uniform across kinds:
|
|
386
567
|
*
|
|
387
|
-
* - Canonical (predefined with user override): base is the predefined channel.
|
|
568
|
+
* - Canonical (predefined with user override): base is the predefined channel. Before delta minimization, any binding fields whose URL extracts to a sibling
|
|
569
|
+
* variant's domain are healed by routing them to the matching variant entry and recording a serviceSelections redirect - the same per-field routing the
|
|
570
|
+
* producer (handlePredefinedEdit) performs at write time. This makes the rule "canonical overrides only carry canonical-service binding" structurally
|
|
571
|
+
* self-healing for any data path that misses the producer (legacy data, hand-edited files, future producers).
|
|
388
572
|
* - Variant: base is the resolved canonical layered with the predefined variant's service fields (the view the user sees in the form/table, so the delta
|
|
389
573
|
* records only what differs from that).
|
|
390
574
|
* - Standalone: no base, null values are stripped as a storage convention.
|
|
391
575
|
*
|
|
392
576
|
* Entries whose normalized delta is empty collapse to nothing and get dropped - a redundant override carries no information, and resolution falls through to
|
|
393
577
|
* the default.
|
|
394
|
-
* @param
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
578
|
+
* @param data - The full channels file data envelope. Channels and serviceSelections are both mutated in place.
|
|
579
|
+
*/
|
|
580
|
+
function normalizeChannelDeltas(data) {
|
|
581
|
+
const resolvedCanonicals = buildResolvedCanonicals(data.channels);
|
|
582
|
+
// Snapshot keys before iteration: the canonical-binding heal may write new variant entries to data.channels (heal-created variants when the matched sibling
|
|
583
|
+
// had no prior user override), and we don't want to re-process those in this pass. Heal-created entries are written in already-minimal delta shape, so
|
|
584
|
+
// skipping them is correct.
|
|
585
|
+
const initialKeys = Object.keys(data.channels);
|
|
586
|
+
for (const key of initialKeys) {
|
|
587
|
+
const stored = data.channels[key];
|
|
588
|
+
if (!stored) {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
401
591
|
// Step 1: enforce the expected stored shape via filterToDeltaSurface. Strips orphan fields (non-delta-eligible identity, non-delta-eligible binding,
|
|
402
592
|
// identity-on-variants) before any diff computation. When fields are stripped, log a warning so operators see when their data is being cleaned. The shape
|
|
403
593
|
// rule is the same regardless of classification kind - canonicalKey-discriminated through filterToDeltaSurface internally.
|
|
@@ -409,14 +599,49 @@ function normalizeChannelDeltas(channels) {
|
|
|
409
599
|
// Step 2: classification-driven delta minimization against the appropriate base.
|
|
410
600
|
const classification = classifyEntry(key, shapeFiltered);
|
|
411
601
|
if (classification.kind === "canonical") {
|
|
602
|
+
// Step 2a: canonical-binding overlap heal. When the canonical override carries binding whose URL extracts to a sibling variant's domain, the user's
|
|
603
|
+
// intent is "default this channel to the sibling's service" - architecturally expressed via serviceSelections, not via overriding the canonical URL.
|
|
604
|
+
// Strip the binding fields from the canonical, propagate any binding that diverges from the matching variant's predefined defaults to the variant
|
|
605
|
+
// entry via mergeVariantBinding (preserve-existing semantics: the variant's prior user customization wins on field conflicts), and record the
|
|
606
|
+
// redirect. Distinct from the producer's replaceVariantBinding semantic - the heal is healing legacy data, not recording a new explicit user intent.
|
|
607
|
+
const canonicalDelta = shapeFiltered;
|
|
608
|
+
const targetVariantKey = inferTargetVariant(key, canonicalDelta.url, data.channels);
|
|
609
|
+
if (targetVariantKey) {
|
|
610
|
+
const targetPredefinedVariant = PREDEFINED_CHANNELS[targetVariantKey];
|
|
611
|
+
// For predefined siblings, diff the canonical's binding against the predefined variant's binding so only divergent fields land on the variant entry.
|
|
612
|
+
// For user-only siblings (no predefined entry), the matched variant already has its own binding from when the user created it; the canonical's
|
|
613
|
+
// binding simply represents redundant duplication and is dropped without modifying the variant entry.
|
|
614
|
+
if (targetPredefinedVariant) {
|
|
615
|
+
const bindingOnly = pickBindingFields(canonicalDelta);
|
|
616
|
+
const variantDelta = normalizeEntryAgainstBase(bindingOnly, targetPredefinedVariant);
|
|
617
|
+
if (variantDelta) {
|
|
618
|
+
data.channels[targetVariantKey] = mergeVariantBinding(data.channels[targetVariantKey], variantDelta);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
data.serviceSelections[key] = targetVariantKey;
|
|
622
|
+
LOG.info("Resolved canonical-binding overlap for '%s'; redirected default service to variant '%s'.", key, targetVariantKey);
|
|
623
|
+
// Continue normalization with the binding-stripped canonical delta. If only identity remains and matches the predefined canonical, the entry collapses.
|
|
624
|
+
const identityOnly = pickIdentityFields(canonicalDelta);
|
|
625
|
+
const normalizedIdentity = normalizeEntryAgainstBase(identityOnly, classification.predefined);
|
|
626
|
+
if (normalizedIdentity) {
|
|
627
|
+
data.channels[key] = normalizedIdentity;
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
Reflect.deleteProperty(data.channels, key);
|
|
631
|
+
}
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
412
634
|
const normalized = normalizeEntryAgainstBase(shapeFiltered, classification.predefined);
|
|
413
635
|
if (normalized) {
|
|
414
|
-
|
|
636
|
+
data.channels[key] = normalized;
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
Reflect.deleteProperty(data.channels, key);
|
|
415
640
|
}
|
|
416
641
|
continue;
|
|
417
642
|
}
|
|
418
643
|
if (classification.kind === "standalone") {
|
|
419
|
-
|
|
644
|
+
data.channels[key] = stripNulls(shapeFiltered);
|
|
420
645
|
continue;
|
|
421
646
|
}
|
|
422
647
|
// Variant. The normalization base is identity-from-canonical layered with the predefined variant's binding (if any) - the same shape the user sees in the
|
|
@@ -426,7 +651,7 @@ function normalizeChannelDeltas(channels) {
|
|
|
426
651
|
// the user does not silently lose data.
|
|
427
652
|
const canonical = resolvedCanonicals[classification.canonicalKey];
|
|
428
653
|
if (!canonical) {
|
|
429
|
-
|
|
654
|
+
data.channels[key] = stripNulls(shapeFiltered);
|
|
430
655
|
continue;
|
|
431
656
|
}
|
|
432
657
|
const identityBase = pickIdentity(canonical);
|
|
@@ -435,10 +660,12 @@ function normalizeChannelDeltas(channels) {
|
|
|
435
660
|
const base = classification.predefined ? overlayVariantBinding(identityBase, classification.predefined) : identityBase;
|
|
436
661
|
const normalized = normalizeEntryAgainstBase(shapeFiltered, base);
|
|
437
662
|
if (normalized) {
|
|
438
|
-
|
|
663
|
+
data.channels[key] = normalized;
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
Reflect.deleteProperty(data.channels, key);
|
|
439
667
|
}
|
|
440
668
|
}
|
|
441
|
-
return filtered;
|
|
442
669
|
}
|
|
443
670
|
/**
|
|
444
671
|
* Prepares channels data for writing to disk. Injects metadata from module state (serviceSelections, tagRegistry) into the serializable output. The metadata
|
|
@@ -635,7 +862,8 @@ function detectIdentityFieldLoss(before, after) {
|
|
|
635
862
|
continue;
|
|
636
863
|
}
|
|
637
864
|
// Field went value -> undefined. Check whether the canonical provides the same value, in which case this is variant inheritance / delta minimization
|
|
638
|
-
// and not data loss. Array-valued fields (notably tags) are compared via
|
|
865
|
+
// and not data loss. Array-valued fields (notably tags) are compared structurally via isDeepStrictEqual since reference equality would always fail
|
|
866
|
+
// across freshly-deserialized arrays.
|
|
639
867
|
const canonicalValue = canonical ? canonical[field] : undefined;
|
|
640
868
|
if (canonicalValue !== undefined) {
|
|
641
869
|
const sameValue = (Array.isArray(beforeValue) && Array.isArray(canonicalValue)) ?
|
|
@@ -669,8 +897,9 @@ export async function mutateChannels(fn) {
|
|
|
669
897
|
await channelsStore.mutate((data) => {
|
|
670
898
|
fn(data);
|
|
671
899
|
// Normalize channel deltas before the beforeWrite hook serializes the data. This ensures the in-memory cache and the on-disk representation are
|
|
672
|
-
// identical.
|
|
673
|
-
|
|
900
|
+
// identical. The normalizer mutates data in place (channels and serviceSelections both - the canonical-binding overlap heal touches both), matching the
|
|
901
|
+
// migration `apply` convention so all in-place writes share one shape.
|
|
902
|
+
normalizeChannelDeltas(data);
|
|
674
903
|
writtenData = data;
|
|
675
904
|
});
|
|
676
905
|
// Side effects after successful write. The store framework guarantees writtenData reflects exactly what was persisted.
|
|
@@ -683,6 +912,28 @@ export async function mutateChannels(fn) {
|
|
|
683
912
|
userChannelsParseError = false;
|
|
684
913
|
userChannelsParseErrorMessage = undefined;
|
|
685
914
|
}
|
|
915
|
+
/**
|
|
916
|
+
* Runs the startup channels-file cleanup pass. Combines two boot-time concerns into one atomic write so they share a single disk transaction:
|
|
917
|
+
*
|
|
918
|
+
* - Stale service-selections cleanup: any selection whose variant key no longer exists (deleted predefined variant, removed user channel) is removed by
|
|
919
|
+
* buildServiceGroups in module state; this pass mirrors that cleanup to disk so the file matches the runtime view across restarts.
|
|
920
|
+
* - Canonical-binding overlap heal: invoked indirectly by the post-callback normalizer (normalizeChannelDeltas) which runs against the freshly-read data
|
|
921
|
+
* inside mutateChannels. Any canonical override carrying binding whose URL extracts to a sibling variant's domain is healed in place - binding stripped
|
|
922
|
+
* from the canonical, propagated to the matching variant entry, serviceSelections updated. The normalizer always runs post-callback; the heal happening
|
|
923
|
+
* automatically is the load-bearing behavior, not the explicit body of this function.
|
|
924
|
+
*
|
|
925
|
+
* Existence as a named function is the architectural point: a bare `mutateChannels(() => {})` would correctly trigger the normalizer but obscure the intent
|
|
926
|
+
* at the call site. The name documents what the pass does so callers see "run startup cleanup" rather than "do nothing inside a mutation."
|
|
927
|
+
* @param staleSelections - Service-selection keys to delete from data.serviceSelections. Empty array is allowed (heal-only path).
|
|
928
|
+
* @throws FileStoreParseError if the channels file contains invalid JSON.
|
|
929
|
+
*/
|
|
930
|
+
export async function runStartupChannelsCleanup(staleSelections) {
|
|
931
|
+
await mutateChannels((data) => {
|
|
932
|
+
for (const key of staleSelections) {
|
|
933
|
+
Reflect.deleteProperty(data.serviceSelections, key);
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
}
|
|
686
937
|
/**
|
|
687
938
|
* Deletes a user channel by key.
|
|
688
939
|
* @param key - The channel key to delete.
|
|
@@ -758,7 +1009,7 @@ export async function resetUserChannels() {
|
|
|
758
1009
|
* are user-created standalones that happen to share the hyphenated key shape (e.g., "abc-kabc" as a local affiliate with its own channel number and station
|
|
759
1010
|
* ID) and must be left alone - stamping them would mark them for delta normalization and silently destroy the user's custom identity.
|
|
760
1011
|
*
|
|
761
|
-
* Array-valued identity fields (tags) compare via
|
|
1012
|
+
* Array-valued identity fields (tags) compare via isDeepStrictEqual after sortTags canonicalizes both sides, so historical write order does not defeat the match.
|
|
762
1013
|
* @param channels - The raw stored channel entries.
|
|
763
1014
|
* @returns Keys that should receive a canonicalKey stamp. Empty when nothing needs migrating.
|
|
764
1015
|
*/
|
|
@@ -845,12 +1096,15 @@ export async function initializeUserChannels() {
|
|
|
845
1096
|
// the freshly-read on-disk data; explicitly deleting the stale keys from data.serviceSelections is the data-side equivalent of the module-side cleanup
|
|
846
1097
|
// buildServiceGroups already performed.
|
|
847
1098
|
const staleSelections = buildServiceGroups(mergedChannels);
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1099
|
+
/* Canonical-binding overlap heal at startup. Any canonical override whose binding URL extracts to a sibling variant's domain represents the wholesale-
|
|
1100
|
+
* duplication shape from before the per-field PUT routing landed - the user wanted "default this channel to the sibling's service" but the producer
|
|
1101
|
+
* dumped the full delta onto the canonical instead of routing binding to the variant and recording the redirect via serviceSelections. The normalizer
|
|
1102
|
+
* fixes this on every write; this conditional one-shot pass triggers the heal at boot for users on upgrade so they don't have to perform any manual save
|
|
1103
|
+
* action to clean up their data. Combined with the stale-selections cleanup so a single write handles both startup conditions when both apply.
|
|
1104
|
+
*/
|
|
1105
|
+
const needsOverlapHeal = hasCanonicalBindingOverlap(loadedUserChannels);
|
|
1106
|
+
if ((staleSelections.length > 0) || needsOverlapHeal) {
|
|
1107
|
+
await runStartupChannelsCleanup(staleSelections);
|
|
854
1108
|
}
|
|
855
1109
|
// Now that service groups are built, validate the configured service tags. Strip any unrecognized tags and warn.
|
|
856
1110
|
if (configuredServices.length > 0) {
|
|
@@ -1947,8 +2201,8 @@ export function validateImportedChannels(data, validProfiles) {
|
|
|
1947
2201
|
/**
|
|
1948
2202
|
* Sorts tags case-insensitively using locale-aware comparison. This is the single source of truth for tag ordering. Every write path (parseTagInput, PATCH
|
|
1949
2203
|
* handlers, computePredefinedDelta, transformChannelTags, setTagRegistry) routes through this so stored tag arrays share one canonical ordering. The channels
|
|
1950
|
-
* normalizer is a READER of that invariant - it uses sortTags on both sides when comparing a delta's tags against a predefined's tags, making the
|
|
1951
|
-
*
|
|
2204
|
+
* normalizer is a READER of that invariant - it uses sortTags on both sides when comparing a delta's tags against a predefined's tags, making the
|
|
2205
|
+
* isDeepStrictEqual comparison canonical regardless of how either side was originally populated.
|
|
1952
2206
|
* @param tags - Any iterable of tag strings (array, Set, generator). Not mutated.
|
|
1953
2207
|
* @returns A new array with the same elements in canonical case-insensitive order.
|
|
1954
2208
|
*/
|