prismcast 1.7.0 → 1.8.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.
Files changed (112) hide show
  1. package/dist/app.js +9 -8
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/login.js +11 -8
  4. package/dist/browser/login.js.map +1 -1
  5. package/dist/channels/index.d.ts +2 -0
  6. package/dist/channels/index.js +208 -22
  7. package/dist/channels/index.js.map +1 -1
  8. package/dist/config/health.js +10 -15
  9. package/dist/config/health.js.map +1 -1
  10. package/dist/config/index.js +9 -0
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/config/profiles.js +10 -10
  13. package/dist/config/profiles.js.map +1 -1
  14. package/dist/config/providers.js +30 -17
  15. package/dist/config/providers.js.map +1 -1
  16. package/dist/config/userChannels.d.ts +62 -9
  17. package/dist/config/userChannels.js +172 -44
  18. package/dist/config/userChannels.js.map +1 -1
  19. package/dist/config/userConfig.d.ts +2 -0
  20. package/dist/config/userConfig.js +29 -4
  21. package/dist/config/userConfig.js.map +1 -1
  22. package/dist/config/userProfiles.js +27 -39
  23. package/dist/config/userProfiles.js.map +1 -1
  24. package/dist/hdhr/discover.js +7 -15
  25. package/dist/hdhr/discover.js.map +1 -1
  26. package/dist/native/index.d.ts +2 -2
  27. package/dist/native/index.js +14 -24
  28. package/dist/native/index.js.map +1 -1
  29. package/dist/native/proxy.d.ts +2 -2
  30. package/dist/native/proxy.js.map +1 -1
  31. package/dist/routes/assets.js +26 -48
  32. package/dist/routes/assets.js.map +1 -1
  33. package/dist/routes/channels.js +5 -11
  34. package/dist/routes/channels.js.map +1 -1
  35. package/dist/routes/components.d.ts +0 -8
  36. package/dist/routes/components.js +0 -19
  37. package/dist/routes/components.js.map +1 -1
  38. package/dist/routes/config/channels/index.d.ts +1 -1
  39. package/dist/routes/config/channels/index.js +1 -1
  40. package/dist/routes/config/channels/index.js.map +1 -1
  41. package/dist/routes/config/channels/routes.js +335 -35
  42. package/dist/routes/config/channels/routes.js.map +1 -1
  43. package/dist/routes/config/channels/table.d.ts +17 -2
  44. package/dist/routes/config/channels/table.js +270 -66
  45. package/dist/routes/config/channels/table.js.map +1 -1
  46. package/dist/routes/config/providers.js +7 -7
  47. package/dist/routes/config/providers.js.map +1 -1
  48. package/dist/routes/config/settings.js +57 -36
  49. package/dist/routes/config/settings.js.map +1 -1
  50. package/dist/routes/icons.d.ts +16 -0
  51. package/dist/routes/icons.js +44 -0
  52. package/dist/routes/icons.js.map +1 -0
  53. package/dist/routes/playlist.d.ts +6 -4
  54. package/dist/routes/playlist.js +68 -27
  55. package/dist/routes/playlist.js.map +1 -1
  56. package/dist/routes/root/content.js +41 -2
  57. package/dist/routes/root/content.js.map +1 -1
  58. package/dist/routes/root/scripts/channels.js +185 -0
  59. package/dist/routes/root/scripts/channels.js.map +1 -1
  60. package/dist/routes/root/scripts/config.js +115 -41
  61. package/dist/routes/root/scripts/config.js.map +1 -1
  62. package/dist/routes/root/scripts/shared.js +69 -23
  63. package/dist/routes/root/scripts/shared.js.map +1 -1
  64. package/dist/routes/root/scripts/status.js +6 -12
  65. package/dist/routes/root/scripts/status.js.map +1 -1
  66. package/dist/routes/root/styles.js +45 -4
  67. package/dist/routes/root/styles.js.map +1 -1
  68. package/dist/routes/ui.d.ts +1 -1
  69. package/dist/routes/ui.js +1 -1
  70. package/dist/routes/ui.js.map +1 -1
  71. package/dist/service/commands.js +3 -35
  72. package/dist/service/commands.js.map +1 -1
  73. package/dist/streaming/codec.d.ts +20 -0
  74. package/dist/streaming/codec.js +68 -0
  75. package/dist/streaming/codec.js.map +1 -0
  76. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  77. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  78. package/dist/streaming/hls.js +11 -6
  79. package/dist/streaming/hls.js.map +1 -1
  80. package/dist/streaming/hlsResume.js +2 -2
  81. package/dist/streaming/hlsResume.js.map +1 -1
  82. package/dist/streaming/preroll.d.ts +15 -14
  83. package/dist/streaming/preroll.js +31 -11
  84. package/dist/streaming/preroll.js.map +1 -1
  85. package/dist/streaming/registry.d.ts +2 -2
  86. package/dist/streaming/registry.js.map +1 -1
  87. package/dist/streaming/setup.d.ts +2 -2
  88. package/dist/streaming/setup.js +5 -12
  89. package/dist/streaming/setup.js.map +1 -1
  90. package/dist/types/channels.d.ts +3 -0
  91. package/dist/types/config.d.ts +1 -0
  92. package/dist/types/index.d.ts +2 -1
  93. package/dist/types/index.js +1 -0
  94. package/dist/types/index.js.map +1 -1
  95. package/dist/types/shared.d.ts +1 -1
  96. package/dist/types/streaming.d.ts +7 -15
  97. package/dist/types/streaming.js +5 -1
  98. package/dist/types/streaming.js.map +1 -1
  99. package/dist/upgrade/commands.js +1 -16
  100. package/dist/upgrade/commands.js.map +1 -1
  101. package/dist/utils/cliOutput.d.ts +10 -0
  102. package/dist/utils/cliOutput.js +17 -0
  103. package/dist/utils/cliOutput.js.map +1 -0
  104. package/dist/utils/format.d.ts +14 -3
  105. package/dist/utils/format.js +24 -6
  106. package/dist/utils/format.js.map +1 -1
  107. package/dist/utils/index.d.ts +1 -0
  108. package/dist/utils/index.js +1 -0
  109. package/dist/utils/index.js.map +1 -1
  110. package/dist/utils/retry.js +2 -4
  111. package/dist/utils/retry.js.map +1 -1
  112. package/package.json +2 -2
@@ -1,10 +1,10 @@
1
- import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
2
- import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState } from "./table.js";
1
+ import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString, stringifySorted } from "../../../utils/index.js";
2
+ import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../../../channels/index.js";
3
+ import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState, generateTagFilterContent, generateTagManagerBody } from "./table.js";
3
4
  import { VALID_SORT_FIELDS, compareChannelSort, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
4
5
  import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.js";
5
- import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
6
+ import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getTagRegistry, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveTagRegistry, saveUserChannels, setTagRegistry, transformChannelTags, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
6
7
  import { CONFIG } from "../../../config/index.js";
7
- import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
8
8
  import { getProfiles } from "../../../config/profiles.js";
9
9
  import { updateChannelLogo } from "../../../streaming/showInfo.js";
10
10
  // Fields that appear in the generated M3U playlist and affect Channels DVR's view of the channel. Used to decide whether the playlist reload hint is shown.
@@ -77,7 +77,7 @@ export function setupChannelRoutes(app) {
77
77
  }
78
78
  res.setHeader("Content-Type", "application/json");
79
79
  res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
80
- res.send(JSON.stringify(resolved, null, 2) + "\n");
80
+ res.send(stringifySorted(resolved) + "\n");
81
81
  }
82
82
  catch (error) {
83
83
  LOG.error("Failed to export channels: %s.", formatError(error));
@@ -246,14 +246,16 @@ export function setupChannelRoutes(app) {
246
246
  let switched = 0;
247
247
  let channelsChanged = false;
248
248
  let selectionsChanged = false;
249
- // Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic.
250
- function buildUserChannel(entry, channelName, channelUrl, selector) {
249
+ // Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic. When
250
+ // canonicalKey is provided, the channel is a provider variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
251
+ // at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
252
+ function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
251
253
  const channel = {
254
+ ...(canonicalKey ? { canonicalKey } : { name: channelName }),
252
255
  channelSelector: selector || undefined,
253
- name: channelName,
254
256
  url: channelUrl
255
257
  };
256
- if (entry.stationId) {
258
+ if (!canonicalKey && entry.stationId) {
257
259
  channel.stationId = sanitizeString(entry.stationId);
258
260
  }
259
261
  return channel;
@@ -277,9 +279,10 @@ export function setupChannelRoutes(app) {
277
279
  await enablePredefinedChannels([canonicalKey]);
278
280
  }
279
281
  const variantKey = canonicalKey + "-" + providerSlug;
280
- // If no variant exists for this provider, create one as a user channel.
282
+ // If no variant exists for this provider, create one as a user channel. The canonicalKey parameter tells buildUserChannel to produce a variant
283
+ // (provider-specific fields only, no identity fields).
281
284
  if (!allKeys.has(variantKey)) {
282
- existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""));
285
+ existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""), canonicalKey);
283
286
  allKeys.add(variantKey);
284
287
  channelsChanged = true;
285
288
  }
@@ -346,12 +349,9 @@ export function setupChannelRoutes(app) {
346
349
  if (allKeys.has(key)) {
347
350
  continue;
348
351
  }
349
- const newChannel = buildUserChannel(entry, name, url, channelSelector);
350
- // When this is a provider variant (key differs from baseKey), store the canonical relationship explicitly so that buildProviderGroups can
351
- // associate the user channel with its canonical via the canonicalKey field.
352
- if (key !== baseKey) {
353
- newChannel.canonicalKey = baseKey;
354
- }
352
+ // When the key differs from baseKey, this is a provider variant — pass baseKey as canonicalKey so buildUserChannel produces a variant with only
353
+ // provider-specific fields. Standalone channels (key === baseKey) get the full channel object with identity fields.
354
+ const newChannel = buildUserChannel(entry, name, url, channelSelector, (key !== baseKey) ? baseKey : undefined);
355
355
  existingChannels[key] = newChannel;
356
356
  allKeys.add(key);
357
357
  channelsChanged = true;
@@ -571,11 +571,10 @@ export function setupChannelRoutes(app) {
571
571
  }
572
572
  const affectedKeys = [];
573
573
  if (clearMode) {
574
- // Clear channel numbers from all visible channels. For predefined channels, use null to signal removal in the delta. For user channels, use undefined.
574
+ // Clear channel numbers from all visible channels. Null signals "clear this field" — the normalizer in saveUserChannels() handles the storage conventions.
575
575
  for (const entry of listing) {
576
576
  const existing = result.channels[entry.key] ?? {};
577
- const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
578
- existing.channelNumber = clearValue;
577
+ existing.channelNumber = null;
579
578
  result.channels[entry.key] = existing;
580
579
  affectedKeys.push(entry.key);
581
580
  }
@@ -630,8 +629,7 @@ export function setupChannelRoutes(app) {
630
629
  continue;
631
630
  }
632
631
  const existing = result.channels[entry.key] ?? {};
633
- const clearValue = isPredefinedChannel(entry.key) ? null : undefined;
634
- existing.hdhrEnabled = enable ? clearValue : false;
632
+ existing.hdhrEnabled = enable ? null : false;
635
633
  result.channels[entry.key] = existing;
636
634
  affectedKeys.push(entry.key);
637
635
  }
@@ -651,6 +649,55 @@ export function setupChannelRoutes(app) {
651
649
  res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
652
650
  }
653
651
  });
652
+ // POST /config/channels/bulk-tags - Add or remove a tag on all enabled, provider-available channels. Operates on the same channel set as other bulk actions.
653
+ // transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
654
+ // modal body.
655
+ app.post("/config/channels/bulk-tags", async (req, res) => {
656
+ try {
657
+ const body = req.body;
658
+ const action = body.action;
659
+ const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
660
+ if ((action !== "add") && (action !== "remove")) {
661
+ res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
662
+ return;
663
+ }
664
+ if (!tag) {
665
+ res.status(400).json({ error: "Tag is required.", success: false });
666
+ return;
667
+ }
668
+ // Validate the tag is in the active vocabulary.
669
+ const vocabulary = new Set(getActiveTagVocabulary());
670
+ if (!vocabulary.has(tag)) {
671
+ res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
672
+ return;
673
+ }
674
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.enabled && entry.availableByProvider, (tags) => (action === "add") ? (tags.includes(tag) ? tags : [...tags, tag]) : tags.filter((t) => t !== tag));
675
+ if (error) {
676
+ res.status(400).json({ error, success: false });
677
+ return;
678
+ }
679
+ if (affectedKeys.length === 0) {
680
+ res.json({ affected: 0, message: "No changes needed.", success: true });
681
+ return;
682
+ }
683
+ const verb = (action === "add") ? "added to" : "removed from";
684
+ LOG.info("Bulk tag %s: %s on %d channels.", action, tag, affectedKeys.length);
685
+ const profiles = getProfiles();
686
+ const message = "Tag '" + tag + "' " + verb + " " + String(affectedKeys.length) + " channel(s).";
687
+ res.json({
688
+ affected: affectedKeys.length,
689
+ filterContent: generateTagFilterContent(),
690
+ message,
691
+ modalBody: generateTagManagerBody(),
692
+ patch: buildChannelTablePatch(affectedKeys, profiles),
693
+ success: true
694
+ });
695
+ }
696
+ catch (error) {
697
+ LOG.error("Failed to bulk-update tags: %s.", formatError(error));
698
+ res.status(500).json({ error: "Failed to update tags: " + formatError(error), success: false });
699
+ }
700
+ });
654
701
  // POST /config/provider-filter - Update the provider filter (enabled provider tags).
655
702
  app.post("/config/provider-filter", async (req, res) => {
656
703
  try {
@@ -890,7 +937,7 @@ export function setupChannelRoutes(app) {
890
937
  }
891
938
  const field = body.field;
892
939
  const value = sanitizeString(body.value ?? "");
893
- if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId")) {
940
+ if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId") && (field !== "tags")) {
894
941
  res.status(400).json({ message: "Invalid inline-edit field.", success: false });
895
942
  return;
896
943
  }
@@ -908,23 +955,28 @@ export function setupChannelRoutes(app) {
908
955
  res.status(400).json({ message: "Cannot edit: channels file contains invalid JSON.", success: false });
909
956
  return;
910
957
  }
911
- // Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet.
958
+ // Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet. Null signals
959
+ // "clear this field" for all channel types — the normalizer in saveUserChannels() handles delta comparison for predefined channels and null-stripping for
960
+ // user channels.
912
961
  const stored = result.channels[key] ?? {};
913
- const clearValue = isPredefinedChannel(key) ? null : undefined;
914
962
  if (field === "channelNumber") {
915
- stored.channelNumber = value ? parseInt(value, 10) : clearValue;
963
+ stored.channelNumber = value ? parseInt(value, 10) : null;
916
964
  }
917
965
  else if (field === "hdhrEnabled") {
918
- // hdhrEnabled: "true" means enabled (clear the override), "false" means disabled. For predefined channels, null clears the delta. For user channels,
919
- // delete the field entirely to revert to the default (included).
920
- stored.hdhrEnabled = (value === "false") ? false : clearValue;
966
+ stored.hdhrEnabled = (value === "false") ? false : null;
967
+ }
968
+ else if (field === "tags") {
969
+ const tags = value ? [...new Set(value.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
970
+ stored.tags = (tags.length > 0) ? tags : null;
921
971
  }
922
972
  else {
923
- stored.stationId = value || clearValue;
973
+ stored.stationId = value || null;
924
974
  }
925
975
  result.channels[key] = stored;
926
976
  await saveUserChannels(result.channels);
927
- const fieldLabels = { channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID" };
977
+ const fieldLabels = {
978
+ channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
979
+ };
928
980
  const fieldLabel = fieldLabels[field] ?? field;
929
981
  const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
930
982
  LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
@@ -977,6 +1029,9 @@ export function setupChannelRoutes(app) {
977
1029
  const channelSelector = sanitizeString(body.channelSelector ?? "");
978
1030
  const channelNumberStr = sanitizeString(body.channelNumber ?? "");
979
1031
  const hdhrEnabled = body.hdhrEnabled !== "false";
1032
+ // Parse tags from comma-separated input. Trim, lowercase, filter empty, sort, deduplicate.
1033
+ const tagsRaw = sanitizeString(body.tags ?? "");
1034
+ const tags = tagsRaw ? [...new Set(tagsRaw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
980
1035
  // Validate channel number if provided.
981
1036
  const channelNumberError = validateChannelNumber(channelNumberStr, key);
982
1037
  if (channelNumberError) {
@@ -1048,14 +1103,19 @@ export function setupChannelRoutes(app) {
1048
1103
  // First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
1049
1104
  // may differ from the canonical predefined base when a variant is selected (e.g., the Hulu variant has a different URL and channelSelector). If the submitted
1050
1105
  // values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
1106
+ // Tags are compared separately (array comparison) after the scalar fields.
1051
1107
  const resolvedKey = resolveProviderKey(key);
1052
1108
  const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
1053
- if (Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field))) {
1109
+ const displayTags = getChannelEffectiveTags(displayChannel);
1110
+ const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
1111
+ const tagsUnchanged = JSON.stringify(tags) === JSON.stringify(displayTags);
1112
+ if (scalarUnchanged && tagsUnchanged) {
1054
1113
  res.json({ key, message: "No changes to save.", success: true });
1055
1114
  return;
1056
1115
  }
1057
1116
  // Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
1058
- // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
1117
+ // revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear). Tags are handled
1118
+ // separately from the scalar loop since they require array comparison.
1059
1119
  const delta = {};
1060
1120
  let hasChanges = false;
1061
1121
  for (const field of Object.keys(formValues)) {
@@ -1065,8 +1125,17 @@ export function setupChannelRoutes(app) {
1065
1125
  hasChanges = true;
1066
1126
  }
1067
1127
  }
1068
- // Helper to check if form values match a given channel's properties.
1069
- const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field));
1128
+ // Tags delta: compare the submitted tags against the predefined base's vocabulary-filtered tags. Using effective tags (not raw) ensures that editing an
1129
+ // unrelated field while a predefined tag is deleted from the vocabulary doesn't spuriously bake the deletion into the channel's stored delta.
1130
+ const predefinedEffectiveTags = getChannelEffectiveTags(predefinedBase);
1131
+ if (JSON.stringify(tags) !== JSON.stringify(predefinedEffectiveTags)) {
1132
+ delta.tags = (tags.length > 0) ? tags : null;
1133
+ hasChanges = true;
1134
+ }
1135
+ // Helper to check if form values (scalars + tags) match a given channel's properties. Tags use vocabulary-filtered comparison so variant matching works
1136
+ // correctly when predefined tags are deleted from the vocabulary.
1137
+ const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
1138
+ (JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
1070
1139
  if (!hasChanges) {
1071
1140
  // The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away — treat it as an
1072
1141
  // implicit revert by removing the override and returning the predefined row HTML.
@@ -1139,6 +1208,9 @@ export function setupChannelRoutes(app) {
1139
1208
  if (channelNumberStr) {
1140
1209
  channel.channelNumber = parseInt(channelNumberStr, 10);
1141
1210
  }
1211
+ if (tags.length > 0) {
1212
+ channel.tags = tags;
1213
+ }
1142
1214
  // Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
1143
1215
  if (!hdhrEnabled) {
1144
1216
  channel.hdhrEnabled = false;
@@ -1173,5 +1245,233 @@ export function setupChannelRoutes(app) {
1173
1245
  res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
1174
1246
  }
1175
1247
  });
1248
+ // Tag Management Endpoints.
1249
+ // GET /config/tags - Returns the tag vocabulary and registry state. The active vocabulary is the computed merge of predefined tags (minus deleted) plus user
1250
+ // tags. The registry contains the raw user state for the tag management UI.
1251
+ app.get("/config/tags", (_req, res) => {
1252
+ res.json({
1253
+ active: getActiveTagVocabulary(),
1254
+ predefined: [...PREDEFINED_TAGS],
1255
+ registry: getTagRegistry(),
1256
+ success: true
1257
+ });
1258
+ });
1259
+ // POST /config/tags - Create a new user tag. Validates the tag name (lowercase alphanumeric + hyphens, max 30 chars) and checks for duplicates against the
1260
+ // active vocabulary and deleted predefined tags (which should be restored, not re-created).
1261
+ app.post("/config/tags", async (req, res) => {
1262
+ try {
1263
+ const body = req.body;
1264
+ const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
1265
+ if (tag.length === 0) {
1266
+ res.status(400).json({ error: "Tag name is required.", success: false });
1267
+ return;
1268
+ }
1269
+ if (tag.length > 30) {
1270
+ res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1271
+ return;
1272
+ }
1273
+ if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(tag)) {
1274
+ res.status(400).json({ error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false });
1275
+ return;
1276
+ }
1277
+ // Check if the tag already exists in the active vocabulary.
1278
+ const vocabulary = new Set(getActiveTagVocabulary());
1279
+ if (vocabulary.has(tag)) {
1280
+ res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
1281
+ return;
1282
+ }
1283
+ // Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
1284
+ const registry = getTagRegistry();
1285
+ if (registry.deletedTags.includes(tag)) {
1286
+ res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
1287
+ return;
1288
+ }
1289
+ // Add the tag to the user registry and persist. No channels are affected (new tag has no assignments yet).
1290
+ registry.tags.push(tag);
1291
+ setTagRegistry(registry);
1292
+ await saveTagRegistry();
1293
+ LOG.info("Created tag: %s.", tag);
1294
+ res.json({
1295
+ active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1296
+ registry: getTagRegistry(), success: true
1297
+ });
1298
+ }
1299
+ catch (error) {
1300
+ LOG.error("Failed to create tag: %s.", formatError(error));
1301
+ res.status(500).json({ error: "Failed to create tag: " + formatError(error), success: false });
1302
+ }
1303
+ });
1304
+ // DELETE /config/tags/:tag - Delete a tag from the vocabulary and cascade to all channel assignments. For predefined tags, the tag is added to deletedTags. For
1305
+ // user-created tags, the tag is removed from the user registry. In both cases, the tag is stripped from every channel that has it — predefined channels via delta
1306
+ // override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
1307
+ app.delete("/config/tags/:tag", async (req, res) => {
1308
+ try {
1309
+ const tag = req.params.tag?.toLowerCase() ?? "";
1310
+ if (tag.length === 0) {
1311
+ res.status(400).json({ error: "Tag name is required.", success: false });
1312
+ return;
1313
+ }
1314
+ const registry = getTagRegistry();
1315
+ const isPredefined = PREDEFINED_TAGS.includes(tag);
1316
+ const isUserTag = registry.tags.includes(tag);
1317
+ if (!isPredefined && !isUserTag) {
1318
+ res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
1319
+ return;
1320
+ }
1321
+ if (isPredefined) {
1322
+ // Already deleted — no-op, return current state without unnecessary I/O.
1323
+ if (registry.deletedTags.includes(tag)) {
1324
+ res.json({
1325
+ active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1326
+ registry: getTagRegistry(), success: true
1327
+ });
1328
+ return;
1329
+ }
1330
+ registry.deletedTags.push(tag);
1331
+ }
1332
+ else {
1333
+ // Remove from user tags.
1334
+ registry.tags = registry.tags.filter((t) => t !== tag);
1335
+ }
1336
+ setTagRegistry(registry);
1337
+ // Cascade: strip the deleted tag from all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
1338
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(tag) === true, (tags) => tags.filter((t) => t !== tag));
1339
+ if (error) {
1340
+ res.status(400).json({ error, success: false });
1341
+ return;
1342
+ }
1343
+ LOG.info("Deleted tag '%s' from vocabulary and %d channel assignments.", tag, affectedKeys.length);
1344
+ const profiles = getProfiles();
1345
+ res.json({
1346
+ active: getActiveTagVocabulary(),
1347
+ filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1348
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1349
+ registry: getTagRegistry(),
1350
+ success: true
1351
+ });
1352
+ }
1353
+ catch (error) {
1354
+ LOG.error("Failed to delete tag: %s.", formatError(error));
1355
+ res.status(500).json({ error: "Failed to delete tag: " + formatError(error), success: false });
1356
+ }
1357
+ });
1358
+ // POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
1359
+ // cascade-restores the tag on predefined channels whose definition includes it. This reverses the cascade delete — predefined channels go back to their default
1360
+ // tag assignments. User channels are not affected (their tag assignments were permanently removed during cascade delete with no source of truth to restore from).
1361
+ app.post("/config/tags/restore", async (req, res) => {
1362
+ try {
1363
+ const body = req.body;
1364
+ const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
1365
+ if (tag.length === 0) {
1366
+ res.status(400).json({ error: "Tag name is required.", success: false });
1367
+ return;
1368
+ }
1369
+ const registry = getTagRegistry();
1370
+ if (!registry.deletedTags.includes(tag)) {
1371
+ res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
1372
+ return;
1373
+ }
1374
+ registry.deletedTags = registry.deletedTags.filter((t) => t !== tag);
1375
+ setTagRegistry(registry);
1376
+ // Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
1377
+ // delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
1378
+ // predefined definition, cleanly reverting the channel to its default state.
1379
+ const { affectedKeys, error } = await transformChannelTags((entry) => {
1380
+ const predefined = getPredefinedChannel(entry.key);
1381
+ return (predefined?.tags?.includes(tag) === true) && (entry.channel.tags?.includes(tag) !== true);
1382
+ }, (tags) => [...tags, tag]);
1383
+ if (error) {
1384
+ res.status(400).json({ error, success: false });
1385
+ return;
1386
+ }
1387
+ LOG.info("Restored predefined tag '%s' on %d channels.", tag, affectedKeys.length);
1388
+ const profiles = getProfiles();
1389
+ res.json({
1390
+ active: getActiveTagVocabulary(),
1391
+ filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
1392
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1393
+ registry: getTagRegistry(),
1394
+ success: true
1395
+ });
1396
+ }
1397
+ catch (error) {
1398
+ LOG.error("Failed to restore tag: %s.", formatError(error));
1399
+ res.status(500).json({ error: "Failed to restore tag: " + formatError(error), success: false });
1400
+ }
1401
+ });
1402
+ // POST /config/tags/rename - Rename a tag atomically across the vocabulary and all channel assignments. For predefined tags, the old name is added to
1403
+ // deletedTags and the new name is added to user tags. For user tags, the old name is replaced with the new name. All channels with the old tag get the
1404
+ // new tag substituted in their stored data (predefined channels via delta, user channels via direct modification).
1405
+ app.post("/config/tags/rename", async (req, res) => {
1406
+ try {
1407
+ const body = req.body;
1408
+ const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim().toLowerCase() : "";
1409
+ const newTag = typeof body.newTag === "string" ? body.newTag.trim().toLowerCase() : "";
1410
+ if (!oldTag || !newTag) {
1411
+ res.status(400).json({ error: "Both old and new tag names are required.", success: false });
1412
+ return;
1413
+ }
1414
+ if (oldTag === newTag) {
1415
+ res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
1416
+ return;
1417
+ }
1418
+ if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(newTag)) {
1419
+ res.status(400).json({
1420
+ error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false
1421
+ });
1422
+ return;
1423
+ }
1424
+ if (newTag.length > 30) {
1425
+ res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
1426
+ return;
1427
+ }
1428
+ // Validate old tag exists in the active vocabulary.
1429
+ const vocabulary = new Set(getActiveTagVocabulary());
1430
+ if (!vocabulary.has(oldTag)) {
1431
+ res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
1432
+ return;
1433
+ }
1434
+ // Validate new tag doesn't already exist.
1435
+ if (vocabulary.has(newTag)) {
1436
+ res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
1437
+ return;
1438
+ }
1439
+ // Update the tag registry: replace old with new in the appropriate list.
1440
+ const registry = getTagRegistry();
1441
+ const oldIsPredefined = PREDEFINED_TAGS.includes(oldTag);
1442
+ if (oldIsPredefined) {
1443
+ // Predefined tag: "delete" the old (add to deletedTags) and create the new as a user tag.
1444
+ if (!registry.deletedTags.includes(oldTag)) {
1445
+ registry.deletedTags.push(oldTag);
1446
+ }
1447
+ registry.tags.push(newTag);
1448
+ }
1449
+ else {
1450
+ // User tag: replace in the user tags list.
1451
+ registry.tags = registry.tags.map((t) => (t === oldTag) ? newTag : t);
1452
+ }
1453
+ setTagRegistry(registry);
1454
+ // Cascade: substitute the old tag with the new tag across all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
1455
+ const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(oldTag) === true, (tags) => tags.map((t) => (t === oldTag) ? newTag : t));
1456
+ if (error) {
1457
+ res.status(400).json({ error, success: false });
1458
+ return;
1459
+ }
1460
+ LOG.info("Renamed tag '%s' to '%s' across %d channels.", oldTag, newTag, affectedKeys.length);
1461
+ const profiles = getProfiles();
1462
+ res.json({
1463
+ active: getActiveTagVocabulary(),
1464
+ filterContent: generateTagFilterContent(),
1465
+ modalBody: generateTagManagerBody(),
1466
+ patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
1467
+ registry: getTagRegistry(),
1468
+ success: true
1469
+ });
1470
+ }
1471
+ catch (error) {
1472
+ LOG.error("Failed to rename tag: %s.", formatError(error));
1473
+ res.status(500).json({ error: "Failed to rename tag: " + formatError(error), success: false });
1474
+ }
1475
+ });
1176
1476
  }
1177
1477
  //# sourceMappingURL=routes.js.map