prismcast 1.2.0 → 1.3.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 +26 -1
- package/dist/app.js +29 -2
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +14 -4
- package/dist/browser/channelSelection.js +48 -704
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/index.d.ts +18 -4
- package/dist/browser/index.js +205 -39
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/tuning/fox.d.ts +19 -0
- package/dist/browser/tuning/fox.js +61 -0
- package/dist/browser/tuning/fox.js.map +1 -0
- package/dist/browser/tuning/hbo.d.ts +22 -0
- package/dist/browser/tuning/hbo.js +182 -0
- package/dist/browser/tuning/hbo.js.map +1 -0
- package/dist/browser/tuning/hulu.d.ts +30 -0
- package/dist/browser/tuning/hulu.js +514 -0
- package/dist/browser/tuning/hulu.js.map +1 -0
- package/dist/browser/tuning/sling.d.ts +22 -0
- package/dist/browser/tuning/sling.js +287 -0
- package/dist/browser/tuning/sling.js.map +1 -0
- package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
- package/dist/browser/tuning/thumbnailRow.js +107 -0
- package/dist/browser/tuning/thumbnailRow.js.map +1 -0
- package/dist/browser/tuning/tileClick.d.ts +18 -0
- package/dist/browser/tuning/tileClick.js +102 -0
- package/dist/browser/tuning/tileClick.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.d.ts +17 -0
- package/dist/browser/tuning/youtubeTv.js +99 -0
- package/dist/browser/tuning/youtubeTv.js.map +1 -0
- package/dist/browser/video.d.ts +2 -2
- package/dist/browser/video.js +18 -8
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.js +131 -12
- package/dist/channels/index.js.map +1 -1
- package/dist/config/profiles.d.ts +5 -5
- package/dist/config/profiles.js +35 -11
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providers.d.ts +48 -4
- package/dist/config/providers.js +182 -22
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +10 -0
- package/dist/config/sites.js +96 -17
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.js +21 -3
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -1
- package/dist/config/userConfig.js +12 -4
- package/dist/config/userConfig.js.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/routes/auth.js +6 -0
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/config.d.ts +5 -0
- package/dist/routes/config.js +273 -35
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/debug.d.ts +6 -0
- package/dist/routes/debug.js +274 -0
- package/dist/routes/debug.js.map +1 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +3 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +17 -5
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/root.js +507 -128
- package/dist/routes/root.js.map +1 -1
- package/dist/routes/theme.js +7 -0
- package/dist/routes/theme.js.map +1 -1
- package/dist/service/commands.js +8 -8
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.js +22 -16
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +7 -0
- package/dist/streaming/fmp4Segmenter.js +124 -15
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +74 -6
- package/dist/streaming/hls.js +212 -130
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsSegments.js +11 -4
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +1 -1
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -1
- package/dist/streaming/monitor.js +202 -53
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +29 -1
- package/dist/streaming/mp4Parser.js +243 -18
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +5 -2
- package/dist/streaming/mpegts.js +122 -29
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/registry.d.ts +1 -1
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +13 -1
- package/dist/streaming/setup.js +136 -36
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +9 -9
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.js +5 -6
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/index.d.ts +16 -3
- package/dist/types/index.js +5 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/debugFilter.d.ts +38 -0
- package/dist/utils/debugFilter.js +157 -0
- package/dist/utils/debugFilter.js.map +1 -0
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +5 -0
- package/dist/utils/ffmpeg.js +13 -2
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/fileLogger.d.ts +2 -1
- package/dist/utils/fileLogger.js +4 -2
- package/dist/utils/fileLogger.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/logEmitter.d.ts +1 -0
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/logger.d.ts +9 -8
- package/dist/utils/logger.js +52 -28
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/retry.d.ts +1 -1
- package/dist/utils/retry.js +3 -3
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/version.js +4 -9
- package/dist/utils/version.js.map +1 -1
- package/package.json +6 -6
package/dist/routes/config.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { CONFIG, getDefaults, validatePositiveInt, validatePositiveNumber } from "../config/index.js";
|
|
2
2
|
import { CONFIG_METADATA, filterDefaults, getAdvancedSections, getConfigFilePath, getEnvOverrides, getNestedValue, getSettingsTabSections, getUITabs, isEqualToDefault, loadUserConfig, saveUserConfig, setNestedValue } from "../config/userConfig.js";
|
|
3
3
|
import { LOG, escapeHtml, formatError, generateChannelKey, isRunningAsService, parseM3U } from "../utils/index.js";
|
|
4
|
-
import { getCanonicalKey, getProviderDisplayName, getProviderGroup, getProviderSelection, getResolvedChannel, hasMultipleProviders,
|
|
5
|
-
import { getChannelsParseErrorMessage, getDisabledPredefinedChannels, getPredefinedChannels, getUserChannels, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, loadUserChannels, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../config/userChannels.js";
|
|
4
|
+
import { getAllProviderTags, getCanonicalKey, getChannelProviderTags, getEnabledProviders, getProviderDisplayName, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../config/providers.js";
|
|
5
|
+
import { getChannelListing, getChannelsParseErrorMessage, getDisabledPredefinedChannels, getPredefinedChannels, getUserChannels, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, loadUserChannels, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../config/userChannels.js";
|
|
6
6
|
import { PREDEFINED_CHANNELS } from "../channels/index.js";
|
|
7
7
|
import { closeBrowser } from "../browser/index.js";
|
|
8
8
|
import { getPresetOptionsWithDegradation } from "../config/presets.js";
|
|
@@ -39,10 +39,9 @@ function scheduleServerRestart(reason) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
// No active streams - restart immediately. Close the browser first to avoid orphan Chrome processes.
|
|
42
|
-
setTimeout(
|
|
42
|
+
setTimeout(() => {
|
|
43
43
|
LOG.info("Exiting for service manager restart %s.", reason);
|
|
44
|
-
|
|
45
|
-
process.exit(0);
|
|
44
|
+
void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
|
|
46
45
|
}, 500);
|
|
47
46
|
return {
|
|
48
47
|
activeStreams: 0,
|
|
@@ -226,6 +225,35 @@ function generateProfileReference(profiles) {
|
|
|
226
225
|
lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
|
|
227
226
|
}
|
|
228
227
|
lines.push("</dl>");
|
|
228
|
+
// Per-strategy guidance for finding Channel Selector values. Organized by strategy type since the same strategy can be used across multiple profiles.
|
|
229
|
+
lines.push("<h4 class=\"selector-guide-heading\">Finding Your Channel Selector</h4>");
|
|
230
|
+
lines.push("<p class=\"category-desc\">Predefined channels already have Channel Selector values set. For custom channels, the value depends on the ");
|
|
231
|
+
lines.push("profile's strategy type:</p>");
|
|
232
|
+
lines.push("<dl class=\"profile-list\">");
|
|
233
|
+
lines.push("<dt>apiMultiVideo, keyboardDynamicMultiVideo (image URL)</dt>");
|
|
234
|
+
lines.push("<dd>Right-click the channel's image on the site \u2192 Inspect Element \u2192 find the <img> tag \u2192 copy a unique portion ");
|
|
235
|
+
lines.push("of the <code>src</code> URL that identifies the channel (e.g., \"espn\" from a URL containing \"poster_linear_espn_none\").</dd>");
|
|
236
|
+
lines.push("<dt>foxLive (station code)</dt>");
|
|
237
|
+
lines.push("<dd>Inspect a channel logo in the guide \u2192 find the <code><button></code> inside <code>GuideChannelLogo</code> \u2192 use ");
|
|
238
|
+
lines.push("the <code>title</code> attribute value (e.g., BTN, FOXD2C, FS1, FS2, FWX).</dd>");
|
|
239
|
+
lines.push("<dt>hboMax (channel name)</dt>");
|
|
240
|
+
lines.push("<dd>Inspect a channel tile in the HBO rail \u2192 find the <code><p aria-hidden=\"true\"></code> element \u2192 use the text ");
|
|
241
|
+
lines.push("content (e.g., HBO, HBO Comedy, HBO Drama, HBO Hits, HBO Movies).</dd>");
|
|
242
|
+
lines.push("<dt>huluLive (channel name)</dt>");
|
|
243
|
+
lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with ");
|
|
244
|
+
lines.push("<code>live-guide-channel-kyber-</code> \u2192 use the portion after that prefix. The name may differ from the logo shown ");
|
|
245
|
+
lines.push("(e.g., the full name rather than an abbreviation). For local affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast ");
|
|
246
|
+
lines.push("resolves the local station automatically.</dd>");
|
|
247
|
+
lines.push("<dt>slingLive (channel name)</dt>");
|
|
248
|
+
lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with <code>channel-</code> ");
|
|
249
|
+
lines.push("\u2192 use the portion after that prefix. The name may differ from the logo shown (e.g., \"FOX Sports 1\" not \"FS1\"). For local ");
|
|
250
|
+
lines.push("affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast resolves the local station automatically.</dd>");
|
|
251
|
+
lines.push("<dt>youtubeTV (channel name)</dt>");
|
|
252
|
+
lines.push("<dd>Inspect a channel thumbnail in the guide \u2192 find the <code>aria-label</code> attribute on the ");
|
|
253
|
+
lines.push("<code>ytu-endpoint</code> element \u2192 use the name after \"watch \" (e.g., <code>aria-label=\"watch CNN\"</code> \u2192 CNN). ");
|
|
254
|
+
lines.push("For locals, use the network name (e.g., NBC) \u2014 affiliates like \"NBC 5\" are resolved automatically. PBS resolves to the ");
|
|
255
|
+
lines.push("local affiliate in major markets.</dd>");
|
|
256
|
+
lines.push("</dl>");
|
|
229
257
|
lines.push("</div>");
|
|
230
258
|
}
|
|
231
259
|
lines.push("</div>");
|
|
@@ -253,7 +281,8 @@ function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue,
|
|
|
253
281
|
// Channel selector.
|
|
254
282
|
const channelSelectorHint = showHints ?
|
|
255
283
|
"Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
|
|
256
|
-
"For
|
|
284
|
+
"For guide-based profiles (Fox, HBO Max, Hulu, Sling, YouTube TV), use the channel name or station code from the guide. " +
|
|
285
|
+
"For image-based profiles, right-click a channel image \u2192 Inspect \u2192 copy a unique portion of the image src URL." :
|
|
257
286
|
undefined;
|
|
258
287
|
lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
|
|
259
288
|
// Channel number for HDHomeRun/Plex integration.
|
|
@@ -309,9 +338,12 @@ export function generateChannelRowHtml(key, profiles) {
|
|
|
309
338
|
const isUser = isUserChannel(key);
|
|
310
339
|
const isPredefined = isPredefinedChannel(key);
|
|
311
340
|
const isDisabled = isPredefinedChannelDisabled(key);
|
|
341
|
+
const isAvailableByProvider = isChannelAvailableByProvider(key);
|
|
312
342
|
// Check if this channel has multiple providers.
|
|
313
343
|
const providerGroup = getProviderGroup(key);
|
|
314
|
-
//
|
|
344
|
+
// Build the provider tags data attribute for client-side filtering.
|
|
345
|
+
const providerTags = getChannelProviderTags(key).join(",");
|
|
346
|
+
// Generate display row. User channels get one CSS class, disabled predefined get another, provider-filtered get a third.
|
|
315
347
|
const displayLines = [];
|
|
316
348
|
const rowClasses = [];
|
|
317
349
|
if (isUser) {
|
|
@@ -320,25 +352,39 @@ export function generateChannelRowHtml(key, profiles) {
|
|
|
320
352
|
if (isDisabled) {
|
|
321
353
|
rowClasses.push("channel-disabled");
|
|
322
354
|
}
|
|
355
|
+
if (!isAvailableByProvider) {
|
|
356
|
+
rowClasses.push("channel-unavailable");
|
|
357
|
+
}
|
|
323
358
|
const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
|
|
324
|
-
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + ">");
|
|
359
|
+
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) + "\">");
|
|
325
360
|
displayLines.push("<td><code>" + escapeHtml(key) + "</code></td>");
|
|
326
361
|
displayLines.push("<td>" + escapeHtml(channel.name ?? key) + "</td>");
|
|
327
|
-
// Source column: dropdown for multi-provider channels, static
|
|
362
|
+
// Source column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers" label
|
|
363
|
+
// alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
|
|
328
364
|
displayLines.push("<td>");
|
|
365
|
+
const labelHidden = isAvailableByProvider ? " style=\"display:none\"" : "";
|
|
366
|
+
const contentHidden = isAvailableByProvider ? "" : " style=\"display:none\"";
|
|
367
|
+
displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available providers</em>");
|
|
329
368
|
if (hasMultipleProviders(key) && providerGroup) {
|
|
330
|
-
// Multi-provider:
|
|
369
|
+
// Multi-provider: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the provider selection changes. Filtered-out
|
|
370
|
+
// options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls filterChannelRows()
|
|
371
|
+
// to remove them from the DOM.
|
|
331
372
|
const currentSelection = getProviderSelection(key) ?? key;
|
|
332
|
-
displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) + "\" onchange=\"updateProviderSelection(this)\"
|
|
373
|
+
displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) + "\" onchange=\"updateProviderSelection(this)\"" +
|
|
374
|
+
contentHidden + ">");
|
|
333
375
|
for (const variant of providerGroup.variants) {
|
|
334
376
|
const selected = (variant.key === currentSelection) ? " selected" : "";
|
|
335
|
-
|
|
377
|
+
const tag = getProviderTagForChannel(variant.key);
|
|
378
|
+
const optionHidden = !isProviderTagEnabled(tag) ? " hidden" : "";
|
|
379
|
+
displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(tag) + "\"" + selected + optionHidden + ">" +
|
|
380
|
+
escapeHtml(variant.label) + "</option>");
|
|
336
381
|
}
|
|
337
382
|
displayLines.push("</select>");
|
|
338
383
|
}
|
|
339
384
|
else {
|
|
340
|
-
// Single-provider:
|
|
341
|
-
displayLines.push(
|
|
385
|
+
// Single-provider: wrap the provider name in a span so client-side JS can toggle it with the no-provider label.
|
|
386
|
+
displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
|
|
387
|
+
escapeHtml(channel.provider ?? getProviderDisplayName(channel.url)) + "</span>");
|
|
342
388
|
}
|
|
343
389
|
displayLines.push("</td>");
|
|
344
390
|
displayLines.push("<td>" + (displayChannel.profile ? escapeHtml(displayChannel.profile) : "<em>auto</em>") + "</td>");
|
|
@@ -424,6 +470,10 @@ function formatValueForDisplay(value, settingType) {
|
|
|
424
470
|
}
|
|
425
471
|
return String(value);
|
|
426
472
|
}
|
|
473
|
+
if (typeof value === "string") {
|
|
474
|
+
return value;
|
|
475
|
+
}
|
|
476
|
+
// Config values are always primitives (string, number, boolean). Numbers and strings are handled above.
|
|
427
477
|
return String(value);
|
|
428
478
|
}
|
|
429
479
|
/**
|
|
@@ -651,7 +701,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
|
|
|
651
701
|
}
|
|
652
702
|
else {
|
|
653
703
|
// Standard dropdown for non-preset fields.
|
|
654
|
-
for (const validValue of setting.validValues) {
|
|
704
|
+
for (const validValue of setting.validValues ?? []) {
|
|
655
705
|
// For boolean types, compare string validValue with stringified currentValue to handle boolean-to-string comparison.
|
|
656
706
|
const isSelected = (setting.type === "boolean") ?
|
|
657
707
|
(validValue === String(currentValue)) :
|
|
@@ -666,7 +716,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
|
|
|
666
716
|
// Render boolean as a checkbox. A hidden input with value "false" precedes the checkbox so that unchecking submits "false" rather than omitting the field
|
|
667
717
|
// entirely (which would cause the server to skip it and fall back to the default).
|
|
668
718
|
const isChecked = (currentValue === true) || (currentValue === "true");
|
|
669
|
-
const defaultStr =
|
|
719
|
+
const defaultStr = defaultValue ? "true" : "false";
|
|
670
720
|
lines.push("<input type=\"hidden\" name=\"" + setting.path + "\" value=\"false\">");
|
|
671
721
|
const checkboxAttrs = [
|
|
672
722
|
"class=\"form-checkbox\"",
|
|
@@ -760,7 +810,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
|
|
|
760
810
|
defaultDisplay = formatValueForDisplay(displayDefault, setting.type);
|
|
761
811
|
}
|
|
762
812
|
else {
|
|
763
|
-
defaultDisplay =
|
|
813
|
+
defaultDisplay = displayDefault;
|
|
764
814
|
}
|
|
765
815
|
// Format the unit with correct pluralization based on the default value.
|
|
766
816
|
let formattedUnit = "";
|
|
@@ -880,6 +930,68 @@ function parseFormValue(setting, value) {
|
|
|
880
930
|
}
|
|
881
931
|
}
|
|
882
932
|
}
|
|
933
|
+
/**
|
|
934
|
+
* Generates the provider filter toolbar HTML with a multi-select dropdown, dismissable chips, and a bulk-assign dropdown.
|
|
935
|
+
* @returns HTML string for the provider filter toolbar.
|
|
936
|
+
*/
|
|
937
|
+
export function generateProviderFilterToolbar() {
|
|
938
|
+
const allTags = getAllProviderTags();
|
|
939
|
+
const enabled = getEnabledProviders();
|
|
940
|
+
const hasFilter = enabled.length > 0;
|
|
941
|
+
const lines = [];
|
|
942
|
+
lines.push("<div class=\"provider-toolbar\">");
|
|
943
|
+
// Left group: Provider filter dropdown and chips.
|
|
944
|
+
lines.push("<div class=\"toolbar-group\">");
|
|
945
|
+
lines.push("<span class=\"toolbar-label\">Providers:</span>");
|
|
946
|
+
lines.push("<div class=\"dropdown provider-dropdown\">");
|
|
947
|
+
const buttonText = hasFilter ? "Filtered" : "All Providers";
|
|
948
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\" onclick=\"toggleDropdown(this)\">" + buttonText + " ▾</button>");
|
|
949
|
+
lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
|
|
950
|
+
for (const tagInfo of allTags) {
|
|
951
|
+
const isDirectTag = tagInfo.tag === "direct";
|
|
952
|
+
const isChecked = isDirectTag || !hasFilter || enabled.includes(tagInfo.tag);
|
|
953
|
+
const checkedAttr = isChecked ? " checked" : "";
|
|
954
|
+
const disabledAttr = isDirectTag ? " disabled" : "";
|
|
955
|
+
lines.push("<label class=\"provider-option\">");
|
|
956
|
+
lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
|
|
957
|
+
" onchange=\"toggleProviderTag(this)\"> " + escapeHtml(tagInfo.displayName));
|
|
958
|
+
lines.push("</label>");
|
|
959
|
+
}
|
|
960
|
+
lines.push("</div>");
|
|
961
|
+
lines.push("</div>");
|
|
962
|
+
// Chips container for active filter tags.
|
|
963
|
+
lines.push("<div class=\"provider-chips\" id=\"provider-chips\">");
|
|
964
|
+
if (hasFilter) {
|
|
965
|
+
for (const tag of enabled) {
|
|
966
|
+
if (tag === "direct") {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const displayName = allTags.find((t) => t.tag === tag)?.displayName ?? tag;
|
|
970
|
+
lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" + escapeHtml(displayName) +
|
|
971
|
+
"<button type=\"button\" class=\"chip-close\" onclick=\"removeProviderChip('" + escapeHtml(tag) + "')\">×</button></span>");
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
lines.push("</div>");
|
|
975
|
+
lines.push("</div>");
|
|
976
|
+
// Spacer.
|
|
977
|
+
lines.push("<div class=\"toolbar-spacer\"></div>");
|
|
978
|
+
// Right group: Bulk assign dropdown.
|
|
979
|
+
lines.push("<div class=\"toolbar-group\">");
|
|
980
|
+
lines.push("<span class=\"toolbar-label\">Set all channels to:</span>");
|
|
981
|
+
lines.push("<select class=\"form-select bulk-assign-select\" id=\"bulk-assign\" onchange=\"bulkAssignProvider(this)\">");
|
|
982
|
+
lines.push("<option value=\"\" disabled selected>Choose provider...</option>");
|
|
983
|
+
// Render all provider options so the client-side _allOptions snapshot captures them for reinsertion when the filter changes. Filtered-out options get the hidden
|
|
984
|
+
// attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls updateBulkAssignOptions() to remove them
|
|
985
|
+
// from the DOM.
|
|
986
|
+
for (const tagInfo of allTags) {
|
|
987
|
+
const optionHidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
|
|
988
|
+
lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\"" + optionHidden + ">" + escapeHtml(tagInfo.displayName) + "</option>");
|
|
989
|
+
}
|
|
990
|
+
lines.push("</select>");
|
|
991
|
+
lines.push("</div>");
|
|
992
|
+
lines.push("</div>");
|
|
993
|
+
return lines.join("\n");
|
|
994
|
+
}
|
|
883
995
|
/**
|
|
884
996
|
* Generates the Channels panel HTML content.
|
|
885
997
|
* @param channelMessage - Optional message to display (success or error).
|
|
@@ -891,18 +1003,15 @@ function parseFormValue(setting, value) {
|
|
|
891
1003
|
* @returns HTML string for the Channels panel content.
|
|
892
1004
|
*/
|
|
893
1005
|
export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
|
|
894
|
-
// Get
|
|
895
|
-
|
|
896
|
-
const
|
|
897
|
-
const allChannelKeys = new Set([...Object.keys(predefinedChannels), ...Object.keys(userChannels)]);
|
|
898
|
-
const channelKeys = [...allChannelKeys].filter((key) => !isProviderVariant(key)).sort();
|
|
1006
|
+
// Get the canonical channel listing (provider variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
|
|
1007
|
+
// it handles predefined/user merging, disabled state, and provider availability.
|
|
1008
|
+
const listing = getChannelListing();
|
|
899
1009
|
const profiles = getProfiles();
|
|
900
1010
|
const disabledPredefined = getDisabledPredefinedChannels();
|
|
901
|
-
const predefinedCount = Object.keys(
|
|
1011
|
+
const predefinedCount = Object.keys(getPredefinedChannels()).length;
|
|
902
1012
|
const allDisabled = disabledPredefined.length === predefinedCount;
|
|
903
|
-
// Count
|
|
904
|
-
|
|
905
|
-
const visibleDisabledCount = disabledPredefined.filter((key) => !isUserChannel(key)).length;
|
|
1013
|
+
// Count channels hidden from the default view: disabled predefined channels OR channels with no available providers.
|
|
1014
|
+
const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByProvider).length;
|
|
906
1015
|
const lines = [];
|
|
907
1016
|
// Panel description.
|
|
908
1017
|
lines.push("<div class=\"settings-panel-description\">");
|
|
@@ -936,7 +1045,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
936
1045
|
else {
|
|
937
1046
|
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" id=\"bulk-toggle-btn\" ", "onclick=\"toggleAllPredefined(false)\">Disable All Predefined</button>");
|
|
938
1047
|
}
|
|
939
|
-
lines.push("<label class=\"toggle-label\"><input type=\"checkbox\" id=\"show-disabled-toggle\" onchange=\"toggleDisabledVisibility()\"> ", "Show disabled (<span id=\"disabled-count\">" + String(
|
|
1048
|
+
lines.push("<label class=\"toggle-label\"><input type=\"checkbox\" id=\"show-disabled-toggle\" onchange=\"toggleDisabledVisibility()\"> ", "Show disabled (<span id=\"disabled-count\">" + String(totalHiddenCount) + "</span>)</label>");
|
|
940
1049
|
lines.push("</div>");
|
|
941
1050
|
lines.push("</div>");
|
|
942
1051
|
// Show channels file parse error if applicable.
|
|
@@ -1010,6 +1119,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1010
1119
|
lines.push("</div>");
|
|
1011
1120
|
lines.push("</form>");
|
|
1012
1121
|
lines.push("</div>"); // End add-channel-form.
|
|
1122
|
+
// Provider filter toolbar with multi-select dropdown, chips, and bulk assign. Placed after the add channel form so the form flows directly from its trigger button.
|
|
1123
|
+
lines.push(generateProviderFilterToolbar());
|
|
1013
1124
|
// Profile reference section (hidden by default, toggled via link in profile dropdown hint).
|
|
1014
1125
|
lines.push(generateProfileReference(profiles));
|
|
1015
1126
|
// Channels table. Disabled predefined channels are hidden by default and revealed via the "Show disabled" toggle. The wrapper div enables horizontal scrolling on
|
|
@@ -1027,8 +1138,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1027
1138
|
lines.push("</thead>");
|
|
1028
1139
|
lines.push("<tbody>");
|
|
1029
1140
|
// Generate rows for all channels using the shared row generator.
|
|
1030
|
-
for (const
|
|
1031
|
-
const rowHtml = generateChannelRowHtml(key, profiles);
|
|
1141
|
+
for (const entry of listing) {
|
|
1142
|
+
const rowHtml = generateChannelRowHtml(entry.key, profiles);
|
|
1032
1143
|
lines.push(rowHtml.displayRow);
|
|
1033
1144
|
if (rowHtml.editRow) {
|
|
1034
1145
|
lines.push(rowHtml.editRow);
|
|
@@ -1294,10 +1405,9 @@ export function setupConfigEndpoint(app) {
|
|
|
1294
1405
|
LOG.info("Forced restart requested via API.");
|
|
1295
1406
|
res.json({ message: "Server is restarting...", success: true });
|
|
1296
1407
|
// Close the browser first to avoid orphan Chrome processes.
|
|
1297
|
-
setTimeout(
|
|
1408
|
+
setTimeout(() => {
|
|
1298
1409
|
LOG.info("Exiting for forced service manager restart.");
|
|
1299
|
-
|
|
1300
|
-
process.exit(0);
|
|
1410
|
+
void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
|
|
1301
1411
|
}, 500);
|
|
1302
1412
|
});
|
|
1303
1413
|
// GET /config/channels/export - Export user channels as JSON.
|
|
@@ -1328,7 +1438,7 @@ export function setupConfigEndpoint(app) {
|
|
|
1328
1438
|
await saveUserChannels(validationResult.channels);
|
|
1329
1439
|
const channelCount = Object.keys(validationResult.channels).length;
|
|
1330
1440
|
// Send success response. Changes take effect immediately due to hot-reloading in saveUserChannels().
|
|
1331
|
-
res.json({ message: "Imported " + channelCount + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
|
|
1441
|
+
res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
|
|
1332
1442
|
}
|
|
1333
1443
|
catch (error) {
|
|
1334
1444
|
LOG.error("Failed to import channels: %s", formatError(error));
|
|
@@ -1574,6 +1684,129 @@ export function setupConfigEndpoint(app) {
|
|
|
1574
1684
|
res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
|
|
1575
1685
|
}
|
|
1576
1686
|
});
|
|
1687
|
+
// POST /config/provider-filter - Update the provider filter (enabled provider tags).
|
|
1688
|
+
app.post("/config/provider-filter", async (req, res) => {
|
|
1689
|
+
try {
|
|
1690
|
+
const body = req.body;
|
|
1691
|
+
const tags = body.enabledProviders;
|
|
1692
|
+
// Validate tags is an array.
|
|
1693
|
+
if (!Array.isArray(tags)) {
|
|
1694
|
+
res.status(400).json({ error: "enabledProviders must be an array.", success: false });
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
// Validate all tags are known.
|
|
1698
|
+
const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
|
|
1699
|
+
for (const tag of tags) {
|
|
1700
|
+
if (!knownTags.has(tag)) {
|
|
1701
|
+
res.status(400).json({ error: "Unknown provider tag: " + tag, success: false });
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
// Update module-level state.
|
|
1706
|
+
setEnabledProviders(tags);
|
|
1707
|
+
// Update runtime CONFIG.
|
|
1708
|
+
CONFIG.channels.enabledProviders = [...tags];
|
|
1709
|
+
// Save to config file.
|
|
1710
|
+
const configResult = await loadUserConfig();
|
|
1711
|
+
const userConfig = configResult.config;
|
|
1712
|
+
userConfig.channels ??= {};
|
|
1713
|
+
userConfig.channels.enabledProviders = tags;
|
|
1714
|
+
await saveUserConfig(filterDefaults(userConfig));
|
|
1715
|
+
LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
|
|
1716
|
+
res.json({ enabledProviders: tags, success: true });
|
|
1717
|
+
}
|
|
1718
|
+
catch (error) {
|
|
1719
|
+
LOG.error("Failed to update provider filter: %s", formatError(error));
|
|
1720
|
+
res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
// POST /config/provider-bulk-assign - Set all channels to a specific provider.
|
|
1724
|
+
app.post("/config/provider-bulk-assign", async (req, res) => {
|
|
1725
|
+
try {
|
|
1726
|
+
const body = req.body;
|
|
1727
|
+
const providerTag = body.provider?.trim();
|
|
1728
|
+
// Validate provider tag.
|
|
1729
|
+
if (!providerTag) {
|
|
1730
|
+
res.status(400).json({ error: "Provider tag is required.", success: false });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
let affected = 0;
|
|
1734
|
+
const previousSelections = {};
|
|
1735
|
+
const selections = {};
|
|
1736
|
+
// Iterate all channels and set those with a matching variant.
|
|
1737
|
+
const listing = getChannelListing();
|
|
1738
|
+
for (const entry of listing) {
|
|
1739
|
+
const group = getProviderGroup(entry.key);
|
|
1740
|
+
if (!group || (group.variants.length <= 1)) {
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
// Find a variant matching the requested provider tag.
|
|
1744
|
+
const matchingVariant = group.variants.find((v) => (getProviderTagForChannel(v.key) === providerTag));
|
|
1745
|
+
if (matchingVariant) {
|
|
1746
|
+
// Snapshot the current selection before overwriting so the client can offer undo.
|
|
1747
|
+
const currentVariant = getProviderSelection(entry.key);
|
|
1748
|
+
previousSelections[entry.key] = currentVariant ?? null;
|
|
1749
|
+
setProviderSelection(entry.key, matchingVariant.key);
|
|
1750
|
+
affected++;
|
|
1751
|
+
// Collect the resolved profile name for client-side UI update.
|
|
1752
|
+
const resolvedChannel = getResolvedChannel(matchingVariant.key);
|
|
1753
|
+
selections[entry.key] = { profile: resolvedChannel?.profile ?? null, variant: matchingVariant.key };
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
// Save to disk.
|
|
1757
|
+
await saveProviderSelections();
|
|
1758
|
+
LOG.info("Bulk assign to '%s': %d of %d channels affected.", providerTag, affected, listing.length);
|
|
1759
|
+
res.json({ affected, previousSelections, selections, success: true, total: listing.length });
|
|
1760
|
+
}
|
|
1761
|
+
catch (error) {
|
|
1762
|
+
LOG.error("Failed to bulk assign provider: %s", formatError(error));
|
|
1763
|
+
res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
// POST /config/provider-bulk-restore - Restore previous provider selections (undo bulk assign).
|
|
1767
|
+
app.post("/config/provider-bulk-restore", async (req, res) => {
|
|
1768
|
+
try {
|
|
1769
|
+
const body = req.body;
|
|
1770
|
+
const previousSelections = body.selections;
|
|
1771
|
+
if (!previousSelections || (typeof previousSelections !== "object")) {
|
|
1772
|
+
res.status(400).json({ error: "Selections map is required.", success: false });
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
let restored = 0;
|
|
1776
|
+
const selections = {};
|
|
1777
|
+
for (const [key, variantKey] of Object.entries(previousSelections)) {
|
|
1778
|
+
const group = getProviderGroup(key);
|
|
1779
|
+
if (!group) {
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
// A null value means the channel was using the default (canonical) selection. Restoring by setting the selection to the canonical key clears the override.
|
|
1783
|
+
if (variantKey === null) {
|
|
1784
|
+
setProviderSelection(key, key);
|
|
1785
|
+
}
|
|
1786
|
+
else {
|
|
1787
|
+
// Validate the variant belongs to this channel's provider group before restoring.
|
|
1788
|
+
const isValid = group.variants.some((v) => (v.key === variantKey));
|
|
1789
|
+
if (!isValid) {
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
setProviderSelection(key, variantKey);
|
|
1793
|
+
}
|
|
1794
|
+
restored++;
|
|
1795
|
+
// Build the same selection response format as bulk assign for client-side UI updates.
|
|
1796
|
+
const effectiveKey = variantKey ?? key;
|
|
1797
|
+
const resolvedChannel = getResolvedChannel(effectiveKey);
|
|
1798
|
+
selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
|
|
1799
|
+
}
|
|
1800
|
+
// Save to disk.
|
|
1801
|
+
await saveProviderSelections();
|
|
1802
|
+
LOG.info("Bulk restore: %d channel(s) reverted.", restored);
|
|
1803
|
+
res.json({ restored, selections, success: true });
|
|
1804
|
+
}
|
|
1805
|
+
catch (error) {
|
|
1806
|
+
LOG.error("Failed to bulk restore providers: %s", formatError(error));
|
|
1807
|
+
res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1577
1810
|
// POST /config/channels - Handle channel add, edit, delete operations. Returns JSON response.
|
|
1578
1811
|
app.post("/config/channels", async (req, res) => {
|
|
1579
1812
|
try {
|
|
@@ -1597,7 +1830,7 @@ export function setupConfigEndpoint(app) {
|
|
|
1597
1830
|
res.status(400).json({ message: "Cannot delete channel: channels file contains invalid JSON.", success: false });
|
|
1598
1831
|
return;
|
|
1599
1832
|
}
|
|
1600
|
-
|
|
1833
|
+
Reflect.deleteProperty(result.channels, key);
|
|
1601
1834
|
await saveUserChannels(result.channels);
|
|
1602
1835
|
LOG.info("User channel '%s' deleted.", key);
|
|
1603
1836
|
// If a predefined channel exists with the same key, generate its HTML so the client can replace the user channel row with the predefined version instead of
|
|
@@ -1612,6 +1845,11 @@ export function setupConfigEndpoint(app) {
|
|
|
1612
1845
|
res.status(400).json({ message: "Invalid channel action.", success: false });
|
|
1613
1846
|
return;
|
|
1614
1847
|
}
|
|
1848
|
+
// Key is required for both add and edit actions.
|
|
1849
|
+
if (!key) {
|
|
1850
|
+
res.status(400).json({ message: "Channel key is required.", success: false });
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1615
1853
|
// Validate channel fields.
|
|
1616
1854
|
const formErrors = {};
|
|
1617
1855
|
// Collect form values.
|
|
@@ -1640,7 +1878,7 @@ export function setupConfigEndpoint(app) {
|
|
|
1640
1878
|
}
|
|
1641
1879
|
// Validate key (only for add action, not edit).
|
|
1642
1880
|
if (action === "add") {
|
|
1643
|
-
const keyError = validateChannelKey(key
|
|
1881
|
+
const keyError = validateChannelKey(key, true);
|
|
1644
1882
|
if (keyError) {
|
|
1645
1883
|
formErrors.key = keyError;
|
|
1646
1884
|
}
|