prismcast 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +44 -28
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +63 -5
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.js +1 -1
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/channelSelection.d.ts +10 -34
  8. package/dist/browser/channelSelection.js +31 -96
  9. package/dist/browser/channelSelection.js.map +1 -1
  10. package/dist/browser/display.d.ts +19 -0
  11. package/dist/browser/display.js +16 -11
  12. package/dist/browser/display.js.map +1 -1
  13. package/dist/browser/index.d.ts +31 -54
  14. package/dist/browser/index.js +334 -272
  15. package/dist/browser/index.js.map +1 -1
  16. package/dist/browser/login.d.ts +65 -0
  17. package/dist/browser/login.js +193 -0
  18. package/dist/browser/login.js.map +1 -0
  19. package/dist/browser/precaching.js +15 -3
  20. package/dist/browser/precaching.js.map +1 -1
  21. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  22. package/dist/browser/tuning/comcastPolymer.js +655 -0
  23. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  24. package/dist/browser/tuning/cox.d.ts +2 -0
  25. package/dist/browser/tuning/cox.js +14 -0
  26. package/dist/browser/tuning/cox.js.map +1 -0
  27. package/dist/browser/tuning/directv.js +14 -1
  28. package/dist/browser/tuning/directv.js.map +1 -1
  29. package/dist/browser/tuning/fox.js +14 -2
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.js +12 -1
  32. package/dist/browser/tuning/hbo.js.map +1 -1
  33. package/dist/browser/tuning/hulu.js +24 -1
  34. package/dist/browser/tuning/hulu.js.map +1 -1
  35. package/dist/browser/tuning/shared.d.ts +46 -0
  36. package/dist/browser/tuning/shared.js +100 -0
  37. package/dist/browser/tuning/shared.js.map +1 -0
  38. package/dist/browser/tuning/sling.js +35 -5
  39. package/dist/browser/tuning/sling.js.map +1 -1
  40. package/dist/browser/tuning/spectrum.js +13 -1
  41. package/dist/browser/tuning/spectrum.js.map +1 -1
  42. package/dist/browser/tuning/thumbnailRow.js +1 -1
  43. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  44. package/dist/browser/tuning/tileClick.js +1 -1
  45. package/dist/browser/tuning/tileClick.js.map +1 -1
  46. package/dist/browser/tuning/xfinity.d.ts +2 -0
  47. package/dist/browser/tuning/xfinity.js +14 -0
  48. package/dist/browser/tuning/xfinity.js.map +1 -0
  49. package/dist/browser/tuning/youtubeTv.js +15 -1
  50. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  51. package/dist/browser/video.d.ts +20 -0
  52. package/dist/browser/video.js +168 -124
  53. package/dist/browser/video.js.map +1 -1
  54. package/dist/channels/index.d.ts +2 -2
  55. package/dist/channels/index.js +2075 -683
  56. package/dist/channels/index.js.map +1 -1
  57. package/dist/config/health.d.ts +28 -28
  58. package/dist/config/health.js +72 -54
  59. package/dist/config/health.js.map +1 -1
  60. package/dist/config/index.js +29 -64
  61. package/dist/config/index.js.map +1 -1
  62. package/dist/config/paths.d.ts +12 -0
  63. package/dist/config/paths.js +16 -0
  64. package/dist/config/paths.js.map +1 -1
  65. package/dist/config/profiles.d.ts +3 -2
  66. package/dist/config/profiles.js +45 -34
  67. package/dist/config/profiles.js.map +1 -1
  68. package/dist/config/providerPacks.js +4 -1
  69. package/dist/config/providerPacks.js.map +1 -1
  70. package/dist/config/providers.d.ts +19 -5
  71. package/dist/config/providers.js +123 -93
  72. package/dist/config/providers.js.map +1 -1
  73. package/dist/config/sites.d.ts +28 -0
  74. package/dist/config/sites.js +104 -127
  75. package/dist/config/sites.js.map +1 -1
  76. package/dist/config/userChannels.d.ts +53 -2
  77. package/dist/config/userChannels.js +222 -13
  78. package/dist/config/userChannels.js.map +1 -1
  79. package/dist/config/userConfig.d.ts +2 -1
  80. package/dist/config/userConfig.js +6 -15
  81. package/dist/config/userConfig.js.map +1 -1
  82. package/dist/config/userProfiles.d.ts +0 -1
  83. package/dist/config/userProfiles.js +18 -10
  84. package/dist/config/userProfiles.js.map +1 -1
  85. package/dist/hdhr/channelMap.d.ts +1 -1
  86. package/dist/hdhr/channelMap.js +4 -4
  87. package/dist/hdhr/channelMap.js.map +1 -1
  88. package/dist/index.js +25 -22
  89. package/dist/index.js.map +1 -1
  90. package/dist/native/decrypt.d.ts +32 -0
  91. package/dist/native/decrypt.js +85 -0
  92. package/dist/native/decrypt.js.map +1 -0
  93. package/dist/native/index.d.ts +61 -0
  94. package/dist/native/index.js +342 -0
  95. package/dist/native/index.js.map +1 -0
  96. package/dist/native/intercept.d.ts +35 -0
  97. package/dist/native/intercept.js +184 -0
  98. package/dist/native/intercept.js.map +1 -0
  99. package/dist/native/probe.d.ts +49 -0
  100. package/dist/native/probe.js +294 -0
  101. package/dist/native/probe.js.map +1 -0
  102. package/dist/native/proxy.d.ts +54 -0
  103. package/dist/native/proxy.js +947 -0
  104. package/dist/native/proxy.js.map +1 -0
  105. package/dist/routes/components.d.ts +43 -0
  106. package/dist/routes/components.js +122 -0
  107. package/dist/routes/components.js.map +1 -1
  108. package/dist/routes/config/channels/index.d.ts +2 -2
  109. package/dist/routes/config/channels/index.js +1 -1
  110. package/dist/routes/config/channels/index.js.map +1 -1
  111. package/dist/routes/config/channels/routes.js +443 -65
  112. package/dist/routes/config/channels/routes.js.map +1 -1
  113. package/dist/routes/config/channels/table.d.ts +76 -0
  114. package/dist/routes/config/channels/table.js +369 -89
  115. package/dist/routes/config/channels/table.js.map +1 -1
  116. package/dist/routes/config/index.d.ts +1 -1
  117. package/dist/routes/config/index.js +1 -1
  118. package/dist/routes/config/index.js.map +1 -1
  119. package/dist/routes/config/providers.d.ts +6 -6
  120. package/dist/routes/config/providers.js +73 -99
  121. package/dist/routes/config/providers.js.map +1 -1
  122. package/dist/routes/debug.js +5 -5
  123. package/dist/routes/debug.js.map +1 -1
  124. package/dist/routes/hls.js +12 -2
  125. package/dist/routes/hls.js.map +1 -1
  126. package/dist/routes/index.d.ts +0 -15
  127. package/dist/routes/index.js +2 -16
  128. package/dist/routes/index.js.map +1 -1
  129. package/dist/routes/providers.js +106 -3
  130. package/dist/routes/providers.js.map +1 -1
  131. package/dist/routes/root/content.js +124 -47
  132. package/dist/routes/root/content.js.map +1 -1
  133. package/dist/routes/root/index.js +2 -1
  134. package/dist/routes/root/index.js.map +1 -1
  135. package/dist/routes/root/scripts/channels.js +772 -312
  136. package/dist/routes/root/scripts/channels.js.map +1 -1
  137. package/dist/routes/root/scripts/config.js +596 -544
  138. package/dist/routes/root/scripts/config.js.map +1 -1
  139. package/dist/routes/root/scripts/index.d.ts +1 -0
  140. package/dist/routes/root/scripts/index.js +1 -0
  141. package/dist/routes/root/scripts/index.js.map +1 -1
  142. package/dist/routes/root/scripts/shared.d.ts +6 -0
  143. package/dist/routes/root/scripts/shared.js +391 -0
  144. package/dist/routes/root/scripts/shared.js.map +1 -0
  145. package/dist/routes/root/scripts/status.js +49 -33
  146. package/dist/routes/root/scripts/status.js.map +1 -1
  147. package/dist/routes/root/styles.js +117 -10
  148. package/dist/routes/root/styles.js.map +1 -1
  149. package/dist/service/commands.js +2 -1
  150. package/dist/service/commands.js.map +1 -1
  151. package/dist/streaming/fmp4Segmenter.d.ts +5 -0
  152. package/dist/streaming/fmp4Segmenter.js +146 -42
  153. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  154. package/dist/streaming/hls.d.ts +24 -19
  155. package/dist/streaming/hls.js +603 -297
  156. package/dist/streaming/hls.js.map +1 -1
  157. package/dist/streaming/hlsResume.d.ts +16 -3
  158. package/dist/streaming/hlsResume.js +35 -8
  159. package/dist/streaming/hlsResume.js.map +1 -1
  160. package/dist/streaming/hlsSegments.d.ts +41 -2
  161. package/dist/streaming/hlsSegments.js +131 -28
  162. package/dist/streaming/hlsSegments.js.map +1 -1
  163. package/dist/streaming/lifecycle.js +27 -13
  164. package/dist/streaming/lifecycle.js.map +1 -1
  165. package/dist/streaming/monitor.d.ts +1 -0
  166. package/dist/streaming/monitor.js +862 -474
  167. package/dist/streaming/monitor.js.map +1 -1
  168. package/dist/streaming/mp4Parser.d.ts +52 -6
  169. package/dist/streaming/mp4Parser.js +156 -30
  170. package/dist/streaming/mp4Parser.js.map +1 -1
  171. package/dist/streaming/mpegts.d.ts +2 -2
  172. package/dist/streaming/mpegts.js +156 -109
  173. package/dist/streaming/mpegts.js.map +1 -1
  174. package/dist/streaming/playlistBuilder.d.ts +38 -0
  175. package/dist/streaming/playlistBuilder.js +66 -0
  176. package/dist/streaming/playlistBuilder.js.map +1 -0
  177. package/dist/streaming/preroll.d.ts +126 -0
  178. package/dist/streaming/preroll.js +434 -0
  179. package/dist/streaming/preroll.js.map +1 -0
  180. package/dist/streaming/pretune.d.ts +8 -0
  181. package/dist/streaming/pretune.js +226 -0
  182. package/dist/streaming/pretune.js.map +1 -0
  183. package/dist/streaming/recovery.d.ts +0 -6
  184. package/dist/streaming/recovery.js +0 -8
  185. package/dist/streaming/recovery.js.map +1 -1
  186. package/dist/streaming/registry.d.ts +57 -9
  187. package/dist/streaming/registry.js +26 -7
  188. package/dist/streaming/registry.js.map +1 -1
  189. package/dist/streaming/setup.d.ts +8 -1
  190. package/dist/streaming/setup.js +71 -27
  191. package/dist/streaming/setup.js.map +1 -1
  192. package/dist/streaming/showInfo.d.ts +29 -4
  193. package/dist/streaming/showInfo.js +210 -38
  194. package/dist/streaming/showInfo.js.map +1 -1
  195. package/dist/streaming/statusEmitter.d.ts +20 -6
  196. package/dist/streaming/statusEmitter.js +31 -7
  197. package/dist/streaming/statusEmitter.js.map +1 -1
  198. package/dist/types/channels.d.ts +32 -0
  199. package/dist/types/config.d.ts +2 -2
  200. package/dist/types/index.d.ts +2 -2
  201. package/dist/types/index.js.map +1 -1
  202. package/dist/types/profiles.d.ts +13 -1
  203. package/dist/types/selection.d.ts +5 -1
  204. package/dist/types/shared.d.ts +1 -1
  205. package/dist/types/streaming.d.ts +7 -0
  206. package/dist/utils/chromeFetch.d.ts +20 -0
  207. package/dist/utils/chromeFetch.js +43 -0
  208. package/dist/utils/chromeFetch.js.map +1 -0
  209. package/dist/utils/debugFilter.d.ts +0 -5
  210. package/dist/utils/debugFilter.js +20 -11
  211. package/dist/utils/debugFilter.js.map +1 -1
  212. package/dist/utils/delay.d.ts +28 -0
  213. package/dist/utils/delay.js +35 -1
  214. package/dist/utils/delay.js.map +1 -1
  215. package/dist/utils/evaluate.js +6 -11
  216. package/dist/utils/evaluate.js.map +1 -1
  217. package/dist/utils/ffmpeg.d.ts +14 -12
  218. package/dist/utils/ffmpeg.js +114 -134
  219. package/dist/utils/ffmpeg.js.map +1 -1
  220. package/dist/utils/format.d.ts +6 -0
  221. package/dist/utils/format.js +8 -0
  222. package/dist/utils/format.js.map +1 -1
  223. package/dist/utils/index.d.ts +3 -0
  224. package/dist/utils/index.js +3 -0
  225. package/dist/utils/index.js.map +1 -1
  226. package/dist/utils/logEmitter.js +1 -1
  227. package/dist/utils/logEmitter.js.map +1 -1
  228. package/dist/utils/pid.d.ts +40 -0
  229. package/dist/utils/pid.js +83 -0
  230. package/dist/utils/pid.js.map +1 -0
  231. package/dist/utils/retry.d.ts +15 -9
  232. package/dist/utils/retry.js +7 -23
  233. package/dist/utils/retry.js.map +1 -1
  234. package/dist/utils/timing.d.ts +5 -0
  235. package/dist/utils/timing.js +13 -0
  236. package/dist/utils/timing.js.map +1 -0
  237. package/dist/utils/version.js +2 -2
  238. package/dist/utils/version.js.map +1 -1
  239. package/package.json +7 -7
@@ -43,42 +43,6 @@ export function generateConfigSubtabScript() {
43
43
  " }",
44
44
  " }",
45
45
  " }",
46
- // Show a toast notification. Auto-dismiss durations: success/info = 5s, warning = 8s, error = no auto-dismiss. Optional action: { label, onclick } appends an
47
- // inline button between the message text and the close button.
48
- " function showToast(message, type, duration, action) {",
49
- " var container = document.getElementById('toast-container');",
50
- " if (!container) return;",
51
- " var toast = document.createElement('div');",
52
- " toast.className = 'toast ' + (type || 'info');",
53
- " toast.textContent = message;",
54
- " toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');",
55
- " if (action && action.label) {",
56
- " var actionBtn = document.createElement('button');",
57
- " actionBtn.type = 'button';",
58
- " actionBtn.className = 'toast-action';",
59
- " actionBtn.textContent = action.label;",
60
- " actionBtn.onclick = function() { if (action.onclick) action.onclick(); dismissToast(toast); };",
61
- " toast.appendChild(actionBtn);",
62
- " }",
63
- " var closeBtn = document.createElement('button');",
64
- " closeBtn.type = 'button';",
65
- " closeBtn.className = 'toast-close';",
66
- " closeBtn.textContent = '\\u00d7';",
67
- " closeBtn.setAttribute('aria-label', 'Dismiss');",
68
- " closeBtn.onclick = function() { dismissToast(toast); };",
69
- " toast.appendChild(closeBtn);",
70
- " container.appendChild(toast);",
71
- " var ms = duration !== undefined ? duration : type === 'error' ? 0 : type === 'warning' ? 8000 : 5000;",
72
- " if (ms > 0) { setTimeout(function() { dismissToast(toast); }, ms); }",
73
- " }",
74
- // Expose showToast globally so other script blocks (e.g., channels-subtab IIFE) can use the toast system.
75
- " window.showToast = showToast;",
76
- // Dismiss a toast with slide-out animation.
77
- " function dismissToast(toast) {",
78
- " if (toast.classList.contains('toast-exit')) return;",
79
- " toast.classList.add('toast-exit');",
80
- " toast.addEventListener('animationend', function() { if (toast.parentNode) toast.parentNode.removeChild(toast); });",
81
- " }",
82
46
  // Hint appended to success toasts when a channel operation changes M3U playlist content that Channels DVR consumes.
83
47
  " var PLAYLIST_HINT = ' Reload the playlist in Channels DVR to see this change.';",
84
48
  // Queue a toast to appear after the next page reload.
@@ -115,34 +79,31 @@ export function generateConfigSubtabScript() {
115
79
  " showToast('Restart cancelled. Changes will apply on next restart.', 'info');",
116
80
  " };",
117
81
  // Force immediate restart despite active streams.
118
- " window.forceRestart = function() {",
82
+ " window.forceRestart = async function() {",
119
83
  " pendingRestart = false;",
120
84
  " document.getElementById('restart-dialog').style.display = 'none';",
121
- " fetch('/config/restart-now', { method: 'POST' })",
122
- " .then(function(response) {",
123
- " if (response.ok) {",
124
- " waitForServerRestart();",
125
- " } else {",
126
- " return response.json().then(function(data) {",
127
- " throw new Error(data.message || 'Restart failed');",
128
- " });",
129
- " }",
130
- " })",
131
- " .catch(function(err) {",
132
- " showToast('Failed to restart: ' + err.message, 'error');",
133
- " });",
85
+ " try {",
86
+ " var res = await fetch('/config/restart-now', { method: 'POST' });",
87
+ " if (res.ok) {",
88
+ " waitForServerRestart();",
89
+ " } else {",
90
+ " var data = await res.json();",
91
+ " throw new Error(data.message || 'Restart failed');",
92
+ " }",
93
+ " } catch(err) {",
94
+ " showToast('Failed to restart: ' + err.message, 'error');",
95
+ " }",
134
96
  " };",
135
97
  // Trigger restart (called when streams reach 0).
136
- " function triggerRestart() {",
137
- " fetch('/config/restart-now', { method: 'POST' })",
138
- " .then(function(response) {",
139
- " if (response.ok) {",
140
- " waitForServerRestart();",
141
- " }",
142
- " })",
143
- " .catch(function() {",
144
- " showToast('Failed to trigger restart. Please restart manually.', 'error');",
145
- " });",
98
+ " async function triggerRestart() {",
99
+ " try {",
100
+ " var res = await fetch('/config/restart-now', { method: 'POST' });",
101
+ " if (res.ok) {",
102
+ " waitForServerRestart();",
103
+ " }",
104
+ " } catch(err) {",
105
+ " showToast('Failed to trigger restart. Please restart manually.', 'error');",
106
+ " }",
146
107
  " }",
147
108
  // Wait for server restart by polling /health, then reload.
148
109
  " function waitForServerRestart() {",
@@ -150,23 +111,22 @@ export function generateConfigSubtabScript() {
150
111
  " var maxAttempts = 30;",
151
112
  " showToast('Restarting server...', 'info', 0);",
152
113
  " if (restartPollInterval) { clearInterval(restartPollInterval); }",
153
- " restartPollInterval = setInterval(function() {",
114
+ " restartPollInterval = setInterval(async function() {",
154
115
  " attempts++;",
155
- " fetch('/health')",
156
- " .then(function(response) {",
157
- " if (response.ok) {",
158
- " clearInterval(restartPollInterval);",
159
- " restartPollInterval = null;",
160
- " showToastAfterReload('Server restarted.', 'success');",
161
- " }",
162
- " })",
163
- " .catch(function() {",
164
- " if (attempts >= maxAttempts) {",
165
- " clearInterval(restartPollInterval);",
166
- " restartPollInterval = null;",
167
- " showToast('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
168
- " }",
169
- " });",
116
+ " try {",
117
+ " var res = await fetch('/health');",
118
+ " if (res.ok) {",
119
+ " clearInterval(restartPollInterval);",
120
+ " restartPollInterval = null;",
121
+ " showToastAfterReload('Server restarted.', 'success');",
122
+ " }",
123
+ " } catch(err) {",
124
+ " if (attempts >= maxAttempts) {",
125
+ " clearInterval(restartPollInterval);",
126
+ " restartPollInterval = null;",
127
+ " showToast('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
128
+ " }",
129
+ " }",
170
130
  " }, 1000);",
171
131
  " }",
172
132
  // Escape HTML entities in text for safe display.
@@ -176,7 +136,7 @@ export function generateConfigSubtabScript() {
176
136
  " return div.innerHTML;",
177
137
  " }",
178
138
  // Open the changelog modal and fetch content dynamically. Also checks whether an upgrade button should be shown.
179
- " window.openChangelogModal = function() {",
139
+ " window.openChangelogModal = async function() {",
180
140
  " var modal = document.getElementById('changelog-modal');",
181
141
  " if (!modal) return;",
182
142
  " var title = modal.querySelector('.changelog-title');",
@@ -189,28 +149,27 @@ export function generateConfigSubtabScript() {
189
149
  " content.style.display = 'none';",
190
150
  " error.style.display = 'none';",
191
151
  " if (upgradeBtn) upgradeBtn.style.display = 'none';",
192
- " fetch('/version/changelog')",
193
- " .then(function(res) { return res.json(); })",
194
- " .then(function(data) {",
195
- " loading.style.display = 'none';",
196
- " title.textContent = \"What's new in v\" + data.displayVersion;",
197
- " if (data.items && data.items.length > 0) {",
198
- " var html = '<ul class=\"changelog-list\">';",
199
- " for (var i = 0; i < data.items.length; i++) {",
200
- " html += '<li>' + escapeHtmlText(data.items[i]) + '</li>';",
201
- " }",
202
- " html += '</ul>';",
203
- " content.innerHTML = html;",
204
- " content.style.display = 'block';",
205
- " } else {",
206
- " error.style.display = 'block';",
152
+ " try {",
153
+ " var res = await fetch('/version/changelog');",
154
+ " var data = await res.json();",
155
+ " loading.style.display = 'none';",
156
+ " title.textContent = \"What's new in v\" + data.displayVersion;",
157
+ " if (data.items && data.items.length > 0) {",
158
+ " var html = '<ul class=\"changelog-list\">';",
159
+ " for (var i = 0; i < data.items.length; i++) {",
160
+ " html += '<li>' + escapeHtmlText(data.items[i]) + '</li>';",
207
161
  " }",
208
- " if (data.updateAvailable && upgradeBtn) { upgradeBtn.style.display = ''; }",
209
- " })",
210
- " .catch(function() {",
211
- " loading.style.display = 'none';",
162
+ " html += '</ul>';",
163
+ " content.innerHTML = html;",
164
+ " content.style.display = 'block';",
165
+ " } else {",
212
166
  " error.style.display = 'block';",
213
- " });",
167
+ " }",
168
+ " if (data.updateAvailable && upgradeBtn) { upgradeBtn.style.display = ''; }",
169
+ " } catch(err) {",
170
+ " loading.style.display = 'none';",
171
+ " error.style.display = 'block';",
172
+ " }",
214
173
  " };",
215
174
  // Close the changelog modal.
216
175
  " window.closeChangelogModal = function() {",
@@ -218,65 +177,61 @@ export function generateConfigSubtabScript() {
218
177
  " if (modal) { modal.style.display = 'none'; }",
219
178
  " };",
220
179
  // Start the upgrade process from the changelog modal. Fetches upgrade info, confirms with the user if there are active streams, then executes the upgrade.
221
- " window.startUpgrade = function() {",
222
- " fetch('/upgrade/info')",
223
- " .then(function(res) { return res.json(); })",
224
- " .then(function(info) {",
225
- " if (!info.upgradeable) {",
226
- " closeChangelogModal();",
227
- " var msg = info.method === 'docker'",
228
- " ? 'Docker containers cannot be upgraded in-place. Pull the latest image and recreate the container.'",
229
- " : 'Manual upgrade required: ' + info.upgradeCommand;",
230
- " showToast(msg, 'info', 8000);",
231
- " return;",
232
- " }",
233
- " var streamCount = Object.keys(streamData).length;",
234
- " if (streamCount > 0) {",
235
- " if (!confirm('There are ' + streamCount + ' active stream(s). Upgrading will interrupt them. Continue?')) return;",
236
- " }",
180
+ " window.startUpgrade = async function() {",
181
+ " try {",
182
+ " var infoRes = await fetch('/upgrade/info');",
183
+ " var info = await infoRes.json();",
184
+ " if (!info.upgradeable) {",
237
185
  " closeChangelogModal();",
238
- " showToast('Upgrading PrismCast...', 'info', 0);",
239
- " return fetch('/upgrade', { method: 'POST' })",
240
- " .then(function(res) { return res.json(); })",
241
- " .then(function(result) {",
242
- " if (result.success && result.willRestart) {",
243
- " waitForServerRestart();",
244
- " } else if (result.success) {",
245
- " showToast('Upgrade complete. Please restart PrismCast manually.', 'success', 8000);",
246
- " } else {",
247
- " showToast('Upgrade failed: ' + result.message, 'error');",
248
- " }",
249
- " });",
250
- " })",
251
- " .catch(function(err) {",
252
- " showToast('Upgrade failed: ' + err.message, 'error');",
253
- " });",
186
+ " var msg = info.method === 'docker'",
187
+ " ? 'Docker containers cannot be upgraded in-place. Pull the latest image and recreate the container.'",
188
+ " : 'Manual upgrade required: ' + info.upgradeCommand;",
189
+ " showToast(msg, 'info', 8000);",
190
+ " return;",
191
+ " }",
192
+ " var streamCount = Object.keys(streamData).length;",
193
+ " if (streamCount > 0) {",
194
+ " if (!confirm('There are ' + streamCount + ' active stream(s). Upgrading will interrupt them. Continue?')) return;",
195
+ " }",
196
+ " closeChangelogModal();",
197
+ " showToast('Upgrading PrismCast...', 'info', 0);",
198
+ " var upgradeRes = await fetch('/upgrade', { method: 'POST' });",
199
+ " var result = await upgradeRes.json();",
200
+ " if (result.success && result.willRestart) {",
201
+ " waitForServerRestart();",
202
+ " } else if (result.success) {",
203
+ " showToast('Upgrade complete. Please restart PrismCast manually.', 'success', 8000);",
204
+ " } else {",
205
+ " showToast('Upgrade failed: ' + result.message, 'error');",
206
+ " }",
207
+ " } catch(err) {",
208
+ " showToast('Upgrade failed: ' + err.message, 'error');",
209
+ " }",
254
210
  " };",
255
211
  // Check for updates manually. Updates the version link in-place when a new version is found.
256
- " window.checkForUpdates = function() {",
212
+ " window.checkForUpdates = async function() {",
257
213
  " var btn = document.querySelector('.version-check');",
258
214
  " if (!btn || btn.classList.contains('checking')) return;",
259
215
  " btn.classList.add('checking');",
260
- " fetch('/version/check', { method: 'POST' })",
261
- " .then(function(res) { return res.json(); })",
262
- " .then(function(data) {",
263
- " btn.classList.remove('checking');",
264
- " if (data.updateAvailable && data.latestVersion) {",
265
- " var link = document.querySelector('.version-container .version');",
266
- " if (link && !link.classList.contains('version-update')) {",
267
- " link.textContent = 'v' + data.currentVersion + ' \\u2192 v' + data.latestVersion;",
268
- " link.classList.add('version-update');",
269
- " }",
270
- " } else {",
271
- " btn.classList.add('up-to-date');",
272
- " setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
216
+ " try {",
217
+ " var res = await fetch('/version/check', { method: 'POST' });",
218
+ " var data = await res.json();",
219
+ " btn.classList.remove('checking');",
220
+ " if (data.updateAvailable && data.latestVersion) {",
221
+ " var link = document.querySelector('.version-container .version');",
222
+ " if (link && !link.classList.contains('version-update')) {",
223
+ " link.textContent = 'v' + data.currentVersion + ' \\u2192 v' + data.latestVersion;",
224
+ " link.classList.add('version-update');",
273
225
  " }",
274
- " })",
275
- " .catch(function() {",
276
- " btn.classList.remove('checking');",
277
- " btn.classList.add('check-error');",
278
- " setTimeout(function() { btn.classList.remove('check-error'); }, 2000);",
279
- " });",
226
+ " } else {",
227
+ " btn.classList.add('up-to-date');",
228
+ " setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
229
+ " }",
230
+ " } catch(err) {",
231
+ " btn.classList.remove('checking');",
232
+ " btn.classList.add('check-error');",
233
+ " setTimeout(function() { btn.classList.remove('check-error'); }, 2000);",
234
+ " }",
280
235
  " };",
281
236
  // Clear all field error indicators.
282
237
  " function clearFieldErrors() {",
@@ -480,7 +435,7 @@ export function generateConfigSubtabScript() {
480
435
  " showToast('All settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
481
436
  " };",
482
437
  // Submit settings form via AJAX.
483
- " window.submitSettingsForm = function(event) {",
438
+ " window.submitSettingsForm = async function(event) {",
484
439
  " event.preventDefault();",
485
440
  " clearFieldErrors();",
486
441
  " var form = document.getElementById('settings-form');",
@@ -500,132 +455,129 @@ export function generateConfigSubtabScript() {
500
455
  " obj[parts[parts.length - 1]] = value;",
501
456
  " }",
502
457
  " if (saveBtn) saveBtn.classList.add('loading');",
503
- " fetch('/config', {",
504
- " method: 'POST',",
505
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
506
- " body: JSON.stringify(config)",
507
- " })",
508
- " .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })",
509
- " .then(function(result) {",
458
+ " try {",
459
+ " var res = await fetch('/config', {",
460
+ " method: 'POST',",
461
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
462
+ " body: JSON.stringify(config)",
463
+ " });",
464
+ " var data = await res.json();",
510
465
  " if (saveBtn) saveBtn.classList.remove('loading');",
511
- " if (result.ok && result.data.success) {",
512
- " if (result.data.willRestart) {",
513
- " if (result.data.deferred) {",
514
- " showPendingRestartDialog(result.data.activeStreams);",
466
+ " if (res.ok && data.success) {",
467
+ " if (data.willRestart) {",
468
+ " if (data.deferred) {",
469
+ " showPendingRestartDialog(data.activeStreams);",
515
470
  " } else {",
516
471
  " waitForServerRestart();",
517
472
  " }",
518
473
  " } else {",
519
- " showToast(result.data.message || 'Configuration saved.', 'info');",
474
+ " showToast(data.message || 'Configuration saved.', 'info');",
520
475
  " }",
521
- " } else if (result.data.errors) {",
522
- " displayFieldErrors(result.data.errors);",
476
+ " } else if (data.errors) {",
477
+ " displayFieldErrors(data.errors);",
523
478
  " showToast('Please correct the errors below.', 'error');",
524
479
  " } else {",
525
- " showToast(result.data.message || 'Failed to save configuration.', 'error');",
480
+ " showToast(data.message || 'Failed to save configuration.', 'error');",
526
481
  " }",
527
- " })",
528
- " .catch(function(err) {",
482
+ " } catch(err) {",
529
483
  " if (saveBtn) saveBtn.classList.remove('loading');",
530
484
  " showToast('Failed to save configuration: ' + err.message, 'error');",
531
- " });",
485
+ " }",
532
486
  " return false;",
533
487
  " };",
534
488
  // Export configuration as JSON download.
535
- " window.exportConfig = function() {",
536
- " fetch('/config/export')",
537
- " .then(function(response) { return response.blob(); })",
538
- " .then(function(blob) {",
539
- " var url = window.URL.createObjectURL(blob);",
540
- " var a = document.createElement('a');",
541
- " a.href = url;",
542
- " a.download = 'prismcast-config.json';",
543
- " document.body.appendChild(a);",
544
- " a.click();",
545
- " document.body.removeChild(a);",
546
- " window.URL.revokeObjectURL(url);",
547
- " })",
548
- " .catch(function(err) { showToast('Failed to export configuration: ' + err.message, 'error'); });",
489
+ " window.exportConfig = async function() {",
490
+ " try {",
491
+ " var res = await fetch('/config/export');",
492
+ " var blob = await res.blob();",
493
+ " var url = window.URL.createObjectURL(blob);",
494
+ " var a = document.createElement('a');",
495
+ " a.href = url;",
496
+ " a.download = 'prismcast-config.json';",
497
+ " document.body.appendChild(a);",
498
+ " a.click();",
499
+ " document.body.removeChild(a);",
500
+ " window.URL.revokeObjectURL(url);",
501
+ " } catch(err) {",
502
+ " showToast('Failed to export configuration: ' + err.message, 'error');",
503
+ " }",
549
504
  " };",
550
505
  // Import configuration from file.
551
506
  " window.importConfig = function(fileInput) {",
552
507
  " var file = fileInput.files[0];",
553
508
  " if (!file) return;",
554
509
  " var reader = new FileReader();",
555
- " reader.onload = function(e) {",
510
+ " reader.onload = async function(e) {",
556
511
  " try {",
557
512
  " var config = JSON.parse(e.target.result);",
558
513
  " if (confirm('Import this configuration? The server may restart to apply changes.')) {",
559
- " fetch('/config/import', {",
514
+ " var res = await fetch('/config/import', {",
560
515
  " method: 'POST',",
561
516
  " headers: { 'Content-Type': 'application/json' },",
562
517
  " body: JSON.stringify(config)",
563
- " })",
564
- " .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })",
565
- " .then(function(result) {",
566
- " if (result.ok && result.data.success) {",
567
- " showToast(result.data.message || 'Configuration imported.', 'success');",
568
- " if (result.data.willRestart) {",
569
- " if (result.data.deferred) {",
570
- " showPendingRestartDialog(result.data.activeStreams);",
571
- " } else {",
572
- " waitForServerRestart();",
573
- " }",
518
+ " });",
519
+ " var data = await res.json();",
520
+ " if (res.ok && data.success) {",
521
+ " showToast(data.message || 'Configuration imported.', 'success');",
522
+ " if (data.willRestart) {",
523
+ " if (data.deferred) {",
524
+ " showPendingRestartDialog(data.activeStreams);",
525
+ " } else {",
526
+ " waitForServerRestart();",
574
527
  " }",
575
- " } else {",
576
- " throw new Error(result.data.message || result.data.error || 'Import failed');",
577
528
  " }",
578
- " })",
579
- " .catch(function(err) { showToast('Failed to import configuration: ' + err.message, 'error'); });",
529
+ " } else {",
530
+ " throw new Error(data.message || data.error || 'Import failed');",
531
+ " }",
580
532
  " }",
581
533
  " } catch (err) {",
582
- " showToast('Invalid JSON file: ' + err.message, 'error');",
534
+ " showToast('Failed to import configuration: ' + err.message, 'error');",
583
535
  " }",
584
536
  " fileInput.value = '';",
585
537
  " };",
586
538
  " reader.readAsText(file);",
587
539
  " };",
588
540
  // Export channels as JSON download.
589
- " window.exportChannels = function() {",
590
- " fetch('/config/channels/export')",
591
- " .then(function(response) { return response.blob(); })",
592
- " .then(function(blob) {",
593
- " var url = window.URL.createObjectURL(blob);",
594
- " var a = document.createElement('a');",
595
- " a.href = url;",
596
- " a.download = 'prismcast-channels.json';",
597
- " document.body.appendChild(a);",
598
- " a.click();",
599
- " document.body.removeChild(a);",
600
- " window.URL.revokeObjectURL(url);",
601
- " })",
602
- " .catch(function(err) { showToast('Failed to export channels: ' + err.message, 'error'); });",
541
+ " window.exportChannels = async function() {",
542
+ " try {",
543
+ " var res = await fetch('/config/channels/export');",
544
+ " var blob = await res.blob();",
545
+ " var url = window.URL.createObjectURL(blob);",
546
+ " var a = document.createElement('a');",
547
+ " a.href = url;",
548
+ " a.download = 'prismcast-channels.json';",
549
+ " document.body.appendChild(a);",
550
+ " a.click();",
551
+ " document.body.removeChild(a);",
552
+ " window.URL.revokeObjectURL(url);",
553
+ " } catch(err) {",
554
+ " showToast('Failed to export channels: ' + err.message, 'error');",
555
+ " }",
603
556
  " };",
604
557
  // Import channels from file.
605
558
  " window.importChannels = function(fileInput) {",
606
559
  " var file = fileInput.files[0];",
607
560
  " if (!file) return;",
608
561
  " var reader = new FileReader();",
609
- " reader.onload = function(e) {",
562
+ " reader.onload = async function(e) {",
610
563
  " try {",
611
564
  " var channels = JSON.parse(e.target.result);",
612
565
  " if (confirm('Import these channels? This will replace all existing user channels.')) {",
613
- " fetch('/config/channels/import', {",
566
+ " var res = await fetch('/config/channels/import', {",
614
567
  " method: 'POST',",
615
568
  " headers: { 'Content-Type': 'application/json' },",
616
569
  " body: JSON.stringify(channels)",
617
- " })",
618
- " .then(function(response) {",
619
- " if (response.ok) {",
620
- " showToastAfterReload('Channels imported successfully.' + PLAYLIST_HINT, 'success');",
621
- " } else {",
622
- " return response.text().then(function(text) { throw new Error(text); });",
623
- " }",
624
- " })",
625
- " .catch(function(err) { showToast('Failed to import channels: ' + err.message, 'error'); });",
570
+ " });",
571
+ " var data = await res.json();",
572
+ " if (data.success) {",
573
+ " showToast((data.message || 'Channels imported successfully.') + PLAYLIST_HINT, 'success');",
574
+ " if (data.patch) { applyChannelPatch(data.patch); }",
575
+ " } else {",
576
+ " showToast('Failed to import channels: ' + (data.error || 'Unknown error.'), 'error');",
577
+ " }",
626
578
  " }",
627
579
  " } catch (err) {",
628
- " showToast('Invalid JSON file: ' + err.message, 'error');",
580
+ " showToast('Failed to import channels: ' + err.message, 'error');",
629
581
  " }",
630
582
  " fileInput.value = '';",
631
583
  " };",
@@ -638,14 +590,14 @@ export function generateConfigSubtabScript() {
638
590
  " var replaceCheckbox = document.getElementById('m3u-replace-duplicates');",
639
591
  " var conflictMode = (replaceCheckbox && replaceCheckbox.checked) ? 'replace' : 'skip';",
640
592
  " var reader = new FileReader();",
641
- " reader.onload = function(e) {",
642
- " fetch('/config/channels/import-m3u', {",
643
- " method: 'POST',",
644
- " headers: { 'Content-Type': 'application/json' },",
645
- " body: JSON.stringify({ content: e.target.result, conflictMode: conflictMode })",
646
- " })",
647
- " .then(function(response) { return response.json(); })",
648
- " .then(function(data) {",
593
+ " reader.onload = async function(e) {",
594
+ " try {",
595
+ " var res = await fetch('/config/channels/import-m3u', {",
596
+ " method: 'POST',",
597
+ " headers: { 'Content-Type': 'application/json' },",
598
+ " body: JSON.stringify({ content: e.target.result, conflictMode: conflictMode })",
599
+ " });",
600
+ " var data = await res.json();",
649
601
  " if (data.success) {",
650
602
  " var msg = 'M3U Import Complete\\n\\n';",
651
603
  " msg += '\\u2713 ' + data.imported + ' channel(s) imported\\n';",
@@ -658,13 +610,15 @@ export function generateConfigSubtabScript() {
658
610
  " }",
659
611
  " if (data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
660
612
  " }",
661
- " if (data.imported > 0 || data.replaced > 0) { showToastAfterReload(msg + PLAYLIST_HINT, 'success'); }",
662
- " else { showToast(msg, 'success'); }",
613
+ " if (data.patch) { applyChannelPatch(data.patch); }",
614
+ " showToast(msg + ((data.imported > 0 || data.replaced > 0) ? PLAYLIST_HINT : ''), 'success');",
663
615
  " } else {",
664
616
  " showToast('M3U import failed: ' + (data.error || 'Unknown error'), 'error');",
665
617
  " }",
666
- " })",
667
- " .catch(function(err) { showToast('Failed to import M3U: ' + err.message, 'error'); });",
618
+ " } catch(err) {",
619
+ " console.error('M3U import error:', err);",
620
+ " showToast('Failed to import M3U: ' + err.message, 'error');",
621
+ " }",
668
622
  " fileInput.value = '';",
669
623
  " };",
670
624
  " reader.readAsText(file);",
@@ -749,13 +703,44 @@ export function generateConfigSubtabScript() {
749
703
  " if (displayRow) displayRow.style.display = '';",
750
704
  " if (editRow) editRow.style.display = 'none';",
751
705
  " };",
752
- // Hide add form and show the Add Channel button.
706
+ // Select a provider pill in the add form. Clicking an inactive pill highlights it, fills the URL field, sets profile to autodetect (disabled), and expands
707
+ // the advanced section (channel selector is essential for provider channels). Clicking the active pill deselects it, clears the URL, and re-enables the
708
+ // profile dropdown for manual entry mode.
709
+ " window.selectProviderPill = function(btn) {",
710
+ " var wasActive = btn.classList.contains('active');",
711
+ " var pills = btn.parentElement.querySelectorAll('.provider-pill');",
712
+ " for(var i = 0; i < pills.length; i++) pills[i].classList.remove('active');",
713
+ " if(!wasActive) btn.classList.add('active');",
714
+ " var urlInput = document.getElementById('add-url');",
715
+ " if(urlInput) {",
716
+ " urlInput.value = wasActive ? '' : (btn.getAttribute('data-url') || '');",
717
+ " urlInput.dispatchEvent(new Event('input', { bubbles: true }));",
718
+ " }",
719
+ " var profileSelect = document.getElementById('add-profile');",
720
+ " if(profileSelect) {",
721
+ " if(!wasActive) { profileSelect.value = ''; profileSelect.disabled = true; }",
722
+ " else { profileSelect.disabled = false; }",
723
+ " }",
724
+ " if(!wasActive) {",
725
+ " var advanced = document.getElementById('add-advanced');",
726
+ " var toggle = advanced ? advanced.previousElementSibling : null;",
727
+ " if(advanced && !advanced.classList.contains('show')) {",
728
+ " advanced.classList.add('show');",
729
+ " if(toggle) toggle.textContent = 'Hide Advanced Options';",
730
+ " }",
731
+ " }",
732
+ " };",
733
+ // Hide the add form and reset all fields and provider pill selection.
753
734
  " window.hideAddForm = function() {",
754
735
  " var addForm = document.getElementById('add-channel-form');",
755
- " var addBtn = document.getElementById('add-channel-btn');",
756
736
  " if (addForm) addForm.style.display = 'none';",
757
- " if (addBtn) addBtn.style.display = 'inline-block';",
758
- " if (addForm) addForm.querySelector('form').reset();",
737
+ " if (addForm) {",
738
+ " addForm.querySelector('form').reset();",
739
+ " var pills = addForm.querySelectorAll('.provider-pill');",
740
+ " for(var i = 0; i < pills.length; i++) pills[i].classList.remove('active');",
741
+ " var profileSelect = addForm.querySelector('#add-profile');",
742
+ " if(profileSelect) profileSelect.disabled = false;",
743
+ " }",
759
744
  " };",
760
745
  // Insert or replace channel rows in the table. Always removes existing rows with the same key first (handles both edits and overrides of builtin channels).
761
746
  // Uses the current table sort field and direction for correct insertion order.
@@ -793,7 +778,7 @@ export function generateConfigSubtabScript() {
793
778
  " tbody.appendChild(newDisplayRow);",
794
779
  " if (newEditRow) tbody.appendChild(newEditRow);",
795
780
  " }",
796
- " updateDisabledCount();",
781
+ " processChannelLogos();",
797
782
  " };",
798
783
  // Remove channel rows from the table.
799
784
  " window.removeChannelRow = function(key) {",
@@ -801,7 +786,6 @@ export function generateConfigSubtabScript() {
801
786
  " var editRow = document.getElementById('edit-row-' + key);",
802
787
  " if (displayRow) displayRow.remove();",
803
788
  " if (editRow) editRow.remove();",
804
- " updateDisabledCount();",
805
789
  " };",
806
790
  // Advanced fields toggle for channel forms.
807
791
  " window.toggleAdvanced = function(prefix) {",
@@ -824,84 +808,153 @@ export function generateConfigSubtabScript() {
824
808
  " ref.style.display = ref.style.display === 'none' ? 'block' : 'none';",
825
809
  " }",
826
810
  " };",
811
+ // Inline cell editing for channel number and station ID. Clicking an editable cell replaces its content with a text input. Enter or blur saves the value
812
+ // via the existing channel POST endpoint. Escape cancels and restores the original value. The cell's data attributes carry the field name, channel key, and
813
+ // current value so the handler is generic across both fields.
814
+ " window.startInlineEdit = function(td) {",
815
+ " if(td.querySelector('input.inline-edit')) return;",
816
+ " var field = td.getAttribute('data-field');",
817
+ " var key = td.getAttribute('data-key');",
818
+ " var value = td.getAttribute('data-value') || '';",
819
+ " var original = td.innerHTML;",
820
+ " var input = document.createElement('input');",
821
+ " input.type = (field === 'channelNumber') ? 'number' : 'text';",
822
+ " input.className = 'inline-edit';",
823
+ " input.value = value;",
824
+ " td.innerHTML = '';",
825
+ " td.appendChild(input);",
826
+ " input.focus();",
827
+ " input.select();",
828
+ " var saving = false;",
829
+ " async function save() {",
830
+ " if(saving) return;",
831
+ " saving = true;",
832
+ " var newValue = input.value.trim();",
833
+ " if(newValue === value) { cancel(); return; }",
834
+ " var data = { action: 'inline-edit', field: field, key: key, value: newValue };",
835
+ " try {",
836
+ " var res = await fetch('/config/channels', {",
837
+ " method: 'POST',",
838
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
839
+ " body: JSON.stringify(data)",
840
+ " });",
841
+ " var d = await res.json();",
842
+ " if(res.ok && d.success) {",
843
+ " if(d.patch) { applyChannelPatch(d.patch); }",
844
+ " } else {",
845
+ " td.innerHTML = original;",
846
+ " showToast(d.message || 'Failed to save.', 'error');",
847
+ " }",
848
+ " } catch(err) {",
849
+ " console.error('Inline edit error:', err);",
850
+ " td.innerHTML = original;",
851
+ " showToast('Failed to save: ' + err.message, 'error');",
852
+ " }",
853
+ " }",
854
+ " function cancel() { td.innerHTML = original; }",
855
+ " input.addEventListener('keydown', function(e) {",
856
+ " if(e.key === 'Enter') { e.preventDefault(); save(); }",
857
+ " else if(e.key === 'Escape') { cancel(); }",
858
+ " });",
859
+ " input.addEventListener('blur', function() { if(!saving) save(); });",
860
+ " };",
861
+ // Toggle HDHR lineup inclusion for a channel via inline checkbox. Uses the inline-edit endpoint with hdhrEnabled field.
862
+ " window.toggleHdhr = async function(checkbox) {",
863
+ " var key = checkbox.getAttribute('data-key');",
864
+ " var value = checkbox.checked ? 'true' : 'false';",
865
+ " try {",
866
+ " var res = await fetch('/config/channels', {",
867
+ " method: 'POST',",
868
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
869
+ " body: JSON.stringify({ action: 'inline-edit', field: 'hdhrEnabled', key: key, value: value })",
870
+ " });",
871
+ " var d = await res.json();",
872
+ " if(res.ok && d.success) {",
873
+ " if(d.patch) { applyChannelPatch(d.patch); }",
874
+ " } else {",
875
+ " checkbox.checked = !checkbox.checked;",
876
+ " showToast(d.message || 'Failed to update HDHR setting.', 'error');",
877
+ " }",
878
+ " } catch(err) {",
879
+ " checkbox.checked = !checkbox.checked;",
880
+ " showToast('Failed to update HDHR setting: ' + err.message, 'error');",
881
+ " }",
882
+ " };",
827
883
  // Submit channel form via AJAX (add or edit).
828
- " window.submitChannelForm = function(event, action) {",
884
+ " window.submitChannelForm = async function(event, action) {",
829
885
  " event.preventDefault();",
830
886
  " var form = event.target;",
831
887
  " var formData = new FormData(form);",
832
888
  " var data = {};",
833
889
  " for (var pair of formData.entries()) { data[pair[0]] = pair[1]; }",
834
- " fetch('/config/channels', {",
835
- " method: 'POST',",
836
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
837
- " body: JSON.stringify(data)",
838
- " })",
839
- " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
840
- " .then(function(result) {",
841
- " if (result.ok && result.data.success) {",
842
- " showToast(result.data.message, 'success');",
843
- " if (result.data.html) {",
844
- " insertChannelRow(result.data.html, result.data.key);",
845
- " refilterChannelRows();",
846
- " }",
890
+ " try {",
891
+ " var res = await fetch('/config/channels', {",
892
+ " method: 'POST',",
893
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
894
+ " body: JSON.stringify(data)",
895
+ " });",
896
+ " var d = await res.json();",
897
+ " if (res.ok && d.success) {",
898
+ " showToast(d.message, 'success');",
899
+ " if (d.patch) { applyChannelPatch(d.patch); }",
847
900
  " if (action === 'add') {",
848
901
  " hideAddForm();",
849
902
  " } else {",
850
- " hideEditForm(result.data.key);",
903
+ " hideEditForm(d.key);",
851
904
  " }",
852
- " } else if (result.data.errors) {",
905
+ " } else if (d.errors) {",
853
906
  " var errorMsgs = [];",
854
- " for (var field in result.data.errors) { errorMsgs.push(field + ': ' + result.data.errors[field]); }",
907
+ " for (var field in d.errors) { errorMsgs.push(field + ': ' + d.errors[field]); }",
855
908
  " showToast('Validation errors: ' + errorMsgs.join(', '), 'error');",
856
909
  " } else {",
857
- " showToast(result.data.message || 'Failed to save channel.', 'error');",
910
+ " showToast(d.message || 'Failed to save channel.', 'error');",
858
911
  " }",
859
- " })",
860
- " .catch(function(err) { showToast('Failed to save channel: ' + err.message, 'error'); });",
912
+ " } catch(err) {",
913
+ " console.error('Channel save error:', err);",
914
+ " showToast('Failed to save channel: ' + err.message, 'error');",
915
+ " }",
861
916
  " return false;",
862
917
  " };",
863
918
  // Delete channel via AJAX.
864
- " window.deleteChannel = function(key) {",
919
+ " window.deleteChannel = async function(key) {",
865
920
  " if (!confirm('Delete channel ' + key + '?')) return;",
866
- " fetch('/config/channels', {",
867
- " method: 'POST',",
868
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
869
- " body: JSON.stringify({ action: 'delete', key: key })",
870
- " })",
871
- " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
872
- " .then(function(result) {",
873
- " if (result.ok && result.data.success) {",
874
- " showToast(result.data.message, 'success');",
875
- " if (result.data.html) {",
876
- " insertChannelRow(result.data.html, result.data.key || key);",
877
- " refilterChannelRows();",
878
- " } else {",
879
- " removeChannelRow(result.data.key || key);",
880
- " }",
921
+ " try {",
922
+ " var res = await fetch('/config/channels', {",
923
+ " method: 'POST',",
924
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
925
+ " body: JSON.stringify({ action: 'delete', key: key })",
926
+ " });",
927
+ " var d = await res.json();",
928
+ " if (res.ok && d.success) {",
929
+ " showToast(d.message, 'success');",
930
+ " if (d.patch) { applyChannelPatch(d.patch); }",
881
931
  " } else {",
882
- " showToast(result.data.message || 'Failed to delete channel.', 'error');",
932
+ " showToast(d.message || 'Failed to delete channel.', 'error');",
883
933
  " }",
884
- " })",
885
- " .catch(function(err) { showToast('Failed to delete channel: ' + err.message, 'error'); });",
934
+ " } catch(err) {",
935
+ " console.error('Channel delete error:', err);",
936
+ " showToast('Failed to delete channel: ' + err.message, 'error');",
937
+ " }",
886
938
  " };",
887
939
  // Toggle a single predefined channel's enabled/disabled state.
888
- " window.togglePredefinedChannel = function(key, enable) {",
889
- " fetch('/config/channels/toggle-predefined', {",
890
- " method: 'POST',",
891
- " headers: { 'Content-Type': 'application/json' },",
892
- " body: JSON.stringify({ key: key, enabled: enable })",
893
- " })",
894
- " .then(function(response) { return response.json(); })",
895
- " .then(function(result) {",
940
+ " window.togglePredefinedChannel = async function(key, enable) {",
941
+ " try {",
942
+ " var res = await fetch('/config/channels/toggle-predefined', {",
943
+ " method: 'POST',",
944
+ " headers: { 'Content-Type': 'application/json' },",
945
+ " body: JSON.stringify({ key: key, enabled: enable })",
946
+ " });",
947
+ " var result = await res.json();",
896
948
  " if (result.success) {",
897
949
  " showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
898
- " updateChannelRowDisabledState(key, !enable);",
899
- " if(result.counts) { updateScopeToggles(result.counts); }",
950
+ " if(result.patch) { applyChannelPatch(result.patch); }",
900
951
  " } else {",
901
952
  " showToast(result.error || 'Failed to toggle channel.', 'error');",
902
953
  " }",
903
- " })",
904
- " .catch(function(err) { showToast('Failed to toggle channel: ' + err.message, 'error'); });",
954
+ " } catch(err) {",
955
+ " console.error('Channel toggle error:', err);",
956
+ " showToast('Failed to toggle channel: ' + err.message, 'error');",
957
+ " }",
905
958
  " };",
906
959
  // Update a channel row's provider selection in-place. Syncs the HTML selected attribute so filterChannelRows() restore logic works correctly. We iterate
907
960
  // _allOptions (if present) rather than querySelectorAll because filtered-out options are removed from the DOM but still tracked in the array.
@@ -918,186 +971,150 @@ export function generateConfigSubtabScript() {
918
971
  " }",
919
972
  " }",
920
973
  // Update provider selection for a multi-provider channel.
921
- " window.updateProviderSelection = function(selectElement) {",
974
+ " window.updateProviderSelection = async function(selectElement) {",
922
975
  " var channelKey = selectElement.getAttribute('data-channel');",
923
976
  " var providerKey = selectElement.value;",
924
- " fetch('/config/provider', {",
925
- " method: 'POST',",
926
- " headers: { 'Content-Type': 'application/json' },",
927
- " body: JSON.stringify({ channel: channelKey, provider: providerKey })",
928
- " })",
929
- " .then(function(response) { return response.json(); })",
930
- " .then(function(result) {",
977
+ " try {",
978
+ " var res = await fetch('/config/provider', {",
979
+ " method: 'POST',",
980
+ " headers: { 'Content-Type': 'application/json' },",
981
+ " body: JSON.stringify({ channel: channelKey, provider: providerKey })",
982
+ " });",
983
+ " var result = await res.json();",
931
984
  " if (result.success) {",
932
985
  " showToast('Provider updated. New streams will use the selected provider.', 'success');",
933
- " if (result.html) { insertChannelRow(result.html, channelKey); refilterChannelRows(); }",
986
+ " if (result.patch) { applyChannelPatch(result.patch); }",
934
987
  " } else {",
935
988
  " showToast(result.error || 'Failed to update provider.', 'error');",
936
989
  " }",
937
- " })",
938
- " .catch(function(err) { showToast('Failed to update provider: ' + err.message, 'error'); });",
990
+ " } catch(err) {",
991
+ " console.error('Provider update error:', err);",
992
+ " showToast('Failed to update provider: ' + err.message, 'error');",
993
+ " }",
939
994
  " };",
940
995
  // Bulk toggle predefined channels by scope (all, pacific, east).
941
- " window.bulkTogglePredefined = function(enable, scope) {",
942
- " fetch('/config/channels/bulk-toggle-predefined', {",
943
- " method: 'POST',",
944
- " headers: { 'Content-Type': 'application/json' },",
945
- " body: JSON.stringify({ enabled: enable, scope: scope })",
946
- " })",
947
- " .then(function(response) { return response.json(); })",
948
- " .then(function(result) {",
996
+ " window.bulkTogglePredefined = async function(enable, scope) {",
997
+ " try {",
998
+ " var res = await fetch('/config/channels/bulk-toggle-predefined', {",
999
+ " method: 'POST',",
1000
+ " headers: { 'Content-Type': 'application/json' },",
1001
+ " body: JSON.stringify({ enabled: enable, scope: scope })",
1002
+ " });",
1003
+ " var result = await res.json();",
949
1004
  " if (result.success) {",
950
1005
  " var labels = { all: 'All predefined', pacific: 'Pacific predefined', east: 'East predefined' };",
951
1006
  " showToast((labels[scope] || 'Predefined') + ' channels ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
952
- " if (result.keys) {",
953
- " for (var i = 0; i < result.keys.length; i++) { setRowDisabledState(result.keys[i], !enable); }",
954
- " }",
955
- " updateDisabledCount();",
956
- " if(result.counts) { updateScopeToggles(result.counts); }",
1007
+ " if (result.patch) { applyChannelPatch(result.patch); }",
957
1008
  " } else {",
958
1009
  " showToast(result.error || 'Failed to toggle channels.', 'error');",
959
1010
  " }",
960
- " })",
961
- " .catch(function(err) { showToast('Failed to toggle channels: ' + err.message, 'error'); });",
1011
+ " } catch(err) {",
1012
+ " console.error('Bulk toggle error:', err);",
1013
+ " showToast('Failed to toggle channels: ' + err.message, 'error');",
1014
+ " }",
962
1015
  " };",
963
- // Update scope toggle checkboxes and count labels from server-provided counts. Pure renderer — no client-side arithmetic.
964
- " function updateScopeToggles(counts) {",
965
- " var scopes = ['all', 'east', 'pacific'];",
966
- " for(var i = 0; i < scopes.length; i++) {",
967
- " var s = scopes[i];",
968
- " if(!counts[s]) continue;",
969
- " var cb = document.querySelector('.scope-toggle[data-scope=\"' + s + '\"]');",
970
- " var span = document.querySelector('.quick-action-count[data-scope=\"' + s + '\"]');",
971
- " if(cb) {",
972
- " cb.checked = (counts[s].enabled === counts[s].total);",
973
- " cb.indeterminate = (counts[s].enabled > 0) && (counts[s].enabled < counts[s].total);",
1016
+ // Auto-number visible channels sequentially based on the current sort order. Reads the starting number from the input in the Quick Actions dropdown. When
1017
+ // the input is empty, clears all channel numbers from visible channels instead of assigning them.
1018
+ " window.autoNumberChannels = async function() {",
1019
+ " var startInput = document.getElementById('auto-number-start');",
1020
+ " var raw = startInput ? startInput.value.trim() : '1';",
1021
+ " var clear = (raw === '');",
1022
+ " var start = clear ? 0 : parseInt(raw, 10);",
1023
+ " if(!clear && (isNaN(start) || (start < 1))) start = 1;",
1024
+ " var table = document.querySelector('.channel-table');",
1025
+ " var sortField = table ? (table.getAttribute('data-sort-field') || 'name') : 'name';",
1026
+ " var sortDir = table ? (table.getAttribute('data-sort-dir') || 'asc') : 'asc';",
1027
+ " var msg = clear ? 'Clear channel numbers from all visible channels?' " +
1028
+ ": 'Assign channel numbers starting from ' + start + ' to all visible channels in the current sort order? This will overwrite existing numbers.';",
1029
+ " if(!confirm(msg)) return;",
1030
+ " try {",
1031
+ " var res = await fetch('/config/channels/auto-number', {",
1032
+ " method: 'POST',",
1033
+ " headers: { 'Content-Type': 'application/json' },",
1034
+ " body: JSON.stringify({ start: start, sortField: sortField, sortDirection: sortDir })",
1035
+ " });",
1036
+ " var result = await res.json();",
1037
+ " if(result.success) {",
1038
+ " showToast(result.message || 'Channels numbered.', 'success');",
1039
+ " if(result.patch) { applyChannelPatch(result.patch); }",
1040
+ " } else {",
1041
+ " showToast(result.error || 'Failed to auto-number channels.', 'error');",
974
1042
  " }",
975
- " if(span) { span.textContent = counts[s].enabled + ' of ' + counts[s].total + ' enabled'; }",
1043
+ " } catch(err) {",
1044
+ " console.error('Auto-number error:', err);",
1045
+ " showToast('Failed to auto-number channels: ' + err.message, 'error');",
976
1046
  " }",
977
- " }",
978
- // SVG icon strings for dynamic DOM manipulation when toggling button states.
979
- " var ICON_LOGIN_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
980
- "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\"/>" +
981
- "<path d=\"M13.5 8H6.5\"/></svg>';",
982
- " var ICON_ENABLE_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
983
- "stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>';",
984
- " var ICON_DISABLE_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
985
- "stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>';",
1047
+ " };",
1048
+ // Bulk toggle HDHR lineup inclusion for all visible channels. The tri-state checkbox calls this on click: from checked (all enabled) it disables all; from
1049
+ // unchecked or indeterminate it enables all.
1050
+ " window.bulkToggleHdhr = async function() {",
1051
+ " var toggle = document.getElementById('hdhr-bulk-toggle');",
1052
+ " if(!toggle) return;",
1053
+ " var enable = !toggle.checked;",
1054
+ " var msg = enable ? 'Include all visible channels in the HDHomeRun/Plex lineup?' : 'Exclude all visible channels from the HDHomeRun/Plex lineup?';",
1055
+ " if(!confirm(msg)) return;",
1056
+ " try {",
1057
+ " var res = await fetch('/config/channels/hdhr-bulk', {",
1058
+ " method: 'POST',",
1059
+ " headers: { 'Content-Type': 'application/json' },",
1060
+ " body: JSON.stringify({ enable: enable })",
1061
+ " });",
1062
+ " var result = await res.json();",
1063
+ " if(result.success) {",
1064
+ " showToast(result.message || 'HDHR settings updated.', 'success');",
1065
+ " if(result.patch) { applyChannelPatch(result.patch); }",
1066
+ " } else {",
1067
+ " showToast(result.error || 'Failed to update HDHR settings.', 'error');",
1068
+ " }",
1069
+ " } catch(err) {",
1070
+ " console.error('HDHR bulk toggle error:', err);",
1071
+ " showToast('Failed to update HDHR settings: ' + err.message, 'error');",
1072
+ " }",
1073
+ " };",
986
1074
  // Revert a channel override back to predefined defaults.
987
- " window.revertChannel = function(key) {",
1075
+ " window.revertChannel = async function(key) {",
988
1076
  " if (!confirm('Revert channel ' + key + ' to predefined defaults?')) return;",
989
- " fetch('/config/channels', {",
990
- " method: 'POST',",
991
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
992
- " body: JSON.stringify({ action: 'revert', key: key })",
993
- " })",
994
- " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
995
- " .then(function(result) {",
996
- " if (result.ok && result.data.success) {",
997
- " showToast(result.data.message, 'success');",
998
- " if (result.data.html) {",
999
- " insertChannelRow(result.data.html, result.data.key || key);",
1077
+ " try {",
1078
+ " var res = await fetch('/config/channels', {",
1079
+ " method: 'POST',",
1080
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
1081
+ " body: JSON.stringify({ action: 'revert', key: key })",
1082
+ " });",
1083
+ " var d = await res.json();",
1084
+ " if (res.ok && d.success) {",
1085
+ " showToast(d.message, 'success');",
1086
+ " if (d.html) {",
1087
+ " insertChannelRow(d.html, d.key || key);",
1000
1088
  " refilterChannelRows();",
1001
1089
  " }",
1002
1090
  " } else {",
1003
- " showToast(result.data.message || 'Failed to revert channel.', 'error');",
1004
- " }",
1005
- " })",
1006
- " .catch(function(err) { showToast('Failed to revert channel: ' + err.message, 'error'); });",
1007
- " };",
1008
- // Set a channel row's disabled state without triggering count updates. Uses icon buttons. Used by bulkTogglePredefined for efficient bulk updates.
1009
- " function setRowDisabledState(key, disabled) {",
1010
- " var row = document.getElementById('display-row-' + key);",
1011
- " if (!row) return;",
1012
- " var btnGroup = row.querySelector('.btn-group');",
1013
- " if (!btnGroup) return;",
1014
- " if (disabled) {",
1015
- " row.classList.add('channel-disabled');",
1016
- " row.classList.remove('user-channel');",
1017
- // Replace login icon with placeholder.
1018
- " var loginBtn = btnGroup.querySelector('.btn-icon-login');",
1019
- " if (loginBtn) {",
1020
- " var placeholder = document.createElement('span');",
1021
- " placeholder.className = 'btn-icon-placeholder';",
1022
- " loginBtn.replaceWith(placeholder);",
1023
- " }",
1024
- // Swap disable icon to enable icon.
1025
- " var disableBtn = btnGroup.querySelector('.btn-icon-disable');",
1026
- " if (disableBtn) {",
1027
- " disableBtn.className = 'btn-icon btn-icon-enable';",
1028
- " disableBtn.title = 'Enable';",
1029
- " disableBtn.setAttribute('aria-label', 'Enable');",
1030
- " disableBtn.innerHTML = ICON_ENABLE_SVG;",
1031
- " disableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', true)\");",
1032
- " }",
1033
- " } else {",
1034
- " row.classList.remove('channel-disabled');",
1035
- // Swap enable icon to disable icon.
1036
- " var enableBtn = btnGroup.querySelector('.btn-icon-enable');",
1037
- " if (enableBtn) {",
1038
- // Replace placeholder with login icon button.
1039
- " var placeholder = btnGroup.querySelector('.btn-icon-placeholder');",
1040
- " if (placeholder) {",
1041
- " var newLoginBtn = document.createElement('button');",
1042
- " newLoginBtn.type = 'button';",
1043
- " newLoginBtn.className = 'btn-icon btn-icon-login';",
1044
- " newLoginBtn.title = 'Login';",
1045
- " newLoginBtn.setAttribute('aria-label', 'Login');",
1046
- " newLoginBtn.innerHTML = ICON_LOGIN_SVG;",
1047
- " newLoginBtn.setAttribute('onclick', \"startChannelLogin('\" + key + \"')\");",
1048
- " placeholder.replaceWith(newLoginBtn);",
1049
- " }",
1050
- " enableBtn.className = 'btn-icon btn-icon-disable';",
1051
- " enableBtn.title = 'Disable';",
1052
- " enableBtn.setAttribute('aria-label', 'Disable');",
1053
- " enableBtn.innerHTML = ICON_DISABLE_SVG;",
1054
- " enableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', false)\");",
1091
+ " showToast(d.message || 'Failed to revert channel.', 'error');",
1055
1092
  " }",
1093
+ " } catch(err) {",
1094
+ " showToast('Failed to revert channel: ' + err.message, 'error');",
1056
1095
  " }",
1057
- " }",
1058
- // Update a single channel row's disabled state and refresh counts. Used by individual togglePredefinedChannel.
1059
- " function updateChannelRowDisabledState(key, disabled) {",
1060
- " setRowDisabledState(key, disabled);",
1061
- " updateDisabledCount();",
1062
- " };",
1063
- // Update the channel summary counts (total, enabled, disabled, predefined/user breakdown) shown above the channel table.
1064
- " function updateDisabledCount() {",
1065
- " var totalRows = document.querySelectorAll('.channel-table tbody tr[id^=\"display-row-\"]').length;",
1066
- " var hiddenRows = document.querySelectorAll('tr.channel-disabled:not(.user-channel), tr.channel-unavailable');",
1067
- " var disabledCount = hiddenRows.length;",
1068
- " var enabledCount = totalRows - disabledCount;",
1069
- " var userRows = document.querySelectorAll('.channel-table tbody tr.user-channel[id^=\"display-row-\"]').length;",
1070
- " var predefinedRows = totalRows - userRows;",
1071
- " var totalEl = document.getElementById('total-count');",
1072
- " var enabledEl = document.getElementById('enabled-count');",
1073
- " var disabledEl = document.getElementById('disabled-count');",
1074
- " var predefinedEl = document.getElementById('predefined-count');",
1075
- " var userEl = document.getElementById('user-count');",
1076
- " if (totalEl) totalEl.textContent = String(totalRows);",
1077
- " if (enabledEl) enabledEl.textContent = String(enabledCount);",
1078
- " if (disabledEl) disabledEl.textContent = String(disabledCount);",
1079
- " if (predefinedEl) predefinedEl.textContent = String(predefinedRows);",
1080
- " if (userEl) userEl.textContent = (userRows > 0) ? ', ' + String(userRows) + ' user' : '';",
1081
1096
  " };",
1082
1097
  // Save the provider filter to the server and update the UI on success.
1083
- " function saveProviderFilter(enabledTags) {",
1084
- " fetch('/config/provider-filter', {",
1085
- " method: 'POST',",
1086
- " headers: { 'Content-Type': 'application/json' },",
1087
- " body: JSON.stringify({ enabledProviders: enabledTags })",
1088
- " })",
1089
- " .then(function(r) { return r.json(); })",
1090
- " .then(function(result) {",
1098
+ " async function saveProviderFilter(enabledTags) {",
1099
+ " try {",
1100
+ " var r = await fetch('/config/provider-filter', {",
1101
+ " method: 'POST',",
1102
+ " headers: { 'Content-Type': 'application/json' },",
1103
+ " body: JSON.stringify({ enabledProviders: enabledTags })",
1104
+ " });",
1105
+ " var result = await r.json();",
1091
1106
  " if (result.success) {",
1092
1107
  " updateProviderChips(enabledTags);",
1093
1108
  " filterChannelRows(enabledTags);",
1094
1109
  " updateBulkAssignOptions(enabledTags);",
1095
1110
  " updateProviderFilterButton(enabledTags);",
1096
- " updateDisabledCount();",
1111
+ " if(result.patch) { applyChannelPatch(result.patch); }",
1097
1112
  " showToast('Provider filter updated.' + PLAYLIST_HINT, 'success');",
1098
1113
  " }",
1099
- " })",
1100
- " .catch(function(err) { showToast('Failed to update filter: ' + err.message, 'error'); });",
1114
+ " } catch(err) {",
1115
+ " console.error('Provider filter error:', err);",
1116
+ " showToast('Failed to update filter: ' + err.message, 'error');",
1117
+ " }",
1101
1118
  " };",
1102
1119
  // Provider filter: toggle a provider tag on/off.
1103
1120
  " window.toggleProviderTag = function(checkbox) {",
@@ -1149,17 +1166,30 @@ export function generateConfigSubtabScript() {
1149
1166
  " var tag = enabledTags[i];",
1150
1167
  " if (tag === 'direct') continue;",
1151
1168
  " var label = tag;",
1169
+ " var chipDomain = '';",
1170
+ " var chipIconUrl = '';",
1152
1171
  " if (menu) {",
1153
1172
  " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1154
- " if (cb && cb.parentElement) label = cb.parentElement.textContent.trim();",
1173
+ " if (cb && cb.parentElement) {",
1174
+ " label = cb.parentElement.textContent.trim();",
1175
+ " var pd = cb.parentElement.querySelector('.provider-display');",
1176
+ " if (pd) {",
1177
+ " chipDomain = pd.getAttribute('data-domain') || '';",
1178
+ " chipIconUrl = pd.getAttribute('data-icon-url') || '';",
1179
+ " }",
1180
+ " }",
1155
1181
  " }",
1156
1182
  " var chip = document.createElement('span');",
1157
1183
  " chip.className = 'provider-chip';",
1158
1184
  " chip.setAttribute('data-tag', tag);",
1159
- " chip.innerHTML = label + '<button type=\"button\" class=\"chip-close\" aria-label=\"Remove ' + label +",
1185
+ " var domainAttr = chipDomain ? ' data-domain=\"' + chipDomain + '\"' : '';",
1186
+ " var iconAttr = chipIconUrl ? ' data-icon-url=\"' + chipIconUrl + '\"' : '';",
1187
+ " chip.innerHTML = '<span class=\"provider-display\"' + domainAttr + iconAttr + ' data-sm>' + label + '</span>' +",
1188
+ " '<button type=\"button\" class=\"chip-close\" aria-label=\"Remove ' + label +",
1160
1189
  " '\" onclick=\"removeProviderChip(\\'' + tag + '\\')\">\\u00d7</button>';",
1161
1190
  " container.appendChild(chip);",
1162
1191
  " }",
1192
+ " processProviderDisplays();",
1163
1193
  " };",
1164
1194
  // Filter channel rows based on enabled provider tags. Toggles the channel-unavailable class on each row and updates Source column content. Filtered-out options
1165
1195
  // are removed from the DOM entirely rather than hidden — Safari ignores both the hidden attribute and display:none on option elements because they are rendered by
@@ -1222,25 +1252,28 @@ export function generateConfigSubtabScript() {
1222
1252
  " if (allChecked) { enabledTags = []; }",
1223
1253
  " if (enabledTags.length > 0) { filterChannelRows(enabledTags); }",
1224
1254
  " }",
1225
- // Update bulk assign items in the Quick Actions menu to only show enabled providers. Toggles display on .bulk-assign-item elements based on the active filter.
1255
+ // Update bulk assign options in the Quick Actions select to only show enabled providers. Toggles the hidden attribute on option elements based on the filter.
1226
1256
  " function updateBulkAssignOptions(enabledTags) {",
1227
- " var items = document.querySelectorAll('.bulk-assign-item');",
1228
- " for (var i = 0; i < items.length; i++) {",
1229
- " var tag = items[i].getAttribute('data-provider-tag');",
1257
+ " var select = document.getElementById('bulk-assign-select');",
1258
+ " if(!select) return;",
1259
+ " var options = select.querySelectorAll('option[data-provider-tag]');",
1260
+ " for(var i = 0; i < options.length; i++) {",
1261
+ " var tag = options[i].getAttribute('data-provider-tag');",
1230
1262
  " var show = (enabledTags.length === 0) || (tag === 'direct') || (enabledTags.indexOf(tag) !== -1);",
1231
- " items[i].style.display = show ? '' : 'none';",
1263
+ " if(show) options[i].removeAttribute('hidden');",
1264
+ " else options[i].setAttribute('hidden', '');",
1232
1265
  " }",
1233
1266
  " };",
1234
1267
  // Bulk assign all channels to a specific provider. Updates all dropdowns and profile cells in-place.
1235
- " window.bulkAssignProvider = function(providerTag) {",
1268
+ " window.bulkAssignProvider = async function(providerTag) {",
1236
1269
  " if (!providerTag) return;",
1237
- " fetch('/config/provider-bulk-assign', {",
1238
- " method: 'POST',",
1239
- " headers: { 'Content-Type': 'application/json' },",
1240
- " body: JSON.stringify({ provider: providerTag })",
1241
- " })",
1242
- " .then(function(r) { return r.json(); })",
1243
- " .then(function(result) {",
1270
+ " try {",
1271
+ " var r = await fetch('/config/provider-bulk-assign', {",
1272
+ " method: 'POST',",
1273
+ " headers: { 'Content-Type': 'application/json' },",
1274
+ " body: JSON.stringify({ provider: providerTag })",
1275
+ " });",
1276
+ " var result = await r.json();",
1244
1277
  " if (result.success) {",
1245
1278
  " var msg = result.affected + ' of ' + result.total + ' channel(s) updated.';",
1246
1279
  " var undoAction = null;",
@@ -1258,18 +1291,19 @@ export function generateConfigSubtabScript() {
1258
1291
  " } else {",
1259
1292
  " showToast(result.error || 'Failed to assign.', 'error');",
1260
1293
  " }",
1261
- " })",
1262
- " .catch(function(err) { showToast('Failed to assign: ' + err.message, 'error'); });",
1294
+ " } catch(err) {",
1295
+ " showToast('Failed to assign: ' + err.message, 'error');",
1296
+ " }",
1263
1297
  " };",
1264
1298
  // Restore previous provider selections (undo bulk assign). Sends the previousSelections map to the server and updates the UI with the restored selections.
1265
- " function restoreBulkProviders(prevSelections) {",
1266
- " fetch('/config/provider-bulk-restore', {",
1267
- " method: 'POST',",
1268
- " headers: { 'Content-Type': 'application/json' },",
1269
- " body: JSON.stringify({ selections: prevSelections })",
1270
- " })",
1271
- " .then(function(r) { return r.json(); })",
1272
- " .then(function(result) {",
1299
+ " async function restoreBulkProviders(prevSelections) {",
1300
+ " try {",
1301
+ " var r = await fetch('/config/provider-bulk-restore', {",
1302
+ " method: 'POST',",
1303
+ " headers: { 'Content-Type': 'application/json' },",
1304
+ " body: JSON.stringify({ selections: prevSelections })",
1305
+ " });",
1306
+ " var result = await r.json();",
1273
1307
  " if (result.success) {",
1274
1308
  " showToast('Bulk assign reverted.', 'success');",
1275
1309
  " if (result.selections) {",
@@ -1281,45 +1315,10 @@ export function generateConfigSubtabScript() {
1281
1315
  " } else {",
1282
1316
  " showToast(result.error || 'Failed to revert.', 'error');",
1283
1317
  " }",
1284
- " })",
1285
- " .catch(function(err) { showToast('Failed to revert: ' + err.message, 'error'); });",
1318
+ " } catch(err) {",
1319
+ " showToast('Failed to revert: ' + err.message, 'error');",
1320
+ " }",
1286
1321
  " }",
1287
- // Close all open dropdown menus and remove the scroll and resize listeners.
1288
- " function closeDropdowns() {",
1289
- " var menus = document.querySelectorAll('.dropdown-menu.show');",
1290
- " for (var i = 0; i < menus.length; i++) menus[i].classList.remove('show');",
1291
- " window.removeEventListener('scroll', closeDropdowns, true);",
1292
- " window.removeEventListener('resize', closeDropdowns);",
1293
- " };",
1294
- " window.closeDropdowns = closeDropdowns;",
1295
- // Toggle a dropdown menu open or closed. On first call for a given button, the menu is detached from its .dropdown parent and portaled to <body> with
1296
- // position: fixed. This allows the menu to escape overflow: auto containers like the channel table wrapper. On every open, the menu is positioned relative
1297
- // to the button's bounding rect, with edge-of-viewport clamping and above-button flip when it would extend below the viewport.
1298
- " window.toggleDropdown = function(btn) {",
1299
- " var menu = btn._portalMenu;",
1300
- " var isOpen = menu && menu.classList.contains('show');",
1301
- " closeDropdowns();",
1302
- " if (isOpen) return;",
1303
- " if (!menu) {",
1304
- " menu = btn.nextElementSibling;",
1305
- " if (!menu) return;",
1306
- " btn._portalMenu = menu;",
1307
- " document.body.appendChild(menu);",
1308
- " menu.style.position = 'fixed';",
1309
- " menu.style.marginTop = '0';",
1310
- " }",
1311
- " menu.classList.add('show');",
1312
- " var rect = btn.getBoundingClientRect();",
1313
- " var top = rect.bottom + 2;",
1314
- " var left = rect.left;",
1315
- " if (left + menu.offsetWidth > window.innerWidth - 4) left = rect.right - menu.offsetWidth;",
1316
- " if (left < 4) left = 4;",
1317
- " if (top + menu.offsetHeight > window.innerHeight - 4) top = rect.top - menu.offsetHeight - 2;",
1318
- " menu.style.top = top + 'px';",
1319
- " menu.style.left = left + 'px';",
1320
- " window.addEventListener('scroll', closeDropdowns, true);",
1321
- " window.addEventListener('resize', closeDropdowns);",
1322
- " };",
1323
1322
  // Close dropdowns when clicking outside. After portaling, menus live on <body> outside .dropdown containers, so we also check .dropdown-menu to prevent
1324
1323
  // clicks on menu items (like column picker checkboxes) from triggering close.
1325
1324
  " document.addEventListener('click', function(e) {",
@@ -1327,12 +1326,16 @@ export function generateConfigSubtabScript() {
1327
1326
  " });",
1328
1327
  // 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
1329
1328
  // available (secure contexts), falling back to execCommand for plain HTTP access via IP address.
1330
- " window.copyStreamUrl = function(type, key) {",
1329
+ " window.copyStreamUrl = async function(type, key) {",
1331
1330
  " closeDropdowns();",
1332
1331
  " var url = (type === 'hls') ? (location.origin + '/hls/' + key + '/stream.m3u8') : (location.origin + '/stream/' + key);",
1333
1332
  " if (navigator.clipboard && navigator.clipboard.writeText) {",
1334
- " navigator.clipboard.writeText(url).then(function() { showToast('Stream URL copied to clipboard.', 'success'); })",
1335
- " .catch(function() { showToast('Failed to copy URL.', 'error'); });",
1333
+ " try {",
1334
+ " await navigator.clipboard.writeText(url);",
1335
+ " showToast('Stream URL copied to clipboard.', 'success');",
1336
+ " } catch(err) {",
1337
+ " showToast('Failed to copy URL.', 'error');",
1338
+ " }",
1336
1339
  " } else {",
1337
1340
  " var ta = document.createElement('textarea');",
1338
1341
  " ta.value = url;",
@@ -1375,6 +1378,18 @@ export function generateConfigSubtabScript() {
1375
1378
  " if (cbs[i].checked) cols.push(cbs[i].getAttribute('data-col-field'));",
1376
1379
  " }",
1377
1380
  " }",
1381
+ // Show or hide Quick Action sections gated on column visibility.
1382
+ " var colField = checkbox.getAttribute('data-col-field');",
1383
+ " var qaMap = { channelNumber: 'quick-action-autonumber', hdhrEnabled: 'quick-action-hdhr' };",
1384
+ " var qaId = qaMap[colField];",
1385
+ " if(qaId) {",
1386
+ " var qaEl = document.getElementById(qaId);",
1387
+ " if(qaEl) qaEl.style.display = checkbox.checked ? '' : 'none';",
1388
+ " var divider = document.getElementById('quick-action-divider');",
1389
+ " var anyVisible = document.getElementById('quick-action-autonumber')?.style.display !== 'none' ||",
1390
+ " document.getElementById('quick-action-hdhr')?.style.display !== 'none';",
1391
+ " if(divider) divider.style.display = anyVisible ? '' : 'none';",
1392
+ " }",
1378
1393
  " fetch('/config/channels/display-prefs', {",
1379
1394
  " method: 'POST', headers: { 'Content-Type': 'application/json' },",
1380
1395
  " body: JSON.stringify({ visibleColumns: cols })",
@@ -1382,7 +1397,7 @@ export function generateConfigSubtabScript() {
1382
1397
  " };",
1383
1398
  // Read the server-stamped sort value from the data-sort-value attribute.
1384
1399
  " function getSortValue(row, field) {",
1385
- " var colIndex = { key: 0, name: 1, provider: 2, channelNumber: 3, stationId: 4, profile: 5, channelSelector: 6 };",
1400
+ " var colIndex = { key: 0, name: 1, provider: 2, channelNumber: 3, hdhrEnabled: 4, stationId: 5, profile: 6, channelSelector: 7 };",
1386
1401
  " var idx = colIndex[field];",
1387
1402
  " if (idx === undefined) return '';",
1388
1403
  " var cell = row.children[idx];",
@@ -1504,9 +1519,10 @@ export function generateConfigSubtabScript() {
1504
1519
  " }",
1505
1520
  " if (!slug) return;",
1506
1521
  " var snapshotUrl = urlInput.value;",
1507
- " fetch('/providers/' + slug + '/channels')",
1508
- " .then(function(r) { return r.ok ? r.json() : null; })",
1509
- " .then(function(channels) {",
1522
+ " (async function() {",
1523
+ " try {",
1524
+ " var r = await fetch('/providers/' + slug + '/channels');",
1525
+ " var channels = r.ok ? await r.json() : null;",
1510
1526
  " if (!channels || urlInput.value !== snapshotUrl) return;",
1511
1527
  " var existing = {};",
1512
1528
  " var opts = datalist.querySelectorAll('option');",
@@ -1519,8 +1535,8 @@ export function generateConfigSubtabScript() {
1519
1535
  " datalist.appendChild(o);",
1520
1536
  " }",
1521
1537
  " }",
1522
- " })",
1523
- " .catch(function() {});",
1538
+ " } catch(err) {}",
1539
+ " })();",
1524
1540
  " };",
1525
1541
  // Initialize disabled channel toggle, provider filter, URL input listeners, and pending toast on page load.
1526
1542
  " (function() {",
@@ -1536,17 +1552,21 @@ export function generateConfigSubtabScript() {
1536
1552
  " if (table) table.classList.remove('hide-disabled');",
1537
1553
  " if (checkbox) checkbox.checked = true;",
1538
1554
  " }",
1539
- // Set indeterminate state on scope toggle checkboxes. HTML has no indeterminate attribute — it must be set via JS on page load.
1540
- " var scopeToggles = document.querySelectorAll('.scope-toggle');",
1541
- " for(var si = 0; si < scopeToggles.length; si++) {",
1542
- " var st = scopeToggles[si];",
1543
- " var total = parseInt(st.getAttribute('data-total'), 10);",
1544
- " var span = document.querySelector('.quick-action-count[data-scope=\"' + st.getAttribute('data-scope') + '\"]');",
1545
- " if(span && total > 0) {",
1546
- " var count = parseInt(span.textContent, 10);",
1547
- " if(!isNaN(count) && (count > 0) && (count < total)) { st.indeterminate = true; }",
1555
+ // Set indeterminate state on scope toggle checkboxes. HTML has no indeterminate attribute — it must be set via JS on page load. The enabled and total values
1556
+ // are read from data attributes on the quick-action-count span, not from the display text — this decouples checkbox state from the text format.
1557
+ " var scopeSpans = document.querySelectorAll('.quick-action-count[data-enabled][data-total]');",
1558
+ " for(var si = 0; si < scopeSpans.length; si++) {",
1559
+ " var span = scopeSpans[si];",
1560
+ " var enabled = parseInt(span.getAttribute('data-enabled'), 10);",
1561
+ " var total = parseInt(span.getAttribute('data-total'), 10);",
1562
+ " if((total > 0) && (enabled > 0) && (enabled < total)) {",
1563
+ " var cb = document.querySelector('.scope-toggle[data-scope=\"' + span.getAttribute('data-scope') + '\"]');",
1564
+ " if(cb) cb.indeterminate = true;",
1548
1565
  " }",
1549
1566
  " }",
1567
+ // Set indeterminate state on the HDHR bulk toggle if data-indeterminate is present.
1568
+ " var hdhrToggle = document.getElementById('hdhr-bulk-toggle');",
1569
+ " if(hdhrToggle && hdhrToggle.getAttribute('data-indeterminate') === 'true') hdhrToggle.indeterminate = true;",
1550
1570
  // Run filterChannelRows on page load when a provider filter is active. The server renders filtered options with the hidden attribute, but Safari ignores it on
1551
1571
  // option elements. This initial pass removes those options from the DOM to enforce the filter.
1552
1572
  " var menu = document.querySelector('.provider-dropdown-menu');",
@@ -1562,21 +1582,51 @@ export function generateConfigSubtabScript() {
1562
1582
  " }",
1563
1583
  " var addUrlInput = document.getElementById('add-url');",
1564
1584
  " if (addUrlInput) {",
1565
- " addUrlInput.addEventListener('input', function() { updateSelectorSuggestions('add-url', 'add-selectorList'); });",
1585
+ " addUrlInput.addEventListener('input', function() {",
1586
+ " updateSelectorSuggestions('add-url', 'add-selectorList');",
1587
+ // Deselect the active provider pill if the user manually edits the URL. Check whether the current URL still matches the active pill's URL — if not, the
1588
+ // user has overridden the auto-fill and the pill should unhighlight. Also re-enable the profile dropdown since manual URL entry may need a custom profile.
1589
+ " var activePill = document.querySelector('.provider-pill.active');",
1590
+ " if(activePill && activePill.getAttribute('data-url') !== addUrlInput.value) {",
1591
+ " activePill.classList.remove('active');",
1592
+ " var profileSelect = document.getElementById('add-profile');",
1593
+ " if(profileSelect) profileSelect.disabled = false;",
1594
+ " }",
1595
+ " });",
1566
1596
  " updateSelectorSuggestions('add-url', 'add-selectorList');",
1567
1597
  " }",
1598
+ // Auto-fill station ID when the channelSelector matches a known entry with a stationId. Reads from the same channelSelectorsByDomain data used by the
1599
+ // datalist. The data-auto attribute tracks whether the current value was auto-filled — user edits clear it, preventing overwrites of intentional input.
1600
+ " var addSelectorInput = document.getElementById('add-channelSelector');",
1601
+ " var addStationInput = document.getElementById('add-stationId');",
1602
+ " if (addSelectorInput && addStationInput) {",
1603
+ " addStationInput.addEventListener('input', function() { addStationInput.removeAttribute('data-auto'); });",
1604
+ " addSelectorInput.addEventListener('input', function() {",
1605
+ " if (addStationInput.value && !addStationInput.hasAttribute('data-auto')) return;",
1606
+ " var urlInput = document.getElementById('add-url');",
1607
+ " if (!urlInput || !urlInput.value) return;",
1608
+ " try {",
1609
+ " var hostname = new URL(urlInput.value).hostname;",
1610
+ " var entries = (typeof channelSelectorsByDomain !== 'undefined') ? channelSelectorsByDomain[hostname] : null;",
1611
+ " if (!entries) return;",
1612
+ " var match = entries.find(function(e) { return e.value === addSelectorInput.value; });",
1613
+ " if (match && match.stationId) { addStationInput.value = match.stationId; addStationInput.setAttribute('data-auto', '1'); }",
1614
+ " else if (addStationInput.hasAttribute('data-auto')) { addStationInput.value = ''; addStationInput.removeAttribute('data-auto'); }",
1615
+ " } catch(e) {}",
1616
+ " });",
1617
+ " }",
1568
1618
  " })();",
1569
1619
  // Login modal state tracking.
1570
1620
  " var loginStatusInterval = null;",
1571
1621
  // Start login mode for a channel. Opens browser window and shows modal.
1572
- " window.startChannelLogin = function(channel) {",
1573
- " fetch('/auth/login', {",
1574
- " method: 'POST',",
1575
- " headers: { 'Content-Type': 'application/json' },",
1576
- " body: JSON.stringify({ channel: channel })",
1577
- " })",
1578
- " .then(function(response) { return response.json(); })",
1579
- " .then(function(result) {",
1622
+ " window.startChannelLogin = async function(channel) {",
1623
+ " try {",
1624
+ " var res = await fetch('/auth/login', {",
1625
+ " method: 'POST',",
1626
+ " headers: { 'Content-Type': 'application/json' },",
1627
+ " body: JSON.stringify({ channel: channel })",
1628
+ " });",
1629
+ " var result = await res.json();",
1580
1630
  " if (result.success) {",
1581
1631
  " showToast('Browser window opened. Complete authentication.', 'info');",
1582
1632
  " showLoginModal();",
@@ -1584,18 +1634,21 @@ export function generateConfigSubtabScript() {
1584
1634
  " } else {",
1585
1635
  " showToast(result.error || 'Failed to start login.', 'error');",
1586
1636
  " }",
1587
- " })",
1588
- " .catch(function(err) { showToast('Failed to start login: ' + err.message, 'error'); });",
1637
+ " } catch(err) {",
1638
+ " showToast('Failed to start login: ' + err.message, 'error');",
1639
+ " }",
1589
1640
  " };",
1590
1641
  // End login mode. Closes browser tab and hides modal.
1591
- " window.endLogin = function() {",
1642
+ " window.endLogin = async function() {",
1592
1643
  " stopLoginStatusPolling();",
1593
- " fetch('/auth/done', { method: 'POST' })",
1594
- " .then(function() {",
1644
+ " try {",
1645
+ " await fetch('/auth/done', { method: 'POST' });",
1595
1646
  " hideLoginModal();",
1596
1647
  " showToast('Authentication complete.', 'success');",
1597
- " })",
1598
- " .catch(function(err) { showToast('Error ending login: ' + err.message, 'error'); hideLoginModal(); });",
1648
+ " } catch(err) {",
1649
+ " showToast('Error ending login: ' + err.message, 'error');",
1650
+ " hideLoginModal();",
1651
+ " }",
1599
1652
  " };",
1600
1653
  // Show the login modal.
1601
1654
  " function showLoginModal() {",
@@ -1610,17 +1663,16 @@ export function generateConfigSubtabScript() {
1610
1663
  // Start polling login status to detect when tab is closed externally.
1611
1664
  " function startLoginStatusPolling() {",
1612
1665
  " stopLoginStatusPolling();",
1613
- " loginStatusInterval = setInterval(function() {",
1614
- " fetch('/auth/status')",
1615
- " .then(function(response) { return response.json(); })",
1616
- " .then(function(status) {",
1666
+ " loginStatusInterval = setInterval(async function() {",
1667
+ " try {",
1668
+ " var res = await fetch('/auth/status');",
1669
+ " var status = await res.json();",
1617
1670
  " if (!status.active) {",
1618
1671
  " stopLoginStatusPolling();",
1619
1672
  " hideLoginModal();",
1620
1673
  " showToast('Login session ended.', 'info');",
1621
1674
  " }",
1622
- " })",
1623
- " .catch(function() { });",
1675
+ " } catch(err) {}",
1624
1676
  " }, 1000);",
1625
1677
  " }",
1626
1678
  // Stop polling login status.