prismcast 1.2.1 → 1.3.1
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 +31 -1
- package/dist/browser/channelSelection.js +94 -977
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/index.d.ts +18 -4
- package/dist/browser/index.js +202 -36
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/tuning/fox.d.ts +19 -0
- package/dist/browser/tuning/fox.js +82 -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 +207 -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 +526 -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 +298 -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 +142 -0
- package/dist/browser/tuning/youtubeTv.js.map +1 -0
- package/dist/browser/video.d.ts +2 -2
- package/dist/browser/video.js +19 -9
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.js +55 -9
- package/dist/channels/index.js.map +1 -1
- package/dist/config/profiles.js +11 -0
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providers.d.ts +46 -1
- package/dist/config/providers.js +178 -13
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +2 -0
- package/dist/config/sites.js +43 -14
- 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 +277 -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 +506 -127
- 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 +210 -129
- 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 +120 -49
- 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 +12 -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
|
/**
|
|
@@ -442,6 +492,10 @@ function toDisplayValue(value, setting) {
|
|
|
442
492
|
const precision = setting.displayPrecision ?? ((setting.type === "float") ? 2 : 1);
|
|
443
493
|
return Number(displayValue.toFixed(precision));
|
|
444
494
|
}
|
|
495
|
+
// Boolean values pass through as strings for display.
|
|
496
|
+
if (typeof value === "boolean") {
|
|
497
|
+
return String(value);
|
|
498
|
+
}
|
|
445
499
|
return value;
|
|
446
500
|
}
|
|
447
501
|
/**
|
|
@@ -651,7 +705,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
|
|
|
651
705
|
}
|
|
652
706
|
else {
|
|
653
707
|
// Standard dropdown for non-preset fields.
|
|
654
|
-
for (const validValue of setting.validValues) {
|
|
708
|
+
for (const validValue of setting.validValues ?? []) {
|
|
655
709
|
// For boolean types, compare string validValue with stringified currentValue to handle boolean-to-string comparison.
|
|
656
710
|
const isSelected = (setting.type === "boolean") ?
|
|
657
711
|
(validValue === String(currentValue)) :
|
|
@@ -666,7 +720,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
|
|
|
666
720
|
// 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
721
|
// entirely (which would cause the server to skip it and fall back to the default).
|
|
668
722
|
const isChecked = (currentValue === true) || (currentValue === "true");
|
|
669
|
-
const defaultStr =
|
|
723
|
+
const defaultStr = defaultValue ? "true" : "false";
|
|
670
724
|
lines.push("<input type=\"hidden\" name=\"" + setting.path + "\" value=\"false\">");
|
|
671
725
|
const checkboxAttrs = [
|
|
672
726
|
"class=\"form-checkbox\"",
|
|
@@ -760,7 +814,7 @@ function generateSettingField(setting, currentValue, defaultValue, envOverride,
|
|
|
760
814
|
defaultDisplay = formatValueForDisplay(displayDefault, setting.type);
|
|
761
815
|
}
|
|
762
816
|
else {
|
|
763
|
-
defaultDisplay =
|
|
817
|
+
defaultDisplay = displayDefault;
|
|
764
818
|
}
|
|
765
819
|
// Format the unit with correct pluralization based on the default value.
|
|
766
820
|
let formattedUnit = "";
|
|
@@ -880,6 +934,68 @@ function parseFormValue(setting, value) {
|
|
|
880
934
|
}
|
|
881
935
|
}
|
|
882
936
|
}
|
|
937
|
+
/**
|
|
938
|
+
* Generates the provider filter toolbar HTML with a multi-select dropdown, dismissable chips, and a bulk-assign dropdown.
|
|
939
|
+
* @returns HTML string for the provider filter toolbar.
|
|
940
|
+
*/
|
|
941
|
+
export function generateProviderFilterToolbar() {
|
|
942
|
+
const allTags = getAllProviderTags();
|
|
943
|
+
const enabled = getEnabledProviders();
|
|
944
|
+
const hasFilter = enabled.length > 0;
|
|
945
|
+
const lines = [];
|
|
946
|
+
lines.push("<div class=\"provider-toolbar\">");
|
|
947
|
+
// Left group: Provider filter dropdown and chips.
|
|
948
|
+
lines.push("<div class=\"toolbar-group\">");
|
|
949
|
+
lines.push("<span class=\"toolbar-label\">Providers:</span>");
|
|
950
|
+
lines.push("<div class=\"dropdown provider-dropdown\">");
|
|
951
|
+
const buttonText = hasFilter ? "Filtered" : "All Providers";
|
|
952
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\" onclick=\"toggleDropdown(this)\">" + buttonText + " ▾</button>");
|
|
953
|
+
lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
|
|
954
|
+
for (const tagInfo of allTags) {
|
|
955
|
+
const isDirectTag = tagInfo.tag === "direct";
|
|
956
|
+
const isChecked = isDirectTag || !hasFilter || enabled.includes(tagInfo.tag);
|
|
957
|
+
const checkedAttr = isChecked ? " checked" : "";
|
|
958
|
+
const disabledAttr = isDirectTag ? " disabled" : "";
|
|
959
|
+
lines.push("<label class=\"provider-option\">");
|
|
960
|
+
lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
|
|
961
|
+
" onchange=\"toggleProviderTag(this)\"> " + escapeHtml(tagInfo.displayName));
|
|
962
|
+
lines.push("</label>");
|
|
963
|
+
}
|
|
964
|
+
lines.push("</div>");
|
|
965
|
+
lines.push("</div>");
|
|
966
|
+
// Chips container for active filter tags.
|
|
967
|
+
lines.push("<div class=\"provider-chips\" id=\"provider-chips\">");
|
|
968
|
+
if (hasFilter) {
|
|
969
|
+
for (const tag of enabled) {
|
|
970
|
+
if (tag === "direct") {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const displayName = allTags.find((t) => t.tag === tag)?.displayName ?? tag;
|
|
974
|
+
lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" + escapeHtml(displayName) +
|
|
975
|
+
"<button type=\"button\" class=\"chip-close\" onclick=\"removeProviderChip('" + escapeHtml(tag) + "')\">×</button></span>");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
lines.push("</div>");
|
|
979
|
+
lines.push("</div>");
|
|
980
|
+
// Spacer.
|
|
981
|
+
lines.push("<div class=\"toolbar-spacer\"></div>");
|
|
982
|
+
// Right group: Bulk assign dropdown.
|
|
983
|
+
lines.push("<div class=\"toolbar-group\">");
|
|
984
|
+
lines.push("<span class=\"toolbar-label\">Set all channels to:</span>");
|
|
985
|
+
lines.push("<select class=\"form-select bulk-assign-select\" id=\"bulk-assign\" onchange=\"bulkAssignProvider(this)\">");
|
|
986
|
+
lines.push("<option value=\"\" disabled selected>Choose provider...</option>");
|
|
987
|
+
// Render all provider options so the client-side _allOptions snapshot captures them for reinsertion when the filter changes. Filtered-out options get the hidden
|
|
988
|
+
// attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls updateBulkAssignOptions() to remove them
|
|
989
|
+
// from the DOM.
|
|
990
|
+
for (const tagInfo of allTags) {
|
|
991
|
+
const optionHidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
|
|
992
|
+
lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\"" + optionHidden + ">" + escapeHtml(tagInfo.displayName) + "</option>");
|
|
993
|
+
}
|
|
994
|
+
lines.push("</select>");
|
|
995
|
+
lines.push("</div>");
|
|
996
|
+
lines.push("</div>");
|
|
997
|
+
return lines.join("\n");
|
|
998
|
+
}
|
|
883
999
|
/**
|
|
884
1000
|
* Generates the Channels panel HTML content.
|
|
885
1001
|
* @param channelMessage - Optional message to display (success or error).
|
|
@@ -891,18 +1007,15 @@ function parseFormValue(setting, value) {
|
|
|
891
1007
|
* @returns HTML string for the Channels panel content.
|
|
892
1008
|
*/
|
|
893
1009
|
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();
|
|
1010
|
+
// Get the canonical channel listing (provider variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
|
|
1011
|
+
// it handles predefined/user merging, disabled state, and provider availability.
|
|
1012
|
+
const listing = getChannelListing();
|
|
899
1013
|
const profiles = getProfiles();
|
|
900
1014
|
const disabledPredefined = getDisabledPredefinedChannels();
|
|
901
|
-
const predefinedCount = Object.keys(
|
|
1015
|
+
const predefinedCount = Object.keys(getPredefinedChannels()).length;
|
|
902
1016
|
const allDisabled = disabledPredefined.length === predefinedCount;
|
|
903
|
-
// Count
|
|
904
|
-
|
|
905
|
-
const visibleDisabledCount = disabledPredefined.filter((key) => !isUserChannel(key)).length;
|
|
1017
|
+
// Count channels hidden from the default view: disabled predefined channels OR channels with no available providers.
|
|
1018
|
+
const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByProvider).length;
|
|
906
1019
|
const lines = [];
|
|
907
1020
|
// Panel description.
|
|
908
1021
|
lines.push("<div class=\"settings-panel-description\">");
|
|
@@ -936,7 +1049,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
936
1049
|
else {
|
|
937
1050
|
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" id=\"bulk-toggle-btn\" ", "onclick=\"toggleAllPredefined(false)\">Disable All Predefined</button>");
|
|
938
1051
|
}
|
|
939
|
-
lines.push("<label class=\"toggle-label\"><input type=\"checkbox\" id=\"show-disabled-toggle\" onchange=\"toggleDisabledVisibility()\"> ", "Show disabled (<span id=\"disabled-count\">" + String(
|
|
1052
|
+
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
1053
|
lines.push("</div>");
|
|
941
1054
|
lines.push("</div>");
|
|
942
1055
|
// Show channels file parse error if applicable.
|
|
@@ -1010,6 +1123,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1010
1123
|
lines.push("</div>");
|
|
1011
1124
|
lines.push("</form>");
|
|
1012
1125
|
lines.push("</div>"); // End add-channel-form.
|
|
1126
|
+
// 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.
|
|
1127
|
+
lines.push(generateProviderFilterToolbar());
|
|
1013
1128
|
// Profile reference section (hidden by default, toggled via link in profile dropdown hint).
|
|
1014
1129
|
lines.push(generateProfileReference(profiles));
|
|
1015
1130
|
// 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 +1142,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1027
1142
|
lines.push("</thead>");
|
|
1028
1143
|
lines.push("<tbody>");
|
|
1029
1144
|
// Generate rows for all channels using the shared row generator.
|
|
1030
|
-
for (const
|
|
1031
|
-
const rowHtml = generateChannelRowHtml(key, profiles);
|
|
1145
|
+
for (const entry of listing) {
|
|
1146
|
+
const rowHtml = generateChannelRowHtml(entry.key, profiles);
|
|
1032
1147
|
lines.push(rowHtml.displayRow);
|
|
1033
1148
|
if (rowHtml.editRow) {
|
|
1034
1149
|
lines.push(rowHtml.editRow);
|
|
@@ -1294,10 +1409,9 @@ export function setupConfigEndpoint(app) {
|
|
|
1294
1409
|
LOG.info("Forced restart requested via API.");
|
|
1295
1410
|
res.json({ message: "Server is restarting...", success: true });
|
|
1296
1411
|
// Close the browser first to avoid orphan Chrome processes.
|
|
1297
|
-
setTimeout(
|
|
1412
|
+
setTimeout(() => {
|
|
1298
1413
|
LOG.info("Exiting for forced service manager restart.");
|
|
1299
|
-
|
|
1300
|
-
process.exit(0);
|
|
1414
|
+
void closeBrowser().then(() => { process.exit(0); }).catch(() => { process.exit(1); });
|
|
1301
1415
|
}, 500);
|
|
1302
1416
|
});
|
|
1303
1417
|
// GET /config/channels/export - Export user channels as JSON.
|
|
@@ -1328,7 +1442,7 @@ export function setupConfigEndpoint(app) {
|
|
|
1328
1442
|
await saveUserChannels(validationResult.channels);
|
|
1329
1443
|
const channelCount = Object.keys(validationResult.channels).length;
|
|
1330
1444
|
// 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 });
|
|
1445
|
+
res.json({ message: "Imported " + String(channelCount) + " channel" + (channelCount === 1 ? "" : "s") + " successfully.", success: true });
|
|
1332
1446
|
}
|
|
1333
1447
|
catch (error) {
|
|
1334
1448
|
LOG.error("Failed to import channels: %s", formatError(error));
|
|
@@ -1574,6 +1688,129 @@ export function setupConfigEndpoint(app) {
|
|
|
1574
1688
|
res.status(500).json({ error: "Failed to toggle channels: " + formatError(error), success: false });
|
|
1575
1689
|
}
|
|
1576
1690
|
});
|
|
1691
|
+
// POST /config/provider-filter - Update the provider filter (enabled provider tags).
|
|
1692
|
+
app.post("/config/provider-filter", async (req, res) => {
|
|
1693
|
+
try {
|
|
1694
|
+
const body = req.body;
|
|
1695
|
+
const tags = body.enabledProviders;
|
|
1696
|
+
// Validate tags is an array.
|
|
1697
|
+
if (!Array.isArray(tags)) {
|
|
1698
|
+
res.status(400).json({ error: "enabledProviders must be an array.", success: false });
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
// Validate all tags are known.
|
|
1702
|
+
const knownTags = new Set(getAllProviderTags().map((t) => t.tag));
|
|
1703
|
+
for (const tag of tags) {
|
|
1704
|
+
if (!knownTags.has(tag)) {
|
|
1705
|
+
res.status(400).json({ error: "Unknown provider tag: " + tag, success: false });
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
// Update module-level state.
|
|
1710
|
+
setEnabledProviders(tags);
|
|
1711
|
+
// Update runtime CONFIG.
|
|
1712
|
+
CONFIG.channels.enabledProviders = [...tags];
|
|
1713
|
+
// Save to config file.
|
|
1714
|
+
const configResult = await loadUserConfig();
|
|
1715
|
+
const userConfig = configResult.config;
|
|
1716
|
+
userConfig.channels ??= {};
|
|
1717
|
+
userConfig.channels.enabledProviders = tags;
|
|
1718
|
+
await saveUserConfig(filterDefaults(userConfig));
|
|
1719
|
+
LOG.info("Provider filter updated: %s.", tags.length > 0 ? tags.join(", ") : "all providers");
|
|
1720
|
+
res.json({ enabledProviders: tags, success: true });
|
|
1721
|
+
}
|
|
1722
|
+
catch (error) {
|
|
1723
|
+
LOG.error("Failed to update provider filter: %s", formatError(error));
|
|
1724
|
+
res.status(500).json({ error: "Failed to update provider filter: " + formatError(error), success: false });
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
// POST /config/provider-bulk-assign - Set all channels to a specific provider.
|
|
1728
|
+
app.post("/config/provider-bulk-assign", async (req, res) => {
|
|
1729
|
+
try {
|
|
1730
|
+
const body = req.body;
|
|
1731
|
+
const providerTag = body.provider?.trim();
|
|
1732
|
+
// Validate provider tag.
|
|
1733
|
+
if (!providerTag) {
|
|
1734
|
+
res.status(400).json({ error: "Provider tag is required.", success: false });
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
let affected = 0;
|
|
1738
|
+
const previousSelections = {};
|
|
1739
|
+
const selections = {};
|
|
1740
|
+
// Iterate all channels and set those with a matching variant.
|
|
1741
|
+
const listing = getChannelListing();
|
|
1742
|
+
for (const entry of listing) {
|
|
1743
|
+
const group = getProviderGroup(entry.key);
|
|
1744
|
+
if (!group || (group.variants.length <= 1)) {
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
// Find a variant matching the requested provider tag.
|
|
1748
|
+
const matchingVariant = group.variants.find((v) => (getProviderTagForChannel(v.key) === providerTag));
|
|
1749
|
+
if (matchingVariant) {
|
|
1750
|
+
// Snapshot the current selection before overwriting so the client can offer undo.
|
|
1751
|
+
const currentVariant = getProviderSelection(entry.key);
|
|
1752
|
+
previousSelections[entry.key] = currentVariant ?? null;
|
|
1753
|
+
setProviderSelection(entry.key, matchingVariant.key);
|
|
1754
|
+
affected++;
|
|
1755
|
+
// Collect the resolved profile name for client-side UI update.
|
|
1756
|
+
const resolvedChannel = getResolvedChannel(matchingVariant.key);
|
|
1757
|
+
selections[entry.key] = { profile: resolvedChannel?.profile ?? null, variant: matchingVariant.key };
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
// Save to disk.
|
|
1761
|
+
await saveProviderSelections();
|
|
1762
|
+
LOG.info("Bulk assign to '%s': %d of %d channels affected.", providerTag, affected, listing.length);
|
|
1763
|
+
res.json({ affected, previousSelections, selections, success: true, total: listing.length });
|
|
1764
|
+
}
|
|
1765
|
+
catch (error) {
|
|
1766
|
+
LOG.error("Failed to bulk assign provider: %s", formatError(error));
|
|
1767
|
+
res.status(500).json({ error: "Failed to bulk assign provider: " + formatError(error), success: false });
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
// POST /config/provider-bulk-restore - Restore previous provider selections (undo bulk assign).
|
|
1771
|
+
app.post("/config/provider-bulk-restore", async (req, res) => {
|
|
1772
|
+
try {
|
|
1773
|
+
const body = req.body;
|
|
1774
|
+
const previousSelections = body.selections;
|
|
1775
|
+
if (!previousSelections || (typeof previousSelections !== "object")) {
|
|
1776
|
+
res.status(400).json({ error: "Selections map is required.", success: false });
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
let restored = 0;
|
|
1780
|
+
const selections = {};
|
|
1781
|
+
for (const [key, variantKey] of Object.entries(previousSelections)) {
|
|
1782
|
+
const group = getProviderGroup(key);
|
|
1783
|
+
if (!group) {
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
// A null value means the channel was using the default (canonical) selection. Restoring by setting the selection to the canonical key clears the override.
|
|
1787
|
+
if (variantKey === null) {
|
|
1788
|
+
setProviderSelection(key, key);
|
|
1789
|
+
}
|
|
1790
|
+
else {
|
|
1791
|
+
// Validate the variant belongs to this channel's provider group before restoring.
|
|
1792
|
+
const isValid = group.variants.some((v) => (v.key === variantKey));
|
|
1793
|
+
if (!isValid) {
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
setProviderSelection(key, variantKey);
|
|
1797
|
+
}
|
|
1798
|
+
restored++;
|
|
1799
|
+
// Build the same selection response format as bulk assign for client-side UI updates.
|
|
1800
|
+
const effectiveKey = variantKey ?? key;
|
|
1801
|
+
const resolvedChannel = getResolvedChannel(effectiveKey);
|
|
1802
|
+
selections[key] = { profile: resolvedChannel?.profile ?? null, variant: effectiveKey };
|
|
1803
|
+
}
|
|
1804
|
+
// Save to disk.
|
|
1805
|
+
await saveProviderSelections();
|
|
1806
|
+
LOG.info("Bulk restore: %d channel(s) reverted.", restored);
|
|
1807
|
+
res.json({ restored, selections, success: true });
|
|
1808
|
+
}
|
|
1809
|
+
catch (error) {
|
|
1810
|
+
LOG.error("Failed to bulk restore providers: %s", formatError(error));
|
|
1811
|
+
res.status(500).json({ error: "Failed to bulk restore providers: " + formatError(error), success: false });
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1577
1814
|
// POST /config/channels - Handle channel add, edit, delete operations. Returns JSON response.
|
|
1578
1815
|
app.post("/config/channels", async (req, res) => {
|
|
1579
1816
|
try {
|
|
@@ -1597,7 +1834,7 @@ export function setupConfigEndpoint(app) {
|
|
|
1597
1834
|
res.status(400).json({ message: "Cannot delete channel: channels file contains invalid JSON.", success: false });
|
|
1598
1835
|
return;
|
|
1599
1836
|
}
|
|
1600
|
-
|
|
1837
|
+
Reflect.deleteProperty(result.channels, key);
|
|
1601
1838
|
await saveUserChannels(result.channels);
|
|
1602
1839
|
LOG.info("User channel '%s' deleted.", key);
|
|
1603
1840
|
// 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 +1849,11 @@ export function setupConfigEndpoint(app) {
|
|
|
1612
1849
|
res.status(400).json({ message: "Invalid channel action.", success: false });
|
|
1613
1850
|
return;
|
|
1614
1851
|
}
|
|
1852
|
+
// Key is required for both add and edit actions.
|
|
1853
|
+
if (!key) {
|
|
1854
|
+
res.status(400).json({ message: "Channel key is required.", success: false });
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1615
1857
|
// Validate channel fields.
|
|
1616
1858
|
const formErrors = {};
|
|
1617
1859
|
// Collect form values.
|
|
@@ -1640,7 +1882,7 @@ export function setupConfigEndpoint(app) {
|
|
|
1640
1882
|
}
|
|
1641
1883
|
// Validate key (only for add action, not edit).
|
|
1642
1884
|
if (action === "add") {
|
|
1643
|
-
const keyError = validateChannelKey(key
|
|
1885
|
+
const keyError = validateChannelKey(key, true);
|
|
1644
1886
|
if (keyError) {
|
|
1645
1887
|
formErrors.key = keyError;
|
|
1646
1888
|
}
|