prismcast 1.4.0 → 1.4.2
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 +37 -8
- package/dist/browser/tuning/hulu.js +114 -62
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/config/providers.d.ts +11 -3
- package/dist/config/providers.js +47 -13
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.js +2 -2
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +29 -19
- package/dist/config/userChannels.js +72 -22
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.js +1 -1
- package/dist/config/userConfig.js.map +1 -1
- package/dist/routes/config.d.ts +8 -4
- package/dist/routes/config.js +316 -114
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/root.js +341 -142
- package/dist/routes/root.js.map +1 -1
- package/dist/routes/theme.js +7 -1
- package/dist/routes/theme.js.map +1 -1
- package/dist/streaming/monitor.js +36 -3
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/showInfo.js +12 -14
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/types/index.d.ts +23 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +2 -2
package/dist/routes/root.js
CHANGED
|
@@ -2,10 +2,8 @@ import { checkForUpdates, escapeHtml, getChangelogItems, getPackageVersion, getV
|
|
|
2
2
|
import { generateAdvancedTabContent, generateChannelsPanel, generateSettingsFormFooter, generateSettingsTabContent, hasEnvOverrides } from "./config.js";
|
|
3
3
|
import { generateBaseStyles, generatePageWrapper, generateTabButton, generateTabPanel, generateTabScript, generateTabStyles } from "./ui.js";
|
|
4
4
|
import { VIDEO_QUALITY_PRESETS } from "../config/presets.js";
|
|
5
|
-
import { getAllChannels } from "../config/userChannels.js";
|
|
6
5
|
import { getUITabs } from "../config/userConfig.js";
|
|
7
6
|
import { resolveBaseUrl } from "./playlist.js";
|
|
8
|
-
import { resolveProfile } from "../config/profiles.js";
|
|
9
7
|
/* The landing page provides operators with all the information they need to integrate with Channels DVR. It features a tabbed interface with six sections:
|
|
10
8
|
*
|
|
11
9
|
* 1. Overview - Introduction to PrismCast and Quick Start instructions
|
|
@@ -24,7 +22,7 @@ function generateHeaderStatusHtml() {
|
|
|
24
22
|
"<div id=\"system-status\" class=\"header-status\">",
|
|
25
23
|
"<span id=\"system-health\"><span class=\"status-dot\" style=\"color: var(--text-muted);\">●</span> Connecting...</span>",
|
|
26
24
|
"<div class=\"dropdown stream-popover\">",
|
|
27
|
-
"<
|
|
25
|
+
"<button type=\"button\" id=\"stream-count\" aria-label=\"Active streams\" onclick=\"toggleStreamPopover()\">-</button>",
|
|
28
26
|
"<div class=\"dropdown-menu\" id=\"stream-popover-menu\"></div>",
|
|
29
27
|
"</div>",
|
|
30
28
|
"</div>"
|
|
@@ -39,7 +37,7 @@ function generateVersionHtml() {
|
|
|
39
37
|
const versionInfo = getVersionInfo(currentVersion);
|
|
40
38
|
// Refresh icon for manual update check (using Unicode refresh symbol).
|
|
41
39
|
const refreshIcon = [
|
|
42
|
-
"<button type=\"button\" class=\"version-check\" onclick=\"checkForUpdates()\" title=\"Check for updates\">",
|
|
40
|
+
"<button type=\"button\" class=\"version-check\" onclick=\"checkForUpdates()\" title=\"Check for updates\" aria-label=\"Check for updates\">",
|
|
43
41
|
"↻",
|
|
44
42
|
"</button>"
|
|
45
43
|
].join("");
|
|
@@ -451,6 +449,34 @@ function generateStatusScript() {
|
|
|
451
449
|
" });",
|
|
452
450
|
" }",
|
|
453
451
|
"};",
|
|
452
|
+
// JS-based tooltips for devices where the primary input can't hover (iPadOS). Safari on iPadOS doesn't show native title tooltips, so we use
|
|
453
|
+
// a single <div> appended to <body> and positioned via getBoundingClientRect(). This is immune to overflow containers and stacking contexts.
|
|
454
|
+
// On pure-touch devices without a trackpad, mouseenter never fires so the tooltip stays hidden. Desktop skips initialization entirely.
|
|
455
|
+
"(function() {",
|
|
456
|
+
" if(!window.matchMedia('(hover: none)').matches) return;",
|
|
457
|
+
" var tip = document.createElement('div');",
|
|
458
|
+
" tip.className = 'btn-icon-tooltip';",
|
|
459
|
+
" document.body.appendChild(tip);",
|
|
460
|
+
" document.addEventListener('mouseenter', function(e) {",
|
|
461
|
+
" var btn = e.target.closest('.btn-icon[aria-label]');",
|
|
462
|
+
" if(!btn) return;",
|
|
463
|
+
" var label = btn.getAttribute('aria-label');",
|
|
464
|
+
" if(!label) return;",
|
|
465
|
+
" var rect = btn.getBoundingClientRect();",
|
|
466
|
+
" tip.textContent = label;",
|
|
467
|
+
" tip.classList.add('visible');",
|
|
468
|
+
" tip.style.top = (rect.bottom + 6) + 'px';",
|
|
469
|
+
" tip.style.left = (rect.left + rect.width / 2) + 'px';",
|
|
470
|
+
" tip.style.transform = 'translateX(-50%)';",
|
|
471
|
+
" }, true);",
|
|
472
|
+
" document.addEventListener('mouseleave', function(e) {",
|
|
473
|
+
" var src = e.target.closest('.btn-icon[aria-label]');",
|
|
474
|
+
" if(!src) return;",
|
|
475
|
+
" var dest = e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.btn-icon[aria-label]');",
|
|
476
|
+
" if(dest === src) return;",
|
|
477
|
+
" tip.classList.remove('visible');",
|
|
478
|
+
" }, true);",
|
|
479
|
+
"})();",
|
|
454
480
|
"</script>"
|
|
455
481
|
].join("\n");
|
|
456
482
|
}
|
|
@@ -458,10 +484,9 @@ function generateStatusScript() {
|
|
|
458
484
|
* Generates the Overview tab content with a comprehensive user guide covering what PrismCast is, video quality expectations, quick start instructions, tuning speed,
|
|
459
485
|
* channel authentication, working with channels, and system requirements.
|
|
460
486
|
* @param baseUrl - The base URL for the server.
|
|
461
|
-
* @param videoChannelCount - The number of video channels available.
|
|
462
487
|
* @returns HTML content for the Overview tab.
|
|
463
488
|
*/
|
|
464
|
-
function generateOverviewContent(baseUrl
|
|
489
|
+
function generateOverviewContent(baseUrl) {
|
|
465
490
|
return [
|
|
466
491
|
// Active streams table at the top.
|
|
467
492
|
generateActiveStreamsSection(),
|
|
@@ -502,7 +527,9 @@ function generateOverviewContent(baseUrl, videoChannelCount) {
|
|
|
502
527
|
"<button class=\"btn-copy-inline\" onclick=\"copyOverviewPlaylistUrl()\" title=\"Copy URL\">Copy</button>",
|
|
503
528
|
"<span id=\"overview-copy-feedback\" class=\"copy-feedback-inline\">Copied!</span></li>",
|
|
504
529
|
"<li>Set <strong>Stream Format</strong> to <strong>HLS</strong>.</li>",
|
|
505
|
-
"<li>
|
|
530
|
+
"<li>Optionally, go to the <a href=\"#channels\">Channels tab</a> and set the <strong>provider filter</strong> to only include streaming services you ",
|
|
531
|
+
"subscribe to. This controls which channels Channels DVR sees in the playlist.</li>",
|
|
532
|
+
"<li>Your configured channels will be imported automatically.</li>",
|
|
506
533
|
"</ol>",
|
|
507
534
|
"<p>Individual channels can also be streamed directly using HLS URLs like <code>" + baseUrl + "/hls/nbc/stream.m3u8</code>.</p>",
|
|
508
535
|
"</div>",
|
|
@@ -564,13 +591,13 @@ function generateOverviewContent(baseUrl, videoChannelCount) {
|
|
|
564
591
|
"enable what you watch and disable the rest. You can also override any predefined channel with your own custom definition ",
|
|
565
592
|
"(see <em>Overriding Predefined Channels</em> below).</p>",
|
|
566
593
|
"<h4>Provider Variants</h4>",
|
|
567
|
-
"<p>Some channels (
|
|
568
|
-
"lets you choose which service to use for that channel. Different providers may offer different tuning performance.</p>",
|
|
594
|
+
"<p>Some channels (Comedy Central, Fox, NBC, etc.) are available from multiple streaming providers. The <strong>provider dropdown</strong> on each ",
|
|
595
|
+
"channel lets you choose which service to use for that channel. Different providers may offer different tuning performance.</p>",
|
|
569
596
|
"<h4>Provider Filter</h4>",
|
|
570
597
|
"<p>If you only subscribe to certain streaming services, use the <strong>provider filter</strong> on the ",
|
|
571
|
-
"<a href=\"#channels\">Channels tab</a> toolbar to show only relevant channels. This filter also
|
|
572
|
-
"
|
|
573
|
-
"on the playlist URL.</p>",
|
|
598
|
+
"<a href=\"#channels\">Channels tab</a> toolbar to show only relevant channels. This filter also controls which channels appear in the playlist ",
|
|
599
|
+
"that Channels DVR imports — set it before adding the playlist source in the <a href=\"#overview\">Quick Start</a>. You can also filter ",
|
|
600
|
+
"programmatically using the <code>?provider=</code> query parameter on the playlist URL.</p>",
|
|
574
601
|
"<h4>Bulk Operations</h4>",
|
|
575
602
|
"<p>The <strong>Set all channels to</strong> dropdown on the <a href=\"#channels\">Channels tab</a> toolbar switches every multi-provider channel ",
|
|
576
603
|
"to a single provider at once. This is useful when you want all channels routed through one streaming service. The operation can be undone by ",
|
|
@@ -701,10 +728,64 @@ function generateApiReferenceContent() {
|
|
|
701
728
|
return [
|
|
702
729
|
"<div class=\"section\">",
|
|
703
730
|
"<p>PrismCast provides a RESTful HTTP API for streaming, management, and diagnostics.</p>",
|
|
731
|
+
"<div class=\"api-index\">",
|
|
732
|
+
"<div class=\"api-index-group\">",
|
|
733
|
+
"<a href=\"#api-streaming\" class=\"api-index-heading\">Streaming</a>",
|
|
734
|
+
"<span class=\"api-index-desc\">HLS and MPEG-TS video streams.</span>",
|
|
735
|
+
"<a href=\"#api-streaming\"><code>GET /hls/:name/stream.m3u8</code></a>",
|
|
736
|
+
"<a href=\"#api-streaming\"><code>GET /stream/:name</code></a>",
|
|
737
|
+
"<a href=\"#api-streaming\"><code>GET /play</code></a>",
|
|
738
|
+
"</div>",
|
|
739
|
+
"<div class=\"api-index-group\">",
|
|
740
|
+
"<a href=\"#api-playlist\" class=\"api-index-heading\">Playlist</a>",
|
|
741
|
+
"<span class=\"api-index-desc\">M3U playlist for Channels DVR.</span>",
|
|
742
|
+
"<a href=\"#api-playlist\"><code>GET /playlist</code></a>",
|
|
743
|
+
"</div>",
|
|
744
|
+
"<div class=\"api-index-group\">",
|
|
745
|
+
"<a href=\"#api-channels\" class=\"api-index-heading\">Channels</a>",
|
|
746
|
+
"<span class=\"api-index-desc\">Add, edit, import, and toggle channel definitions.</span>",
|
|
747
|
+
"<a href=\"#api-channels\"><code>POST /config/channels</code></a>",
|
|
748
|
+
"<a href=\"#api-channels\"><code>GET /config/channels/export</code></a>",
|
|
749
|
+
"<a href=\"#api-channels\"><code>POST /config/channels/import</code></a>",
|
|
750
|
+
"</div>",
|
|
751
|
+
"<div class=\"api-index-group\">",
|
|
752
|
+
"<a href=\"#api-providers\" class=\"api-index-heading\">Providers</a>",
|
|
753
|
+
"<span class=\"api-index-desc\">Provider selection and playlist filtering.</span>",
|
|
754
|
+
"<a href=\"#api-providers\"><code>POST /config/provider</code></a>",
|
|
755
|
+
"<a href=\"#api-providers\"><code>POST /config/provider-filter</code></a>",
|
|
756
|
+
"</div>",
|
|
757
|
+
"<div class=\"api-index-group\">",
|
|
758
|
+
"<a href=\"#api-auth\" class=\"api-index-heading\">Authentication</a>",
|
|
759
|
+
"<span class=\"api-index-desc\">TV provider login sessions.</span>",
|
|
760
|
+
"<a href=\"#api-auth\"><code>POST /auth/login</code></a>",
|
|
761
|
+
"<a href=\"#api-auth\"><code>POST /auth/done</code></a>",
|
|
762
|
+
"</div>",
|
|
763
|
+
"<div class=\"api-index-group\">",
|
|
764
|
+
"<a href=\"#api-management\" class=\"api-index-heading\">Management</a>",
|
|
765
|
+
"<span class=\"api-index-desc\">List channels, view and control active streams.</span>",
|
|
766
|
+
"<a href=\"#api-management\"><code>GET /channels</code></a>",
|
|
767
|
+
"<a href=\"#api-management\"><code>GET /streams</code></a>",
|
|
768
|
+
"<a href=\"#api-management\"><code>DELETE /streams/:id</code></a>",
|
|
769
|
+
"</div>",
|
|
770
|
+
"<div class=\"api-index-group\">",
|
|
771
|
+
"<a href=\"#api-settings\" class=\"api-index-heading\">Settings</a>",
|
|
772
|
+
"<span class=\"api-index-desc\">Save, export, and import server configuration.</span>",
|
|
773
|
+
"<a href=\"#api-settings\"><code>POST /config</code></a>",
|
|
774
|
+
"<a href=\"#api-settings\"><code>GET /config/export</code></a>",
|
|
775
|
+
"<a href=\"#api-settings\"><code>POST /config/import</code></a>",
|
|
776
|
+
"</div>",
|
|
777
|
+
"<div class=\"api-index-group\">",
|
|
778
|
+
"<a href=\"#api-diagnostics\" class=\"api-index-heading\">Diagnostics</a>",
|
|
779
|
+
"<span class=\"api-index-desc\">Health checks, logs, and real-time monitoring.</span>",
|
|
780
|
+
"<a href=\"#api-diagnostics\"><code>GET /health</code></a>",
|
|
781
|
+
"<a href=\"#api-diagnostics\"><code>GET /logs</code></a>",
|
|
782
|
+
"<a href=\"#api-diagnostics\"><code>GET /logs/stream</code></a>",
|
|
783
|
+
"</div>",
|
|
784
|
+
"</div>",
|
|
704
785
|
"</div>",
|
|
705
786
|
// Streaming endpoints.
|
|
706
787
|
"<div class=\"section\">",
|
|
707
|
-
"<h3>Streaming</h3>",
|
|
788
|
+
"<h3 id=\"api-streaming\">Streaming</h3>",
|
|
708
789
|
"<table>",
|
|
709
790
|
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
710
791
|
"<tr>",
|
|
@@ -734,7 +815,7 @@ function generateApiReferenceContent() {
|
|
|
734
815
|
"</div>",
|
|
735
816
|
// Playlist endpoints.
|
|
736
817
|
"<div class=\"section\">",
|
|
737
|
-
"<h3>Playlist</h3>",
|
|
818
|
+
"<h3 id=\"api-playlist\">Playlist</h3>",
|
|
738
819
|
"<table>",
|
|
739
820
|
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
740
821
|
"<tr>",
|
|
@@ -747,32 +828,68 @@ function generateApiReferenceContent() {
|
|
|
747
828
|
"</tr>",
|
|
748
829
|
"</table>",
|
|
749
830
|
"</div>",
|
|
750
|
-
//
|
|
831
|
+
// Channel endpoints.
|
|
751
832
|
"<div class=\"section\">",
|
|
752
|
-
"<h3>
|
|
833
|
+
"<h3 id=\"api-channels\">Channels</h3>",
|
|
834
|
+
"<p>Channel definitions, import/export, and predefined channel management.</p>",
|
|
753
835
|
"<table>",
|
|
754
836
|
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
755
837
|
"<tr>",
|
|
756
|
-
"<td class=\"endpoint\"><
|
|
757
|
-
"<td>
|
|
838
|
+
"<td class=\"endpoint\"><code>POST /config/channels</code></td>",
|
|
839
|
+
"<td>Add, edit, delete, or revert user channels. Body includes <code>action</code> (add/edit/delete/revert) and channel data. " +
|
|
840
|
+
"Revert removes a predefined channel override, restoring defaults.</td>",
|
|
758
841
|
"</tr>",
|
|
759
842
|
"<tr>",
|
|
760
|
-
"<td class=\"endpoint\"><a href=\"/
|
|
761
|
-
"<td>
|
|
843
|
+
"<td class=\"endpoint\"><a href=\"/config/channels/export\"><code>GET /config/channels/export</code></a></td>",
|
|
844
|
+
"<td>Export user-defined channels as a JSON file download.</td>",
|
|
762
845
|
"</tr>",
|
|
763
846
|
"<tr>",
|
|
764
|
-
"<td class=\"endpoint\"><code>
|
|
765
|
-
"<td>
|
|
847
|
+
"<td class=\"endpoint\"><code>POST /config/channels/import</code></td>",
|
|
848
|
+
"<td>Import channels from JSON, replacing all existing user channels.</td>",
|
|
766
849
|
"</tr>",
|
|
767
850
|
"<tr>",
|
|
768
|
-
"<td class=\"endpoint\"><code>
|
|
769
|
-
"<td>
|
|
851
|
+
"<td class=\"endpoint\"><code>POST /config/channels/import-m3u</code></td>",
|
|
852
|
+
"<td>Import channels from M3U playlist. Body: <code>{ \"content\": \"...\", \"conflictMode\": \"skip\" | \"replace\" }</code></td>",
|
|
853
|
+
"</tr>",
|
|
854
|
+
"<tr>",
|
|
855
|
+
"<td class=\"endpoint\"><code>POST /config/channels/toggle-predefined</code></td>",
|
|
856
|
+
"<td>Enable or disable a single predefined channel. Body: <code>{ \"key\": \"nbc\", \"enabled\": true }</code></td>",
|
|
857
|
+
"</tr>",
|
|
858
|
+
"<tr>",
|
|
859
|
+
"<td class=\"endpoint\"><code>POST /config/channels/toggle-all-predefined</code></td>",
|
|
860
|
+
"<td>Enable or disable all predefined channels. Body: <code>{ \"enabled\": true }</code></td>",
|
|
861
|
+
"</tr>",
|
|
862
|
+
"</table>",
|
|
863
|
+
"</div>",
|
|
864
|
+
// Provider endpoints.
|
|
865
|
+
"<div class=\"section\">",
|
|
866
|
+
"<h3 id=\"api-providers\">Providers</h3>",
|
|
867
|
+
"<p>Provider selection and filtering for multi-provider channels.</p>",
|
|
868
|
+
"<table>",
|
|
869
|
+
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
870
|
+
"<tr>",
|
|
871
|
+
"<td class=\"endpoint\"><code>POST /config/provider</code></td>",
|
|
872
|
+
"<td>Update provider selection for a multi-provider channel. Body: <code>{ \"channel\": \"nbc\", \"provider\": \"nbc-hulu\" }</code></td>",
|
|
873
|
+
"</tr>",
|
|
874
|
+
"<tr>",
|
|
875
|
+
"<td class=\"endpoint\"><code>POST /config/provider-filter</code></td>",
|
|
876
|
+
"<td>Set enabled provider tags. Body: <code>{ \"enabledProviders\": [\"hulu\", \"yttv\"] }</code>. Empty array disables filter.</td>",
|
|
877
|
+
"</tr>",
|
|
878
|
+
"<tr>",
|
|
879
|
+
"<td class=\"endpoint\"><code>POST /config/provider-bulk-assign</code></td>",
|
|
880
|
+
"<td>Assign a provider to all multi-provider channels. Body: <code>{ \"provider\": \"hulu\" }</code>. " +
|
|
881
|
+
"Returns <code>{ affected, previousSelections, selections }</code></td>",
|
|
882
|
+
"</tr>",
|
|
883
|
+
"<tr>",
|
|
884
|
+
"<td class=\"endpoint\"><code>POST /config/provider-bulk-restore</code></td>",
|
|
885
|
+
"<td>Restore previous provider selections (undo bulk assign). Body: <code>{ \"selections\": { \"nbc\": \"nbc-hulu\", \"fox\": null } }</code>. " +
|
|
886
|
+
"A <code>null</code> value restores the channel to its default provider.</td>",
|
|
770
887
|
"</tr>",
|
|
771
888
|
"</table>",
|
|
772
889
|
"</div>",
|
|
773
890
|
// Authentication endpoints.
|
|
774
891
|
"<div class=\"section\">",
|
|
775
|
-
"<h3>Authentication</h3>",
|
|
892
|
+
"<h3 id=\"api-auth\">Authentication</h3>",
|
|
776
893
|
"<table>",
|
|
777
894
|
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
778
895
|
"<tr>",
|
|
@@ -789,74 +906,56 @@ function generateApiReferenceContent() {
|
|
|
789
906
|
"</tr>",
|
|
790
907
|
"</table>",
|
|
791
908
|
"</div>",
|
|
792
|
-
//
|
|
909
|
+
// Management endpoints.
|
|
793
910
|
"<div class=\"section\">",
|
|
794
|
-
"<h3>
|
|
911
|
+
"<h3 id=\"api-management\">Management</h3>",
|
|
795
912
|
"<table>",
|
|
796
913
|
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
797
914
|
"<tr>",
|
|
798
|
-
"<td class=\"endpoint\"><code>
|
|
799
|
-
"<td>
|
|
800
|
-
"</tr>",
|
|
801
|
-
"<tr>",
|
|
802
|
-
"<td class=\"endpoint\"><a href=\"/config/export\"><code>GET /config/export</code></a></td>",
|
|
803
|
-
"<td>Export current configuration as a JSON file download.</td>",
|
|
804
|
-
"</tr>",
|
|
805
|
-
"<tr>",
|
|
806
|
-
"<td class=\"endpoint\"><code>POST /config/import</code></td>",
|
|
807
|
-
"<td>Import configuration from JSON. Server restarts to apply changes (if running as service).</td>",
|
|
808
|
-
"</tr>",
|
|
809
|
-
"<tr>",
|
|
810
|
-
"<td class=\"endpoint\"><code>POST /config/restart-now</code></td>",
|
|
811
|
-
"<td>Force immediate server restart regardless of active streams. Only works when running as a service.</td>",
|
|
812
|
-
"</tr>",
|
|
813
|
-
"<tr>",
|
|
814
|
-
"<td class=\"endpoint\"><code>POST /config/channels</code></td>",
|
|
815
|
-
"<td>Add, edit, or delete user channels. Body includes <code>action</code> (add/edit/delete) and channel data.</td>",
|
|
816
|
-
"</tr>",
|
|
817
|
-
"<tr>",
|
|
818
|
-
"<td class=\"endpoint\"><a href=\"/config/channels/export\"><code>GET /config/channels/export</code></a></td>",
|
|
819
|
-
"<td>Export user-defined channels as a JSON file download.</td>",
|
|
820
|
-
"</tr>",
|
|
821
|
-
"<tr>",
|
|
822
|
-
"<td class=\"endpoint\"><code>POST /config/channels/import</code></td>",
|
|
823
|
-
"<td>Import channels from JSON, replacing all existing user channels.</td>",
|
|
915
|
+
"<td class=\"endpoint\"><a href=\"/channels\"><code>GET /channels</code></a></td>",
|
|
916
|
+
"<td>List all channels (predefined + user) as JSON with source, enabled status, and channel metadata.</td>",
|
|
824
917
|
"</tr>",
|
|
825
918
|
"<tr>",
|
|
826
|
-
"<td class=\"endpoint\"><code>
|
|
827
|
-
"<td>
|
|
919
|
+
"<td class=\"endpoint\"><a href=\"/streams\"><code>GET /streams</code></a></td>",
|
|
920
|
+
"<td>List all currently active streams with their ID, channel, URL, duration, and status.</td>",
|
|
828
921
|
"</tr>",
|
|
829
922
|
"<tr>",
|
|
830
|
-
"<td class=\"endpoint\"><code>
|
|
831
|
-
"<td>
|
|
923
|
+
"<td class=\"endpoint\"><code>GET /streams/status</code></td>",
|
|
924
|
+
"<td>Server-Sent Events stream for real-time stream and system status updates.</td>",
|
|
832
925
|
"</tr>",
|
|
833
926
|
"<tr>",
|
|
834
|
-
"<td class=\"endpoint\"><code>
|
|
835
|
-
"<td>
|
|
927
|
+
"<td class=\"endpoint\"><code>DELETE /streams/:id</code></td>",
|
|
928
|
+
"<td>Terminate a specific stream by its numeric ID. Returns 200 on success, 404 if not found.</td>",
|
|
836
929
|
"</tr>",
|
|
930
|
+
"</table>",
|
|
931
|
+
"</div>",
|
|
932
|
+
// Settings endpoints.
|
|
933
|
+
"<div class=\"section\">",
|
|
934
|
+
"<h3 id=\"api-settings\">Settings</h3>",
|
|
935
|
+
"<p>Server configuration and backup.</p>",
|
|
936
|
+
"<table>",
|
|
937
|
+
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
837
938
|
"<tr>",
|
|
838
|
-
"<td class=\"endpoint\"><code>POST /config
|
|
839
|
-
"<td>
|
|
939
|
+
"<td class=\"endpoint\"><code>POST /config</code></td>",
|
|
940
|
+
"<td>Save configuration settings. Returns <code>{ success, message, willRestart, deferred, activeStreams }</code></td>",
|
|
840
941
|
"</tr>",
|
|
841
942
|
"<tr>",
|
|
842
|
-
"<td class=\"endpoint\"><code>
|
|
843
|
-
"<td>
|
|
943
|
+
"<td class=\"endpoint\"><a href=\"/config/export\"><code>GET /config/export</code></a></td>",
|
|
944
|
+
"<td>Export current configuration as a JSON file download.</td>",
|
|
844
945
|
"</tr>",
|
|
845
946
|
"<tr>",
|
|
846
|
-
"<td class=\"endpoint\"><code>POST /config/
|
|
847
|
-
"<td>
|
|
848
|
-
"Returns <code>{ affected, previousSelections, selections }</code></td>",
|
|
947
|
+
"<td class=\"endpoint\"><code>POST /config/import</code></td>",
|
|
948
|
+
"<td>Import configuration from JSON. Server restarts to apply changes (if running as service).</td>",
|
|
849
949
|
"</tr>",
|
|
850
950
|
"<tr>",
|
|
851
|
-
"<td class=\"endpoint\"><code>POST /config/
|
|
852
|
-
"<td>
|
|
853
|
-
"A <code>null</code> value restores the channel to its default provider.</td>",
|
|
951
|
+
"<td class=\"endpoint\"><code>POST /config/restart-now</code></td>",
|
|
952
|
+
"<td>Force immediate server restart regardless of active streams. Only works when running as a service.</td>",
|
|
854
953
|
"</tr>",
|
|
855
954
|
"</table>",
|
|
856
955
|
"</div>",
|
|
857
956
|
// Diagnostics endpoints.
|
|
858
957
|
"<div class=\"section\">",
|
|
859
|
-
"<h3>Diagnostics</h3>",
|
|
958
|
+
"<h3 id=\"api-diagnostics\">Diagnostics</h3>",
|
|
860
959
|
"<table>",
|
|
861
960
|
"<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
|
|
862
961
|
"<tr>",
|
|
@@ -1233,6 +1332,7 @@ function generateConfigSubtabScript() {
|
|
|
1233
1332
|
" closeBtn.type = 'button';",
|
|
1234
1333
|
" closeBtn.className = 'toast-close';",
|
|
1235
1334
|
" closeBtn.textContent = '\\u00d7';",
|
|
1335
|
+
" closeBtn.setAttribute('aria-label', 'Dismiss');",
|
|
1236
1336
|
" closeBtn.onclick = function() { dismissToast(toast); };",
|
|
1237
1337
|
" toast.appendChild(closeBtn);",
|
|
1238
1338
|
" container.appendChild(toast);",
|
|
@@ -1245,6 +1345,8 @@ function generateConfigSubtabScript() {
|
|
|
1245
1345
|
" toast.classList.add('toast-exit');",
|
|
1246
1346
|
" toast.addEventListener('animationend', function() { if (toast.parentNode) toast.parentNode.removeChild(toast); });",
|
|
1247
1347
|
" }",
|
|
1348
|
+
// Hint appended to success toasts when a channel operation changes M3U playlist content that Channels DVR consumes.
|
|
1349
|
+
" var PLAYLIST_HINT = ' Reload the playlist in Channels DVR to see this change.';",
|
|
1248
1350
|
// Queue a toast to appear after the next page reload.
|
|
1249
1351
|
" function showToastAfterReload(message, type) {",
|
|
1250
1352
|
" sessionStorage.setItem('pendingToast', JSON.stringify({ message: message, type: type || 'success' }));",
|
|
@@ -1524,6 +1626,7 @@ function generateConfigSubtabScript() {
|
|
|
1524
1626
|
" btn.type = 'button';",
|
|
1525
1627
|
" btn.className = 'btn-reset';",
|
|
1526
1628
|
" btn.title = 'Reset to default';",
|
|
1629
|
+
" btn.setAttribute('aria-label', 'Reset to default');",
|
|
1527
1630
|
" btn.innerHTML = '↻';",
|
|
1528
1631
|
" btn.onclick = function() { resetSetting(path); };",
|
|
1529
1632
|
" row.appendChild(btn);",
|
|
@@ -1769,7 +1872,7 @@ function generateConfigSubtabScript() {
|
|
|
1769
1872
|
" })",
|
|
1770
1873
|
" .then(function(response) {",
|
|
1771
1874
|
" if (response.ok) {",
|
|
1772
|
-
" showToastAfterReload('Channels imported successfully.', 'success');",
|
|
1875
|
+
" showToastAfterReload('Channels imported successfully.' + PLAYLIST_HINT, 'success');",
|
|
1773
1876
|
" } else {",
|
|
1774
1877
|
" return response.text().then(function(text) { throw new Error(text); });",
|
|
1775
1878
|
" }",
|
|
@@ -1810,7 +1913,7 @@ function generateConfigSubtabScript() {
|
|
|
1810
1913
|
" }",
|
|
1811
1914
|
" if (data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
|
|
1812
1915
|
" }",
|
|
1813
|
-
" if (data.imported > 0 || data.replaced > 0) { showToastAfterReload(msg, 'success'); }",
|
|
1916
|
+
" if (data.imported > 0 || data.replaced > 0) { showToastAfterReload(msg + PLAYLIST_HINT, 'success'); }",
|
|
1814
1917
|
" else { showToast(msg, 'success'); }",
|
|
1815
1918
|
" } else {",
|
|
1816
1919
|
" showToast('M3U import failed: ' + (data.error || 'Unknown error'), 'error');",
|
|
@@ -1988,13 +2091,12 @@ function generateConfigSubtabScript() {
|
|
|
1988
2091
|
" showToast(result.data.message, 'success');",
|
|
1989
2092
|
" if (result.data.html) {",
|
|
1990
2093
|
" insertChannelRow(result.data.html, result.data.key);",
|
|
1991
|
-
"
|
|
1992
|
-
"
|
|
1993
|
-
"
|
|
1994
|
-
"
|
|
1995
|
-
" }",
|
|
2094
|
+
" refilterChannelRows();",
|
|
2095
|
+
" }",
|
|
2096
|
+
" if (action === 'add') {",
|
|
2097
|
+
" hideAddForm();",
|
|
1996
2098
|
" } else {",
|
|
1997
|
-
"
|
|
2099
|
+
" hideEditForm(result.data.key);",
|
|
1998
2100
|
" }",
|
|
1999
2101
|
" } else if (result.data.errors) {",
|
|
2000
2102
|
" var errorMsgs = [];",
|
|
@@ -2021,6 +2123,7 @@ function generateConfigSubtabScript() {
|
|
|
2021
2123
|
" showToast(result.data.message, 'success');",
|
|
2022
2124
|
" if (result.data.html) {",
|
|
2023
2125
|
" insertChannelRow(result.data.html, result.data.key || key);",
|
|
2126
|
+
" refilterChannelRows();",
|
|
2024
2127
|
" } else {",
|
|
2025
2128
|
" removeChannelRow(result.data.key || key);",
|
|
2026
2129
|
" }",
|
|
@@ -2040,7 +2143,7 @@ function generateConfigSubtabScript() {
|
|
|
2040
2143
|
" .then(function(response) { return response.json(); })",
|
|
2041
2144
|
" .then(function(result) {",
|
|
2042
2145
|
" if (result.success) {",
|
|
2043
|
-
" showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
|
|
2146
|
+
" showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
|
|
2044
2147
|
" updateChannelRowDisabledState(key, !enable);",
|
|
2045
2148
|
" } else {",
|
|
2046
2149
|
" showToast(result.error || 'Failed to toggle channel.', 'error');",
|
|
@@ -2048,24 +2151,18 @@ function generateConfigSubtabScript() {
|
|
|
2048
2151
|
" })",
|
|
2049
2152
|
" .catch(function(err) { showToast('Failed to toggle channel: ' + err.message, 'error'); });",
|
|
2050
2153
|
" };",
|
|
2051
|
-
// Update a channel row's provider selection
|
|
2052
|
-
//
|
|
2053
|
-
" function updateChannelProviderUI(channelKey, variant
|
|
2154
|
+
// Update a channel row's provider selection in-place. Syncs the HTML selected attribute so filterChannelRows() restore logic works correctly. We iterate
|
|
2155
|
+
// _allOptions (if present) rather than querySelectorAll because filtered-out options are removed from the DOM but still tracked in the array.
|
|
2156
|
+
" function updateChannelProviderUI(channelKey, variant) {",
|
|
2054
2157
|
" var row = document.getElementById('display-row-' + channelKey);",
|
|
2055
2158
|
" if (!row) return;",
|
|
2056
2159
|
" var sel = row.querySelector('.provider-select');",
|
|
2057
|
-
" if (sel)
|
|
2058
|
-
"
|
|
2059
|
-
"
|
|
2060
|
-
"
|
|
2061
|
-
"
|
|
2062
|
-
"
|
|
2063
|
-
" }",
|
|
2064
|
-
" }",
|
|
2065
|
-
" var profileCell = row.cells[3];",
|
|
2066
|
-
" if (profileCell) {",
|
|
2067
|
-
" if (profile) { profileCell.textContent = profile; }",
|
|
2068
|
-
" else { profileCell.innerHTML = '<em>auto</em>'; }",
|
|
2160
|
+
" if (!sel) return;",
|
|
2161
|
+
" sel.value = variant;",
|
|
2162
|
+
" var allOpts = sel._allOptions || Array.prototype.slice.call(sel.querySelectorAll('option'));",
|
|
2163
|
+
" for (var oi = 0; oi < allOpts.length; oi++) {",
|
|
2164
|
+
" if (allOpts[oi].value === variant) { allOpts[oi].setAttribute('selected', ''); }",
|
|
2165
|
+
" else { allOpts[oi].removeAttribute('selected'); }",
|
|
2069
2166
|
" }",
|
|
2070
2167
|
" }",
|
|
2071
2168
|
// Update provider selection for a multi-provider channel.
|
|
@@ -2081,7 +2178,7 @@ function generateConfigSubtabScript() {
|
|
|
2081
2178
|
" .then(function(result) {",
|
|
2082
2179
|
" if (result.success) {",
|
|
2083
2180
|
" showToast('Provider updated. New streams will use the selected provider.', 'success');",
|
|
2084
|
-
"
|
|
2181
|
+
" if (result.html) { insertChannelRow(result.html, channelKey); refilterChannelRows(); }",
|
|
2085
2182
|
" } else {",
|
|
2086
2183
|
" showToast(result.error || 'Failed to update provider.', 'error');",
|
|
2087
2184
|
" }",
|
|
@@ -2098,7 +2195,7 @@ function generateConfigSubtabScript() {
|
|
|
2098
2195
|
" .then(function(response) { return response.json(); })",
|
|
2099
2196
|
" .then(function(result) {",
|
|
2100
2197
|
" if (result.success) {",
|
|
2101
|
-
" showToast('All predefined channels ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
|
|
2198
|
+
" showToast('All predefined channels ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
|
|
2102
2199
|
" var rows = document.querySelectorAll('tr[id^=\"display-row-\"]:not(.user-channel)');",
|
|
2103
2200
|
" for (var i = 0; i < rows.length; i++) {",
|
|
2104
2201
|
" var rowKey = rows[i].id.replace('display-row-', '');",
|
|
@@ -2112,7 +2209,37 @@ function generateConfigSubtabScript() {
|
|
|
2112
2209
|
" })",
|
|
2113
2210
|
" .catch(function(err) { showToast('Failed to toggle channels: ' + err.message, 'error'); });",
|
|
2114
2211
|
" };",
|
|
2115
|
-
//
|
|
2212
|
+
// SVG icon strings for dynamic DOM manipulation when toggling button states.
|
|
2213
|
+
" var ICON_LOGIN_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
2214
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6.5 2H3.5a1 1 0 00-1 1v10a1 1 0 001 1h3\"/><path d=\"M10.5 11l3-3-3-3\"/>" +
|
|
2215
|
+
"<path d=\"M13.5 8H6.5\"/></svg>';",
|
|
2216
|
+
" var ICON_ENABLE_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
2217
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>';",
|
|
2218
|
+
" var ICON_DISABLE_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
2219
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>';",
|
|
2220
|
+
// Revert a channel override back to predefined defaults.
|
|
2221
|
+
" window.revertChannel = function(key) {",
|
|
2222
|
+
" if (!confirm('Revert channel ' + key + ' to predefined defaults?')) return;",
|
|
2223
|
+
" fetch('/config/channels', {",
|
|
2224
|
+
" method: 'POST',",
|
|
2225
|
+
" headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
|
|
2226
|
+
" body: JSON.stringify({ action: 'revert', key: key })",
|
|
2227
|
+
" })",
|
|
2228
|
+
" .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
|
|
2229
|
+
" .then(function(result) {",
|
|
2230
|
+
" if (result.ok && result.data.success) {",
|
|
2231
|
+
" showToast(result.data.message, 'success');",
|
|
2232
|
+
" if (result.data.html) {",
|
|
2233
|
+
" insertChannelRow(result.data.html, result.data.key || key);",
|
|
2234
|
+
" refilterChannelRows();",
|
|
2235
|
+
" }",
|
|
2236
|
+
" } else {",
|
|
2237
|
+
" showToast(result.data.message || 'Failed to revert channel.', 'error');",
|
|
2238
|
+
" }",
|
|
2239
|
+
" })",
|
|
2240
|
+
" .catch(function(err) { showToast('Failed to revert channel: ' + err.message, 'error'); });",
|
|
2241
|
+
" };",
|
|
2242
|
+
// Set a channel row's disabled state without triggering count updates. Uses icon buttons. Used by toggleAllPredefined for efficient bulk updates.
|
|
2116
2243
|
" function setRowDisabledState(key, disabled) {",
|
|
2117
2244
|
" var row = document.getElementById('display-row-' + key);",
|
|
2118
2245
|
" if (!row) return;",
|
|
@@ -2121,26 +2248,43 @@ function generateConfigSubtabScript() {
|
|
|
2121
2248
|
" if (disabled) {",
|
|
2122
2249
|
" row.classList.add('channel-disabled');",
|
|
2123
2250
|
" row.classList.remove('user-channel');",
|
|
2124
|
-
|
|
2125
|
-
"
|
|
2126
|
-
"
|
|
2251
|
+
// Replace login icon with placeholder.
|
|
2252
|
+
" var loginBtn = btnGroup.querySelector('.btn-icon-login');",
|
|
2253
|
+
" if (loginBtn) {",
|
|
2254
|
+
" var placeholder = document.createElement('span');",
|
|
2255
|
+
" placeholder.className = 'btn-icon-placeholder';",
|
|
2256
|
+
" loginBtn.replaceWith(placeholder);",
|
|
2257
|
+
" }",
|
|
2258
|
+
// Swap disable icon to enable icon.
|
|
2259
|
+
" var disableBtn = btnGroup.querySelector('.btn-icon-disable');",
|
|
2127
2260
|
" if (disableBtn) {",
|
|
2128
|
-
" disableBtn.className = 'btn btn-enable
|
|
2129
|
-
" disableBtn.
|
|
2261
|
+
" disableBtn.className = 'btn-icon btn-icon-enable';",
|
|
2262
|
+
" disableBtn.title = 'Enable';",
|
|
2263
|
+
" disableBtn.setAttribute('aria-label', 'Enable');",
|
|
2264
|
+
" disableBtn.innerHTML = ICON_ENABLE_SVG;",
|
|
2130
2265
|
" disableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', true)\");",
|
|
2131
2266
|
" }",
|
|
2132
2267
|
" } else {",
|
|
2133
2268
|
" row.classList.remove('channel-disabled');",
|
|
2134
|
-
|
|
2269
|
+
// Swap enable icon to disable icon.
|
|
2270
|
+
" var enableBtn = btnGroup.querySelector('.btn-icon-enable');",
|
|
2135
2271
|
" if (enableBtn) {",
|
|
2136
|
-
|
|
2137
|
-
"
|
|
2138
|
-
"
|
|
2139
|
-
"
|
|
2140
|
-
"
|
|
2141
|
-
"
|
|
2142
|
-
"
|
|
2143
|
-
"
|
|
2272
|
+
// Replace placeholder with login icon button.
|
|
2273
|
+
" var placeholder = btnGroup.querySelector('.btn-icon-placeholder');",
|
|
2274
|
+
" if (placeholder) {",
|
|
2275
|
+
" var newLoginBtn = document.createElement('button');",
|
|
2276
|
+
" newLoginBtn.type = 'button';",
|
|
2277
|
+
" newLoginBtn.className = 'btn-icon btn-icon-login';",
|
|
2278
|
+
" newLoginBtn.title = 'Login';",
|
|
2279
|
+
" newLoginBtn.setAttribute('aria-label', 'Login');",
|
|
2280
|
+
" newLoginBtn.innerHTML = ICON_LOGIN_SVG;",
|
|
2281
|
+
" newLoginBtn.setAttribute('onclick', \"startChannelLogin('\" + key + \"')\");",
|
|
2282
|
+
" placeholder.replaceWith(newLoginBtn);",
|
|
2283
|
+
" }",
|
|
2284
|
+
" enableBtn.className = 'btn-icon btn-icon-disable';",
|
|
2285
|
+
" enableBtn.title = 'Disable';",
|
|
2286
|
+
" enableBtn.setAttribute('aria-label', 'Disable');",
|
|
2287
|
+
" enableBtn.innerHTML = ICON_DISABLE_SVG;",
|
|
2144
2288
|
" enableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', false)\");",
|
|
2145
2289
|
" }",
|
|
2146
2290
|
" }",
|
|
@@ -2204,6 +2348,7 @@ function generateConfigSubtabScript() {
|
|
|
2204
2348
|
" updateBulkAssignOptions(enabledTags);",
|
|
2205
2349
|
" updateProviderFilterButton(enabledTags);",
|
|
2206
2350
|
" updateDisabledCount();",
|
|
2351
|
+
" showToast('Provider filter updated.' + PLAYLIST_HINT, 'success');",
|
|
2207
2352
|
" }",
|
|
2208
2353
|
" })",
|
|
2209
2354
|
" .catch(function(err) { showToast('Failed to update filter: ' + err.message, 'error'); });",
|
|
@@ -2239,7 +2384,8 @@ function generateConfigSubtabScript() {
|
|
|
2239
2384
|
" var chip = document.createElement('span');",
|
|
2240
2385
|
" chip.className = 'provider-chip';",
|
|
2241
2386
|
" chip.setAttribute('data-tag', tag);",
|
|
2242
|
-
" chip.innerHTML = label + '<button type=\"button\" class=\"chip-close\"
|
|
2387
|
+
" chip.innerHTML = label + '<button type=\"button\" class=\"chip-close\" aria-label=\"Remove ' + label +",
|
|
2388
|
+
" '\" onclick=\"removeProviderChip(\\'' + tag + '\\')\">\\u00d7</button>';",
|
|
2243
2389
|
" container.appendChild(chip);",
|
|
2244
2390
|
" }",
|
|
2245
2391
|
" };",
|
|
@@ -2290,6 +2436,20 @@ function generateConfigSubtabScript() {
|
|
|
2290
2436
|
" }",
|
|
2291
2437
|
" }",
|
|
2292
2438
|
" };",
|
|
2439
|
+
// Re-run the provider filter on all channel rows using the current checkbox state. Called after insertChannelRow replaces a row (which loses the filter state).
|
|
2440
|
+
" function refilterChannelRows() {",
|
|
2441
|
+
" var menu = document.querySelector('.provider-dropdown-menu');",
|
|
2442
|
+
" if (!menu) return;",
|
|
2443
|
+
" var cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
|
|
2444
|
+
" var enabledTags = [];",
|
|
2445
|
+
" var allChecked = true;",
|
|
2446
|
+
" for (var i = 0; i < cbs.length; i++) {",
|
|
2447
|
+
" if (cbs[i].checked) { enabledTags.push(cbs[i].getAttribute('data-tag')); }",
|
|
2448
|
+
" else { allChecked = false; }",
|
|
2449
|
+
" }",
|
|
2450
|
+
" if (allChecked) { enabledTags = []; }",
|
|
2451
|
+
" if (enabledTags.length > 0) { filterChannelRows(enabledTags); }",
|
|
2452
|
+
" }",
|
|
2293
2453
|
// Update bulk assign dropdown to only show enabled providers. Uses DOM removal like filterChannelRows because Safari ignores hidden/display:none on option
|
|
2294
2454
|
// elements. The snapshot filters by truthy .value to exclude the "Choose provider..." placeholder (value="") so it is never removed from the DOM.
|
|
2295
2455
|
" function updateBulkAssignOptions(enabledTags) {",
|
|
@@ -2335,7 +2495,7 @@ function generateConfigSubtabScript() {
|
|
|
2335
2495
|
" if (result.selections) {",
|
|
2336
2496
|
" for (var key in result.selections) {",
|
|
2337
2497
|
" var sel = result.selections[key];",
|
|
2338
|
-
" updateChannelProviderUI(key, sel.variant
|
|
2498
|
+
" updateChannelProviderUI(key, sel.variant);",
|
|
2339
2499
|
" }",
|
|
2340
2500
|
" }",
|
|
2341
2501
|
" } else {",
|
|
@@ -2358,7 +2518,7 @@ function generateConfigSubtabScript() {
|
|
|
2358
2518
|
" if (result.selections) {",
|
|
2359
2519
|
" for (var key in result.selections) {",
|
|
2360
2520
|
" var sel = result.selections[key];",
|
|
2361
|
-
" updateChannelProviderUI(key, sel.variant
|
|
2521
|
+
" updateChannelProviderUI(key, sel.variant);",
|
|
2362
2522
|
" }",
|
|
2363
2523
|
" }",
|
|
2364
2524
|
" } else {",
|
|
@@ -2385,6 +2545,26 @@ function generateConfigSubtabScript() {
|
|
|
2385
2545
|
" document.addEventListener('click', function(e) {",
|
|
2386
2546
|
" if (!e.target.closest('.dropdown')) closeDropdowns();",
|
|
2387
2547
|
" });",
|
|
2548
|
+
// Copy a stream URL to the clipboard and show a toast notification. The type parameter selects HLS or MPEG-TS format. Uses the modern Clipboard API when
|
|
2549
|
+
// available (secure contexts), falling back to execCommand for plain HTTP access via IP address.
|
|
2550
|
+
" window.copyStreamUrl = function(type, key) {",
|
|
2551
|
+
" closeDropdowns();",
|
|
2552
|
+
" var url = (type === 'hls') ? (location.origin + '/hls/' + key + '/stream.m3u8') : (location.origin + '/stream/' + key);",
|
|
2553
|
+
" if (navigator.clipboard && navigator.clipboard.writeText) {",
|
|
2554
|
+
" navigator.clipboard.writeText(url).then(function() { showToast('Stream URL copied to clipboard.', 'success'); })",
|
|
2555
|
+
" .catch(function() { showToast('Failed to copy URL.', 'error'); });",
|
|
2556
|
+
" } else {",
|
|
2557
|
+
" var ta = document.createElement('textarea');",
|
|
2558
|
+
" ta.value = url;",
|
|
2559
|
+
" ta.style.position = 'fixed';",
|
|
2560
|
+
" ta.style.opacity = '0';",
|
|
2561
|
+
" document.body.appendChild(ta);",
|
|
2562
|
+
" ta.select();",
|
|
2563
|
+
" try { document.execCommand('copy'); showToast('Stream URL copied to clipboard.', 'success'); }",
|
|
2564
|
+
" catch(e) { showToast('Failed to copy URL.', 'error'); }",
|
|
2565
|
+
" document.body.removeChild(ta);",
|
|
2566
|
+
" }",
|
|
2567
|
+
" };",
|
|
2388
2568
|
// Toggle visibility of disabled predefined channels and persist preference.
|
|
2389
2569
|
" window.toggleDisabledVisibility = function() {",
|
|
2390
2570
|
" var table = document.querySelector('.channel-table');",
|
|
@@ -2568,6 +2748,7 @@ function generateLandingPageStyles() {
|
|
|
2568
2748
|
".header-status { display: flex; gap: 20px; align-items: center; font-size: 13px; color: var(--text-secondary); }",
|
|
2569
2749
|
".header-status span { white-space: nowrap; }",
|
|
2570
2750
|
// Stream count popover. Clickable when streams are active; popover drops from the right edge of the header.
|
|
2751
|
+
"#stream-count { background: none; border: none; color: inherit; font: inherit; padding: 0; }",
|
|
2571
2752
|
"#stream-count.clickable { cursor: pointer; }",
|
|
2572
2753
|
"#stream-count.clickable:hover { color: var(--text-primary); }",
|
|
2573
2754
|
".stream-popover .dropdown-menu { right: 0; left: auto; min-width: 220px; }",
|
|
@@ -2630,22 +2811,23 @@ function generateLandingPageStyles() {
|
|
|
2630
2811
|
".log-debug { color: var(--dark-text-debug); }",
|
|
2631
2812
|
".log-muted { color: var(--dark-text-muted); }",
|
|
2632
2813
|
".log-connecting { color: var(--dark-text-muted); }",
|
|
2633
|
-
// Channel table styles. The wrapper enables horizontal scrolling on small screens.
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
".channel-table
|
|
2637
|
-
"
|
|
2638
|
-
".channel-table th {
|
|
2814
|
+
// Channel table styles. The wrapper provides a rounded card border and enables horizontal scrolling on small screens. We use border-collapse: separate so
|
|
2815
|
+
// that border-radius works on the header cells.
|
|
2816
|
+
// Max-width caps the Name column (the sole flexible column) at 350px. Fixed columns: Key 170 + Provider 200 + Actions 140 = 510px.
|
|
2817
|
+
".channel-table-wrapper { max-width: 860px; margin: 0 auto 20px; border: 1px solid var(--border-default); border-radius: var(--radius-lg); }",
|
|
2818
|
+
".channel-table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; min-width: 650px; margin: 0; }",
|
|
2819
|
+
".channel-table th, .channel-table td { padding: 10px 12px; text-align: left; border: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }",
|
|
2820
|
+
".channel-table th { background: var(--table-header-bg); font-weight: 600; font-size: 13px; border-bottom: 1px solid var(--border-default); }",
|
|
2821
|
+
".channel-table tbody tr:nth-child(even):not(.user-channel) { background: var(--table-row-even); }",
|
|
2639
2822
|
".channel-table tr:hover { background: var(--table-row-hover); }",
|
|
2640
2823
|
".channel-table .col-key { width: 170px; }",
|
|
2641
|
-
".channel-table .col-
|
|
2642
|
-
".channel-table .col-
|
|
2643
|
-
".channel-table .col-profile { width: 140px; }",
|
|
2644
|
-
".channel-table .col-actions { width: 170px; white-space: nowrap; overflow: visible; }",
|
|
2824
|
+
".channel-table .col-provider { width: 200px; }",
|
|
2825
|
+
".channel-table .col-actions, .channel-table td:last-child { width: 140px; white-space: nowrap; overflow: visible; }",
|
|
2645
2826
|
".provider-select { width: 100%; padding: 2px 4px; font-size: 12px; border: 1px solid var(--form-input-border); ",
|
|
2646
2827
|
"border-radius: 3px; background: var(--form-input-bg); color: var(--text-primary); }",
|
|
2647
|
-
//
|
|
2648
|
-
"
|
|
2828
|
+
// Key column styling: monospace at a slightly smaller size with secondary color to reduce visual weight.
|
|
2829
|
+
".ch-key { color: var(--text-secondary); font-family: var(--font-mono); font-size: 13px; }",
|
|
2830
|
+
// Responsive: hide Key on phones.
|
|
2649
2831
|
"@media (max-width: 768px) { .channel-table .col-key, .channel-table td:nth-child(1), .channel-table th:nth-child(1) { display: none; } }",
|
|
2650
2832
|
// User channel row tinting to distinguish custom/override channels from predefined.
|
|
2651
2833
|
".channel-table tr.user-channel { background: var(--user-channel-tint); }",
|
|
@@ -2653,7 +2835,6 @@ function generateLandingPageStyles() {
|
|
|
2653
2835
|
// Disabled predefined channel row styling and hide-disabled toggle.
|
|
2654
2836
|
".channel-table tr.channel-disabled { opacity: 0.5; }",
|
|
2655
2837
|
".channel-table tr.channel-disabled td { color: var(--text-tertiary); }",
|
|
2656
|
-
".channel-table tr.channel-disabled code { color: var(--text-tertiary); }",
|
|
2657
2838
|
".channel-table.hide-disabled tr.channel-disabled { display: none; }",
|
|
2658
2839
|
// Provider-filtered channel row styling. Uses reduced opacity and italic text to distinguish from manually disabled rows. The compound selector ensures that rows
|
|
2659
2840
|
// which are both disabled and provider-filtered render at the disabled-level opacity (0.5) rather than the more aggressive unavailable-level opacity (0.4).
|
|
@@ -2681,14 +2862,31 @@ function generateLandingPageStyles() {
|
|
|
2681
2862
|
".chip-close:hover { color: var(--text-primary); }",
|
|
2682
2863
|
// Bulk assign dropdown.
|
|
2683
2864
|
".bulk-assign-select { font-size: 13px; padding: 4px 8px; border: 1px solid var(--border-default); border-radius: var(--radius-md); ",
|
|
2684
|
-
"background: var(--surface-
|
|
2865
|
+
"background: var(--surface-page); color: var(--text-primary); cursor: pointer; }",
|
|
2685
2866
|
// Responsive: stack provider toolbar groups vertically on small screens.
|
|
2686
2867
|
"@media (max-width: 768px) { .provider-toolbar { flex-direction: column; align-items: flex-start; } }",
|
|
2687
|
-
//
|
|
2688
|
-
".btn-
|
|
2689
|
-
"
|
|
2690
|
-
".btn-
|
|
2691
|
-
".btn-
|
|
2868
|
+
// Icon button styling for channel action buttons.
|
|
2869
|
+
".btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: none; ",
|
|
2870
|
+
"border-radius: var(--radius-md); background: transparent; cursor: pointer; color: var(--text-secondary); transition: color 0.15s, background 0.15s; }",
|
|
2871
|
+
".btn-icon:hover { background: var(--surface-hover); }",
|
|
2872
|
+
".user-channel .btn-icon:hover { background: var(--user-channel-tint-hover); }",
|
|
2873
|
+
".btn-icon-edit:hover { color: var(--interactive-edit); }",
|
|
2874
|
+
".btn-icon-delete:hover { color: var(--interactive-delete); }",
|
|
2875
|
+
".btn-icon-revert:hover { color: var(--interactive-edit); }",
|
|
2876
|
+
".btn-icon-enable:hover { color: var(--interactive-success); }",
|
|
2877
|
+
".btn-icon-disable:hover { color: var(--interactive-delete); }",
|
|
2878
|
+
".btn-icon-login:hover { color: var(--interactive-primary); }",
|
|
2879
|
+
".btn-icon-copy:hover { color: var(--interactive-primary); }",
|
|
2880
|
+
".btn-icon-placeholder { display: inline-block; width: 28px; height: 28px; }",
|
|
2881
|
+
".copy-dropdown .dropdown-menu { left: auto; right: 0; }",
|
|
2882
|
+
".copy-dropdown .dropdown-item { font-size: 12px; }",
|
|
2883
|
+
// JS tooltip styling. The tooltip element is appended to <body> and positioned via getBoundingClientRect() so it's immune to overflow and stacking contexts.
|
|
2884
|
+
// Only activated when the primary input can't hover (hover: none), targeting iPadOS where Safari doesn't show native title tooltips. On pure-touch
|
|
2885
|
+
// devices without a trackpad, the JS loads but mouseenter never fires so the tooltip stays hidden. Desktop (hover: hover) skips initialization entirely.
|
|
2886
|
+
".btn-icon-tooltip { position: fixed; padding: 4px 8px; border-radius: var(--radius-sm); background: var(--surface-overlay); color: var(--text-primary); ",
|
|
2887
|
+
"font-size: 12px; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 0.5s; z-index: 10000; ",
|
|
2888
|
+
"box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); }",
|
|
2889
|
+
".btn-icon-tooltip.visible { opacity: 1; transition: opacity 0.1s; }",
|
|
2692
2890
|
// Channel toolbar with operation buttons and display controls.
|
|
2693
2891
|
".channel-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; margin-bottom: 15px; }",
|
|
2694
2892
|
".channel-toolbar .toolbar-group { display: flex; align-items: center; gap: 6px; }",
|
|
@@ -2721,7 +2919,7 @@ function generateLandingPageStyles() {
|
|
|
2721
2919
|
"padding: 20px; }",
|
|
2722
2920
|
".profile-reference-header { display: flex; justify-content: space-between; align-items: flex-start; }",
|
|
2723
2921
|
".profile-reference h3 { margin: 0 0 10px 0; color: var(--text-heading-secondary); }",
|
|
2724
|
-
".profile-reference-close { color: var(--text-secondary); font-size: 18px;
|
|
2922
|
+
".profile-reference-close { color: var(--text-secondary); font-size: 18px; background: none; border: none; cursor: pointer; padding: 0 5px; }",
|
|
2725
2923
|
".profile-reference-close:hover { color: var(--text-primary); }",
|
|
2726
2924
|
".reference-intro { color: var(--text-secondary); font-size: 13px; margin-bottom: 20px; }",
|
|
2727
2925
|
".profile-category { margin-bottom: 20px; }",
|
|
@@ -2733,6 +2931,14 @@ function generateLandingPageStyles() {
|
|
|
2733
2931
|
".profile-list dt:first-child { margin-top: 0; }",
|
|
2734
2932
|
".profile-list dd { color: var(--text-secondary); font-size: 13px; margin: 4px 0 0 0; }",
|
|
2735
2933
|
".selector-guide-heading { margin-top: 20px !important; border-top: 1px solid var(--border-default); padding-top: 16px; }",
|
|
2934
|
+
// API Reference index.
|
|
2935
|
+
".api-index { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px 24px; margin-top: 12px; }",
|
|
2936
|
+
".api-index-group { display: flex; flex-direction: column; gap: 2px; }",
|
|
2937
|
+
".api-index a { color: var(--text-secondary); text-decoration: none; font-size: 12px; line-height: 1.5; }",
|
|
2938
|
+
".api-index a:hover { color: var(--interactive-primary); }",
|
|
2939
|
+
".api-index a code { font-size: 11px; }",
|
|
2940
|
+
".api-index-heading { font-weight: 600; font-size: 13px !important; color: var(--text-primary) !important; margin-bottom: 1px; }",
|
|
2941
|
+
".api-index-desc { color: var(--text-muted); font-size: 11px; margin-bottom: 3px; }",
|
|
2736
2942
|
// Other landing page styles.
|
|
2737
2943
|
".endpoint code { font-size: 13px; }",
|
|
2738
2944
|
// Modified value indicator styling.
|
|
@@ -2875,15 +3081,8 @@ export function setupRootEndpoint(app) {
|
|
|
2875
3081
|
});
|
|
2876
3082
|
app.get("/", (req, res) => {
|
|
2877
3083
|
const baseUrl = resolveBaseUrl(req);
|
|
2878
|
-
// Count the number of video channels (excluding static pages).
|
|
2879
|
-
const channels = getAllChannels();
|
|
2880
|
-
const videoChannelCount = Object.keys(channels).filter((name) => {
|
|
2881
|
-
const channel = channels[name];
|
|
2882
|
-
const profile = resolveProfile(channel.profile);
|
|
2883
|
-
return !profile.noVideo;
|
|
2884
|
-
}).length;
|
|
2885
3084
|
// Generate content for each tab.
|
|
2886
|
-
const overviewContent = generateOverviewContent(baseUrl
|
|
3085
|
+
const overviewContent = generateOverviewContent(baseUrl);
|
|
2887
3086
|
const channelsContent = generateChannelsTabContent();
|
|
2888
3087
|
const logsContent = generateLogsContent();
|
|
2889
3088
|
const configContent = generateConfigContent();
|