prismcast 1.7.0 → 1.9.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 (208) hide show
  1. package/README.md +6 -6
  2. package/dist/app.js +24 -13
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +4 -6
  5. package/dist/browser/cdp.js +13 -17
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.js +3 -3
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/login.js +11 -8
  10. package/dist/browser/login.js.map +1 -1
  11. package/dist/browser/precaching.d.ts +1 -1
  12. package/dist/browser/precaching.js +23 -23
  13. package/dist/browser/precaching.js.map +1 -1
  14. package/dist/browser/tuning/directv.js +4 -7
  15. package/dist/browser/tuning/directv.js.map +1 -1
  16. package/dist/browser/tuning/fox.js +1 -1
  17. package/dist/browser/tuning/fox.js.map +1 -1
  18. package/dist/browser/tuning/hbo.js +1 -1
  19. package/dist/browser/tuning/hbo.js.map +1 -1
  20. package/dist/browser/tuning/hulu.js +3 -3
  21. package/dist/browser/tuning/hulu.js.map +1 -1
  22. package/dist/browser/tuning/sling.js +2 -2
  23. package/dist/browser/tuning/sling.js.map +1 -1
  24. package/dist/channels/index.d.ts +2 -0
  25. package/dist/channels/index.js +438 -252
  26. package/dist/channels/index.js.map +1 -1
  27. package/dist/config/health.d.ts +3 -3
  28. package/dist/config/health.js +13 -18
  29. package/dist/config/health.js.map +1 -1
  30. package/dist/config/index.js +11 -2
  31. package/dist/config/index.js.map +1 -1
  32. package/dist/config/persistence.d.ts +59 -0
  33. package/dist/config/persistence.js +127 -0
  34. package/dist/config/persistence.js.map +1 -0
  35. package/dist/config/profiles.js +10 -10
  36. package/dist/config/profiles.js.map +1 -1
  37. package/dist/config/servicePacks.d.ts +46 -0
  38. package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
  39. package/dist/config/servicePacks.js.map +1 -0
  40. package/dist/config/services.d.ts +202 -0
  41. package/dist/config/services.js +758 -0
  42. package/dist/config/services.js.map +1 -0
  43. package/dist/config/sites.js +61 -61
  44. package/dist/config/sites.js.map +1 -1
  45. package/dist/config/userChannels.d.ts +89 -27
  46. package/dist/config/userChannels.js +400 -184
  47. package/dist/config/userChannels.js.map +1 -1
  48. package/dist/config/userConfig.d.ts +13 -9
  49. package/dist/config/userConfig.js +78 -60
  50. package/dist/config/userConfig.js.map +1 -1
  51. package/dist/config/userProfiles.d.ts +31 -15
  52. package/dist/config/userProfiles.js +154 -123
  53. package/dist/config/userProfiles.js.map +1 -1
  54. package/dist/hdhr/channelMap.d.ts +1 -1
  55. package/dist/hdhr/channelMap.js +4 -7
  56. package/dist/hdhr/channelMap.js.map +1 -1
  57. package/dist/hdhr/discover.js +7 -15
  58. package/dist/hdhr/discover.js.map +1 -1
  59. package/dist/hdhr/index.js +6 -9
  60. package/dist/hdhr/index.js.map +1 -1
  61. package/dist/native/index.d.ts +2 -2
  62. package/dist/native/index.js +17 -27
  63. package/dist/native/index.js.map +1 -1
  64. package/dist/native/intercept.js +86 -89
  65. package/dist/native/intercept.js.map +1 -1
  66. package/dist/native/probe.js +2 -2
  67. package/dist/native/probe.js.map +1 -1
  68. package/dist/native/proxy.d.ts +2 -2
  69. package/dist/native/proxy.js +4 -4
  70. package/dist/native/proxy.js.map +1 -1
  71. package/dist/routes/assets.js +29 -51
  72. package/dist/routes/assets.js.map +1 -1
  73. package/dist/routes/auth.js +3 -3
  74. package/dist/routes/auth.js.map +1 -1
  75. package/dist/routes/channels.js +5 -11
  76. package/dist/routes/channels.js.map +1 -1
  77. package/dist/routes/components.d.ts +0 -8
  78. package/dist/routes/components.js +0 -19
  79. package/dist/routes/components.js.map +1 -1
  80. package/dist/routes/config/channels/index.d.ts +1 -1
  81. package/dist/routes/config/channels/index.js +1 -1
  82. package/dist/routes/config/channels/index.js.map +1 -1
  83. package/dist/routes/config/channels/routes.js +748 -420
  84. package/dist/routes/config/channels/routes.js.map +1 -1
  85. package/dist/routes/config/channels/table.d.ts +21 -6
  86. package/dist/routes/config/channels/table.js +456 -193
  87. package/dist/routes/config/channels/table.js.map +1 -1
  88. package/dist/routes/config/index.d.ts +2 -2
  89. package/dist/routes/config/index.js +3 -3
  90. package/dist/routes/config/index.js.map +1 -1
  91. package/dist/routes/config/{providers.js → services.js} +47 -42
  92. package/dist/routes/config/services.js.map +1 -0
  93. package/dist/routes/config/settings.js +88 -49
  94. package/dist/routes/config/settings.js.map +1 -1
  95. package/dist/routes/debug.js +5 -7
  96. package/dist/routes/debug.js.map +1 -1
  97. package/dist/routes/health.js +1 -1
  98. package/dist/routes/health.js.map +1 -1
  99. package/dist/routes/icons.d.ts +16 -0
  100. package/dist/routes/icons.js +44 -0
  101. package/dist/routes/icons.js.map +1 -0
  102. package/dist/routes/index.js +2 -2
  103. package/dist/routes/index.js.map +1 -1
  104. package/dist/routes/logs.js +4 -4
  105. package/dist/routes/logs.js.map +1 -1
  106. package/dist/routes/playlist.d.ts +6 -4
  107. package/dist/routes/playlist.js +98 -45
  108. package/dist/routes/playlist.js.map +1 -1
  109. package/dist/routes/root/content.js +94 -55
  110. package/dist/routes/root/content.js.map +1 -1
  111. package/dist/routes/root/scripts/channels.js +620 -478
  112. package/dist/routes/root/scripts/channels.js.map +1 -1
  113. package/dist/routes/root/scripts/config.js +884 -964
  114. package/dist/routes/root/scripts/config.js.map +1 -1
  115. package/dist/routes/root/scripts/shared.js +468 -188
  116. package/dist/routes/root/scripts/shared.js.map +1 -1
  117. package/dist/routes/root/scripts/status.js +252 -207
  118. package/dist/routes/root/scripts/status.js.map +1 -1
  119. package/dist/routes/root/styles.js +57 -8
  120. package/dist/routes/root/styles.js.map +1 -1
  121. package/dist/routes/services.d.ts +6 -0
  122. package/dist/routes/{providers.js → services.js} +43 -43
  123. package/dist/routes/services.js.map +1 -0
  124. package/dist/routes/ui.d.ts +1 -1
  125. package/dist/routes/ui.js +3 -1
  126. package/dist/routes/ui.js.map +1 -1
  127. package/dist/service/commands.js +8 -43
  128. package/dist/service/commands.js.map +1 -1
  129. package/dist/service/generators.d.ts +1 -1
  130. package/dist/service/generators.js +143 -41
  131. package/dist/service/generators.js.map +1 -1
  132. package/dist/streaming/codec.d.ts +20 -0
  133. package/dist/streaming/codec.js +68 -0
  134. package/dist/streaming/codec.js.map +1 -0
  135. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  136. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  137. package/dist/streaming/hls.d.ts +4 -4
  138. package/dist/streaming/hls.js +35 -29
  139. package/dist/streaming/hls.js.map +1 -1
  140. package/dist/streaming/hlsResume.js +3 -3
  141. package/dist/streaming/hlsResume.js.map +1 -1
  142. package/dist/streaming/monitor.d.ts +2 -2
  143. package/dist/streaming/monitor.js +19 -13
  144. package/dist/streaming/monitor.js.map +1 -1
  145. package/dist/streaming/preroll.d.ts +15 -14
  146. package/dist/streaming/preroll.js +32 -12
  147. package/dist/streaming/preroll.js.map +1 -1
  148. package/dist/streaming/registry.d.ts +2 -2
  149. package/dist/streaming/registry.js +4 -8
  150. package/dist/streaming/registry.js.map +1 -1
  151. package/dist/streaming/setup.d.ts +4 -4
  152. package/dist/streaming/setup.js +28 -34
  153. package/dist/streaming/setup.js.map +1 -1
  154. package/dist/streaming/showInfo.js +8 -10
  155. package/dist/streaming/showInfo.js.map +1 -1
  156. package/dist/streaming/statusEmitter.d.ts +4 -3
  157. package/dist/streaming/statusEmitter.js +3 -3
  158. package/dist/streaming/statusEmitter.js.map +1 -1
  159. package/dist/types/channels.d.ts +18 -11
  160. package/dist/types/config.d.ts +3 -2
  161. package/dist/types/index.d.ts +4 -3
  162. package/dist/types/index.js +1 -0
  163. package/dist/types/index.js.map +1 -1
  164. package/dist/types/profiles.d.ts +9 -9
  165. package/dist/types/selection.d.ts +1 -1
  166. package/dist/types/shared.d.ts +1 -1
  167. package/dist/types/streaming.d.ts +8 -16
  168. package/dist/types/streaming.js +5 -1
  169. package/dist/types/streaming.js.map +1 -1
  170. package/dist/upgrade/commands.js +1 -16
  171. package/dist/upgrade/commands.js.map +1 -1
  172. package/dist/utils/chromeFetch.js +1 -1
  173. package/dist/utils/cliOutput.d.ts +10 -0
  174. package/dist/utils/cliOutput.js +17 -0
  175. package/dist/utils/cliOutput.js.map +1 -0
  176. package/dist/utils/debugFilter.js +3 -3
  177. package/dist/utils/debugFilter.js.map +1 -1
  178. package/dist/utils/fileLogger.js +4 -3
  179. package/dist/utils/fileLogger.js.map +1 -1
  180. package/dist/utils/format.d.ts +21 -4
  181. package/dist/utils/format.js +45 -7
  182. package/dist/utils/format.js.map +1 -1
  183. package/dist/utils/index.d.ts +1 -0
  184. package/dist/utils/index.js +1 -0
  185. package/dist/utils/index.js.map +1 -1
  186. package/dist/utils/logger.js +17 -27
  187. package/dist/utils/logger.js.map +1 -1
  188. package/dist/utils/morganStream.js +2 -2
  189. package/dist/utils/morganStream.js.map +1 -1
  190. package/dist/utils/platform.js +3 -2
  191. package/dist/utils/platform.js.map +1 -1
  192. package/dist/utils/retry.js +2 -4
  193. package/dist/utils/retry.js.map +1 -1
  194. package/dist/utils/streamContext.d.ts +7 -0
  195. package/dist/utils/streamContext.js +10 -1
  196. package/dist/utils/streamContext.js.map +1 -1
  197. package/dist/utils/version.js +4 -4
  198. package/dist/utils/version.js.map +1 -1
  199. package/package.json +5 -7
  200. package/dist/config/providerPacks.d.ts +0 -46
  201. package/dist/config/providerPacks.js.map +0 -1
  202. package/dist/config/providers.d.ts +0 -175
  203. package/dist/config/providers.js +0 -678
  204. package/dist/config/providers.js.map +0 -1
  205. package/dist/routes/config/providers.js.map +0 -1
  206. package/dist/routes/providers.d.ts +0 -6
  207. package/dist/routes/providers.js.map +0 -1
  208. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -6,116 +6,90 @@ export function generateChannelsSubtabScript() {
6
6
  return [
7
7
  "<script>",
8
8
  "(function() {",
9
- // Channels subtab switching function. Scoped to .channels-subtab-btn / .channels-subtab-panel to avoid colliding with Config tab subtabs.
10
- " function switchChannelsSubtab(subtab, updateUrl) {",
11
- " var btns = document.querySelectorAll('.channels-subtab-btn');",
12
- " var panels = document.querySelectorAll('.channels-subtab-panel');",
13
- " for(var i = 0; i < btns.length; i++) {",
14
- " btns[i].classList.remove('active');",
15
- " btns[i].setAttribute('aria-selected', 'false');",
16
- " if(btns[i].getAttribute('data-channels-subtab') === subtab) {",
17
- " btns[i].classList.add('active');",
18
- " btns[i].setAttribute('aria-selected', 'true');",
19
- " }",
20
- " }",
21
- " for(var j = 0; j < panels.length; j++) {",
22
- " panels[j].classList.remove('active');",
23
- " if(panels[j].id === 'channels-subtab-' + subtab) {",
24
- " panels[j].classList.add('active');",
25
- " }",
26
- " }",
27
- " try { localStorage.setItem('prismcast-channels-subtab', subtab); } catch(e) {}",
28
- " if(updateUrl !== false) {",
29
- " var newHash = (subtab === 'channels') ? '#channels' : '#channels/' + subtab;",
30
- " if(window.location.hash !== newHash) {",
31
- " window.location.hash = newHash;",
32
- " }",
33
- " }",
34
- " }",
35
- // Expose for main tab script hashchange handler.
9
+ // Channels subtab switching via the shared factory.
10
+ " const switchChannelsSubtab = createSubtabSwitcher({",
11
+ " btnSelector: '.channels-subtab-btn',",
12
+ " dataAttr: 'data-channels-subtab',",
13
+ " hashFn: (s) => (s === 'channels') ? '#channels' : '#channels/' + s,",
14
+ " panelPrefix: 'channels-subtab-',",
15
+ " panelSelector: '.channels-subtab-panel',",
16
+ " storageKey: 'prismcast-channels-subtab'",
17
+ " });",
36
18
  " window.switchChannelsSubtab = switchChannelsSubtab;",
37
- // Attach click handlers to channels subtab buttons.
38
- " var btns = document.querySelectorAll('.channels-subtab-btn');",
39
- " for(var i = 0; i < btns.length; i++) {",
40
- " btns[i].addEventListener('click', function() {",
41
- " switchChannelsSubtab(this.getAttribute('data-channels-subtab'));",
42
- " });",
43
- " }",
44
19
  // Replace the Custom Profiles panel content with server-rendered HTML. Called after profile mutations to update the panel without a page reload.
45
- // Re-runs processProviderDisplays() so dynamically swapped provider icon elements render correctly.
20
+ // Re-runs processServiceDisplays() so dynamically swapped service icon elements render correctly.
46
21
  " function applyProfilePanel(html) {",
47
- " var panel = document.querySelector('#channels-subtab-custom-profiles .section');",
22
+ " const panel = document.querySelector('#channels-subtab-custom-profiles .section');",
48
23
  " if(panel) {",
49
24
  " panel.innerHTML = html;",
50
- " if(window.processProviderDisplays) { processProviderDisplays(); }",
25
+ " if(window.processServiceDisplays) { processServiceDisplays(); }",
51
26
  " }",
52
27
  " }",
53
28
  // Delete a user profile with confirmation.
54
- " window.deleteUserProfile = async function(key) {",
29
+ " window.deleteUserProfile = async (key) => {",
55
30
  " if(!confirm('Delete profile \"' + key + '\" and all its domain mappings?')) return;",
56
31
  " try {",
57
- " var res = await fetch('/config/profiles/' + encodeURIComponent(key), { method: 'DELETE' });",
58
- " var data = await res.json();",
32
+ " const res = await fetch('/config/profiles/' + encodeURIComponent(key), { method: 'DELETE' });",
33
+ " const data = await res.json();",
59
34
  " if(data.success) {",
60
35
  " showToast(data.message || 'Profile deleted.', 'success');",
61
36
  " if(data.panelHtml) { applyProfilePanel(data.panelHtml); }",
62
37
  " } else { showToast('Failed to delete profile: ' + (data.error || 'Unknown error.'), 'error'); }",
63
- " } catch(err) { console.error('Profile delete error:', err); showToast('Failed to delete profile.', 'error'); }",
38
+ " } catch(err) { console.error('Profile delete request failed.', err); showToast('Failed to delete profile.', 'error'); }",
64
39
  " };",
65
40
  // Edit an existing user profile by pre-populating the wizard.
66
- " window.editUserProfile = async function(key) {",
41
+ " window.editUserProfile = async (key) => {",
67
42
  " try {",
68
- " var res = await fetch('/config/profiles');",
69
- " var data = await res.json();",
43
+ " const res = await fetch('/config/profiles');",
44
+ " const data = await res.json();",
70
45
  " if(!data.success) { showToast('Failed to load profiles.', 'error'); return; }",
71
- " var profiles = data.profiles || [];",
72
- " var match = profiles.find(function(p) { return p.key === key; });",
46
+ " const profiles = data.profiles || [];",
47
+ " const match = profiles.find((p) => p.key === key);",
73
48
  " if(!match || !match.profile) { showToast('Profile not found.', 'error'); return; }",
74
- " var p = match.profile;",
75
- " var editCs = p.channelSelection || {};",
76
- " var strat = editCs.strategy || 'none';",
77
- " var strategyFields = {};",
78
- " var editStrat = window.__wizardStrategies.find(function(st) { return st.id === strat; });",
49
+ " const p = match.profile;",
50
+ " const editCs = p.channelSelection || {};",
51
+ " const strat = editCs.strategy || 'none';",
52
+ " const strategyFields = {};",
53
+ " const editStrat = window.__wizardStrategies.find((st) => st.id === strat);",
79
54
  " if(editStrat) {",
80
- " for(var i = 0; i < editStrat.fields.length; i++) {",
81
- " var ef = editStrat.fields[i];",
55
+ " for(const ef of editStrat.fields) {",
82
56
  " if(ef.type === 'text' && editCs[ef.id]) { strategyFields[ef.id] = editCs[ef.id]; }",
83
57
  " else if(ef.type === 'boolean' && editCs[ef.id]) { strategyFields[ef.id] = true; }",
84
58
  " }",
85
59
  " }",
86
- " var flags = {};",
87
- " var wizFlags = window.__wizardFlags;",
88
- " for(var i = 0; i < wizFlags.length; i++) { if(p[wizFlags[i].id]) flags[wizFlags[i].id] = true; }",
89
- " var domains = (match.domains && match.domains.length > 0) ?",
90
- " match.domains.map(function(d) { return { domain: d.domain, provider: d.provider || '', providerTag: d.providerTag || '' }; }) :",
91
- " [{ domain: '', provider: '', providerTag: '' }];",
60
+ " const flags = {};",
61
+ " const wizFlags = window.__wizardFlags;",
62
+ " for(const wf of wizFlags) { if(p[wf.id]) flags[wf.id] = true; }",
63
+ " const domains = (match.domains && match.domains.length > 0) ?",
64
+ " match.domains.map((d) => ({ domain: d.domain, service: d.service || '', serviceTag: d.serviceTag || '' })) :",
65
+ " [{ domain: '', service: '', serviceTag: '' }];",
92
66
  " profileWizard.state = {",
93
67
  " editKey: key, editMode: true, profileName: key,",
94
68
  " baseProfile: p.extends || '', strategy: strat,",
95
69
  " strategyFields: strategyFields, flags: flags,",
96
70
  " hideSelector: p.hideSelector || '', domains: domains, description: p.description || ''",
97
71
  " };",
98
- " profileWizard.open({ highestStep: 5, title: 'Edit Provider Profile' });",
99
- " } catch(e) { showToast('Failed to load profile data.', 'error'); }",
72
+ " profileWizard.open({ highestStep: 5, title: 'Edit Service Profile' });",
73
+ " } catch(e) { console.error('Profile data failed to load.', e); showToast('Failed to load profile data.', 'error'); }",
100
74
  " };",
101
75
  // Pending import data stored while the import preview modal is open.
102
- " var pendingImportData = null;",
103
- // Import a provider pack via file picker — shows a preview modal before importing.
104
- " window.startProviderImport = function() {",
105
- " var input = document.createElement('input');",
76
+ " let pendingImportData = null;",
77
+ // Import a service pack via file picker — shows a preview modal before importing.
78
+ " window.startServiceImport = () => {",
79
+ " const input = document.createElement('input');",
106
80
  " input.type = 'file';",
107
81
  " input.accept = '.json,application/json';",
108
- " input.onchange = function() {",
82
+ " input.onchange = () => {",
109
83
  " if(!input.files || !input.files[0]) return;",
110
- " var reader = new FileReader();",
111
- " reader.onload = function() {",
84
+ " const reader = new FileReader();",
85
+ " reader.onload = () => {",
112
86
  " try {",
113
- " var data = JSON.parse(reader.result);",
87
+ " const data = JSON.parse(reader.result);",
114
88
  " pendingImportData = data;",
115
- " var profileCount = data.profiles ? Object.keys(data.profiles).length : 0;",
116
- " var domainCount = data.domains ? Object.keys(data.domains).length : 0;",
117
- " var channelCount = data.channels ? Object.keys(data.channels).length : 0;",
118
- " var html = '<table class=\"import-summary-table\">';",
89
+ " const profileCount = data.profiles ? Object.keys(data.profiles).length : 0;",
90
+ " const domainCount = data.domains ? Object.keys(data.domains).length : 0;",
91
+ " const channelCount = data.channels ? Object.keys(data.channels).length : 0;",
92
+ " let html = '<table class=\"import-summary-table\">';",
119
93
  " html += '<tr><td class=\"import-summary-label\">Pack name</td>';",
120
94
  " html += '<td>' + esc(data.name || 'Unnamed') + '</td></tr>';",
121
95
  " html += '<tr><td class=\"import-summary-label\">Profiles</td>';",
@@ -129,11 +103,12 @@ export function generateChannelsSubtabScript() {
129
103
  " html += '<div class=\"export-divider\"></div>';",
130
104
  " html += '<label class=\"export-option-label\">';",
131
105
  " html += '<input type=\"checkbox\" id=\"import-include-channels\" checked> Include ' + plural(channelCount, 'channel') + '</label>';",
132
- " html += '<div class=\"export-hint\">Import the channel definitions from this pack. Uncheck to import only the provider profiles and domains.</div>';",
106
+ " html += '<div class=\"export-hint\">Import the channel definitions from this pack. Uncheck to import only the service profiles and domains.</div>';",
133
107
  " }",
134
108
  " document.getElementById('import-modal-body').innerHTML = html;",
135
109
  " document.getElementById('import-modal').style.display = 'flex';",
136
110
  " } catch(e) {",
111
+ " console.error('Service pack JSON failed to parse.', e);",
137
112
  " showToast('Invalid JSON file.', 'error');",
138
113
  " }",
139
114
  " };",
@@ -142,28 +117,28 @@ export function generateChannelsSubtabScript() {
142
117
  " input.click();",
143
118
  " };",
144
119
  // Close the import preview modal.
145
- " window.closeImportModal = function() {",
120
+ " window.closeImportModal = () => {",
146
121
  " document.getElementById('import-modal').style.display = 'none';",
147
122
  " pendingImportData = null;",
148
123
  " };",
149
124
  // Execute the import after the user confirms in the preview modal.
150
- " window.executeImport = async function() {",
125
+ " window.executeImport = async () => {",
151
126
  " if(!pendingImportData) return;",
152
- " var skipChannels = false;",
153
- " var cb = document.getElementById('import-include-channels');",
127
+ " let skipChannels = false;",
128
+ " const cb = document.getElementById('import-include-channels');",
154
129
  " if(cb && !cb.checked) skipChannels = true;",
155
- " var body = Object.assign({}, pendingImportData);",
130
+ " const body = { ...pendingImportData };",
156
131
  " if(skipChannels) body.skipChannels = true;",
157
132
  " try {",
158
- " var res = await fetch('/config/profiles/import', {",
159
- " method: 'POST',",
133
+ " const res = await fetch('/config/profiles/import', {",
134
+ " body: JSON.stringify(body),",
160
135
  " headers: { 'Content-Type': 'application/json' },",
161
- " body: JSON.stringify(body)",
136
+ " method: 'POST'",
162
137
  " });",
163
- " var result = await res.json();",
138
+ " const result = await res.json();",
164
139
  " closeImportModal();",
165
140
  " if(result.success) {",
166
- " var msg = 'Imported ' + plural(result.summary.profilesAdded, 'profile') + ', ' +",
141
+ " let msg = 'Imported ' + plural(result.summary.profilesAdded, 'profile') + ', ' +",
167
142
  " plural(result.summary.domainsAdded, 'domain') + ', ' + plural(result.summary.channelsAdded, 'channel') + '.';",
168
143
  " if(result.warnings && result.warnings.length > 0) msg += ' Warnings: ' + result.warnings.join('; ');",
169
144
  " showToast(msg, 'success');",
@@ -171,28 +146,27 @@ export function generateChannelsSubtabScript() {
171
146
  " } else {",
172
147
  " showToast('Import failed: ' + (result.error || 'Unknown error.'), 'error');",
173
148
  " }",
174
- " } catch(err) { console.error('Provider pack import error:', err); closeImportModal(); showToast('Import request failed.', 'error'); }",
149
+ " } catch(err) { console.error('Service pack import request failed.', err); closeImportModal(); showToast('Import request failed.', 'error'); }",
175
150
  " };",
176
- // Export a provider pack. Opens a modal with profile checklist and options. Shows provider name prominently for each profile with domain and channel counts.
151
+ // Export a service pack. Opens a modal with profile checklist and options. Shows service name prominently for each profile with domain and channel counts.
177
152
  // The select-all row is hidden when there is only one profile since the single checkbox makes it redundant.
178
- " window.startProviderExport = async function() {",
153
+ " window.startServiceExport = async () => {",
179
154
  " try {",
180
- " var res = await fetch('/config/profiles');",
181
- " var data = await res.json();",
182
- " var profiles = data.profiles || [];",
155
+ " const res = await fetch('/config/profiles');",
156
+ " const data = await res.json();",
157
+ " const profiles = data.profiles || [];",
183
158
  " if(profiles.length === 0) { showToast('No user profiles to export.', 'error'); return; }",
184
- " var listDiv = document.getElementById('export-profile-list');",
185
- " var html = '';",
186
- " for(var i = 0; i < profiles.length; i++) {",
187
- " var p = profiles[i];",
188
- " var domainCount = p.domains ? p.domains.length : 0;",
189
- " var channelCount = p.channelCount || 0;",
190
- " var providerName = '';",
191
- " for(var di = 0; di < (p.domains || []).length; di++) {",
192
- " if(p.domains[di].provider) { providerName = p.domains[di].provider; break; }",
159
+ " const listDiv = document.getElementById('export-profile-list');",
160
+ " let html = '';",
161
+ " for(const p of profiles) {",
162
+ " const domainCount = p.domains ? p.domains.length : 0;",
163
+ " const channelCount = p.channelCount || 0;",
164
+ " let serviceName = '';",
165
+ " for(const dom of (p.domains || [])) {",
166
+ " if(dom.service) { serviceName = dom.service; break; }",
193
167
  " }",
194
- " var meta = [];",
195
- " if(providerName) meta.push(esc(providerName));",
168
+ " const meta = [];",
169
+ " if(serviceName) meta.push(esc(serviceName));",
196
170
  " meta.push(plural(domainCount, 'domain'));",
197
171
  " meta.push(plural(channelCount, 'channel'));",
198
172
  " html += '<label class=\"export-profile-item\">';",
@@ -207,35 +181,35 @@ export function generateChannelsSubtabScript() {
207
181
  " document.getElementById('export-select-all-row').style.display = (profiles.length > 1) ? '' : 'none';",
208
182
  " document.getElementById('export-include-channels').checked = false;",
209
183
  " document.getElementById('export-modal').style.display = 'flex';",
210
- " } catch(e) { showToast('Failed to load profiles.', 'error'); }",
184
+ " } catch(e) { console.error('Profiles failed to load for export.', e); showToast('Failed to load profiles.', 'error'); }",
211
185
  " };",
212
186
  // Toggle all export profile checkboxes.
213
- " window.toggleExportAll = function(master) {",
214
- " var cbs = document.querySelectorAll('.export-profile-cb');",
215
- " for(var i = 0; i < cbs.length; i++) cbs[i].checked = master.checked;",
187
+ " window.toggleExportAll = (master) => {",
188
+ " const cbs = document.querySelectorAll('.export-profile-cb');",
189
+ " for(const cb of cbs) cb.checked = master.checked;",
216
190
  " };",
217
191
  // Close the export modal.
218
- " window.closeExportModal = function() {",
192
+ " window.closeExportModal = () => {",
219
193
  " document.getElementById('export-modal').style.display = 'none';",
220
194
  " };",
221
195
  // Execute the export by building the download URL from the selected profiles.
222
- " window.executeExport = function() {",
223
- " var cbs = document.querySelectorAll('.export-profile-cb:checked');",
196
+ " window.executeExport = () => {",
197
+ " const cbs = document.querySelectorAll('.export-profile-cb:checked');",
224
198
  " if(cbs.length === 0) { showToast('Select at least one profile.', 'error'); return; }",
225
- " var keys = [];",
226
- " for(var i = 0; i < cbs.length; i++) keys.push(cbs[i].value);",
227
- " var url = '/config/profiles/export?profile=' + encodeURIComponent(keys.join(',')) + '&domains=1';",
199
+ " const keys = [];",
200
+ " for(const cb of cbs) keys.push(cb.value);",
201
+ " let url = '/config/profiles/export?profile=' + encodeURIComponent(keys.join(',')) + '&domains=1';",
228
202
  " if(document.getElementById('export-include-channels').checked) url += '&channels=1';",
229
203
  " closeExportModal();",
230
204
  " window.location.href = url;",
231
205
  " };",
232
- // Auto-generate a provider tag from a provider name: lowercase, strip non-alphanumeric.
233
- " function autoProviderTag(name) {",
206
+ // Auto-generate a service tag from a service name: lowercase, strip non-alphanumeric.
207
+ " function autoServiceTag(name) {",
234
208
  " return name.toLowerCase().replace(/[^a-z0-9]/g, '');",
235
209
  " }",
236
210
  // HTML-escape user input for safe innerHTML insertion in the review step.
237
211
  " function esc(str) {",
238
- " var d = document.createElement('div');",
212
+ " const d = document.createElement('div');",
239
213
  " d.appendChild(document.createTextNode(str || ''));",
240
214
  " return d.innerHTML;",
241
215
  " }",
@@ -245,7 +219,7 @@ export function generateChannelsSubtabScript() {
245
219
  " }",
246
220
  // Profile builder wizard. Uses the shared wizard controller for step navigation, indicator updates, and error display. The state object holds wizard-specific
247
221
  // form data (profile name, base profile, strategy, flags, domains, description).
248
- " var profileWizard = createWizardController({",
222
+ " const profileWizard = createWizardController({",
249
223
  " contentId: 'wizard-content',",
250
224
  " errorId: 'wizard-error',",
251
225
  " modalId: 'wizard-modal',",
@@ -256,28 +230,28 @@ export function generateChannelsSubtabScript() {
256
230
  " });",
257
231
  // Render the current profile wizard step into the content area. Manages button visibility and delegates to the step-specific content renderer.
258
232
  " function renderProfileStep(step) {",
259
- " var content = document.getElementById('wizard-content');",
233
+ " const content = document.getElementById('wizard-content');",
260
234
  " if(!content) return;",
261
235
  // Show/hide navigation buttons based on the current step.
262
236
  " if(step > 1) { profileWizard.show('wizard-back'); } else { profileWizard.hide('wizard-back'); }",
263
237
  " if(step < 5) { profileWizard.show('wizard-next'); } else { profileWizard.hide('wizard-next'); }",
264
238
  " if(step === 5) { profileWizard.show('wizard-save'); profileWizard.show('wizard-save-test'); }",
265
239
  " else { profileWizard.hide('wizard-save'); profileWizard.hide('wizard-save-test'); }",
266
- " var html = '';",
267
- " var s = profileWizard.state;",
240
+ " let html = '';",
241
+ " const s = profileWizard.state;",
268
242
  // Step 1: Base Profile.
269
243
  " if(step === 1) {",
270
244
  " html += '<div class=\"field-group\">';",
271
245
  " html += '<label class=\"wizard-label\" for=\"wizard-profile-name\">Profile Name</label>';",
272
- " var nameReadonly = s.editMode ? ' readonly style=\"opacity: 0.6; cursor: not-allowed;\"' : '';",
273
- " html += '<input type=\"text\" id=\"wizard-profile-name\" value=\"' + s.profileName.replace(/\"/g, '&quot;') +",
274
- " '\" placeholder=\"e.g., myProvider\"' + nameReadonly + '>';",
246
+ " const nameReadonly = s.editMode ? ' readonly style=\"opacity: 0.6; cursor: not-allowed;\"' : '';",
247
+ " html += '<input type=\"text\" id=\"wizard-profile-name\" value=\"' + s.profileName.replaceAll('\"', '&quot;') +",
248
+ " '\" placeholder=\"e.g., myService\"' + nameReadonly + '>';",
275
249
  " html += '<div class=\"field-hint\">' + (s.editMode ? 'Profile name cannot be changed.' :",
276
250
  " 'Unique identifier. Letters, numbers, and hyphens only.') + '</div>';",
277
251
  " html += '</div>';",
278
252
  " html += '<label class=\"wizard-label\">Base Profile</label>';",
279
- " var profiles = window.__wizardProfiles || {};",
280
- " var cats = [",
253
+ " const profiles = window.__wizardProfiles || {};",
254
+ " const cats = [",
281
255
  " { key: 'api', title: 'Fullscreen API',",
282
256
  " desc: 'For single-channel sites that require JavaScript\\'s requestFullscreen() API instead of keyboard shortcuts.' },",
283
257
  " { key: 'keyboard', title: 'Keyboard Fullscreen',",
@@ -288,19 +262,17 @@ export function generateChannelsSubtabScript() {
288
262
  " desc: 'For non-standard use cases like static pages without video.' },",
289
263
  " { key: 'custom', title: 'Custom', desc: '' }",
290
264
  " ];",
291
- " for(var ci = 0; ci < cats.length; ci++) {",
292
- " var cat = cats[ci];",
293
- " var items = (profiles[cat.key] || []).filter(function(p) { return !s.editMode || p.name !== s.editKey; });",
265
+ " for(const cat of cats) {",
266
+ " const items = (profiles[cat.key] || []).filter((p) => !s.editMode || p.name !== s.editKey);",
294
267
  " if(items.length === 0) continue;",
295
268
  " html += '<div class=\"wizard-profile-category\">';",
296
269
  " html += '<h4>' + cat.title + '</h4>';",
297
270
  " if(cat.desc) html += '<div class=\"wizard-category-desc\">' + esc(cat.desc) + '</div>';",
298
- " for(var pi = 0; pi < items.length; pi++) {",
299
- " var p = items[pi];",
300
- " var checked = (s.baseProfile === p.name) ? ' checked' : '';",
271
+ " for(const p of items) {",
272
+ " const checked = (s.baseProfile === p.name) ? ' checked' : '';",
301
273
  " html += '<label class=\"wizard-profile-option\">';",
302
274
  " html += '<input type=\"radio\" name=\"baseProfile\" value=\"' + p.name + '\"' + checked + '>';",
303
- " var sumTag = p.summary ? ' <span class=\"profile-option-summary\">(' + esc(p.summary) + ')</span>' : '';",
275
+ " const sumTag = p.summary ? ' <span class=\"profile-option-summary\">(' + esc(p.summary) + ')</span>' : '';",
304
276
  " html += '<div><div class=\"profile-option-name\">' + esc(p.name) + sumTag + '</div>';",
305
277
  " html += '<div class=\"profile-option-desc\">' + esc(p.description) + '</div></div>';",
306
278
  " html += '</label>';",
@@ -311,33 +283,31 @@ export function generateChannelsSubtabScript() {
311
283
  // Step 2: Channel Selection Strategy. Strategies and their fields are rendered from the server-side registry (window.__wizardStrategies).
312
284
  " if(step === 2) {",
313
285
  " html += '<label class=\"wizard-label\">Channel Selection Strategy</label>';",
314
- " var strategies = window.__wizardStrategies;",
315
- " for(var si = 0; si < strategies.length; si++) {",
316
- " var st = strategies[si];",
317
- " var checked = (s.strategy === st.id) ? ' checked' : '';",
286
+ " const strategies = window.__wizardStrategies;",
287
+ " for(const st of strategies) {",
288
+ " const checked = (s.strategy === st.id) ? ' checked' : '';",
318
289
  " html += '<label class=\"wizard-strategy-option\">';",
319
290
  " html += '<input type=\"radio\" name=\"strategy\" value=\"' + st.id + '\"' + checked + '>';",
320
291
  " html += '<div><div class=\"wizard-strategy-label\">' + st.name + '</div>';",
321
292
  " html += '<div class=\"wizard-strategy-desc\">' + st.description + '</div></div>';",
322
293
  " html += '</label>';",
323
294
  " }",
324
- " var currentStrat = strategies.find(function(st) { return st.id === s.strategy; });",
295
+ " const currentStrat = strategies.find((st) => st.id === s.strategy);",
325
296
  " if(currentStrat && currentStrat.fields.length > 0) {",
326
297
  " html += '<hr style=\"border: none; border-top: 1px solid var(--border-default); margin: 16px 0;\">';",
327
- " for(var fi = 0; fi < currentStrat.fields.length; fi++) {",
328
- " var f = currentStrat.fields[fi];",
298
+ " for(const f of currentStrat.fields) {",
329
299
  " if(f.type === 'text') {",
330
- " var val = (s.strategyFields[f.id] || '').replace(/\"/g, '&quot;');",
331
- " var optLabel = f.required ? '' : ' <span style=\"color: var(--text-muted);\">(optional)</span>';",
300
+ " const val = (s.strategyFields[f.id] || '').replaceAll('\"', '&quot;');",
301
+ " const optLabel = f.required ? '' : ' <span style=\"color: var(--text-muted);\">(optional)</span>';",
332
302
  " html += '<div class=\"field-group\">';",
333
303
  " html += '<label class=\"wizard-label\" for=\"wizard-sf-' + f.id + '\">' + f.label + optLabel + '</label>';",
334
304
  " html += '<input type=\"text\" id=\"wizard-sf-' + f.id + '\" data-field=\"' + f.id + '\" value=\"' + val + '\"';",
335
- " if(f.placeholder) html += ' placeholder=\"' + f.placeholder.replace(/\"/g, '&quot;') + '\"';",
305
+ " if(f.placeholder) html += ' placeholder=\"' + f.placeholder.replaceAll('\"', '&quot;') + '\"';",
336
306
  " html += '>';",
337
307
  " if(f.hint) html += '<div class=\"field-hint\">' + f.hint + '</div>';",
338
308
  " html += '</div>';",
339
309
  " } else if(f.type === 'boolean') {",
340
- " var boolChecked = s.strategyFields[f.id] ? ' checked' : '';",
310
+ " const boolChecked = s.strategyFields[f.id] ? ' checked' : '';",
341
311
  " html += '<label class=\"wizard-flag\">';",
342
312
  " html += '<input type=\"checkbox\" data-field=\"' + f.id + '\"' + boolChecked + '>';",
343
313
  " html += '<div><div class=\"wizard-flag-label\">' + f.label + '</div>';",
@@ -352,10 +322,9 @@ export function generateChannelsSubtabScript() {
352
322
  " html += '<label class=\"wizard-label\">Profile Flags</label>';",
353
323
  " html += '<div class=\"field-hint\" style=\"margin-bottom: 12px;\">These override the base profile. ' +",
354
324
  " 'Only change what differs from ' + s.baseProfile + '.</div>';",
355
- " var wizFlags = window.__wizardFlags;",
356
- " for(var fi = 0; fi < wizFlags.length; fi++) {",
357
- " var f = wizFlags[fi];",
358
- " var checked = s.flags[f.id] ? ' checked' : '';",
325
+ " const wizFlags = window.__wizardFlags;",
326
+ " for(const f of wizFlags) {",
327
+ " const checked = s.flags[f.id] ? ' checked' : '';",
359
328
  " html += '<label class=\"wizard-flag\">';",
360
329
  " html += '<input type=\"checkbox\" name=\"flag-' + f.id + '\"' + checked + '>';",
361
330
  " html += '<div><div class=\"wizard-flag-label\">' + f.label + '</div>';",
@@ -364,7 +333,7 @@ export function generateChannelsSubtabScript() {
364
333
  " }",
365
334
  " html += '<div class=\"field-group\" style=\"margin-top: 12px;\">';",
366
335
  " html += '<label class=\"wizard-label\" for=\"wizard-hide-selector\">Hide Selector (CSS) <span style=\"color: var(--text-muted);\">(optional)</span></label>';",
367
- " html += '<input type=\"text\" id=\"wizard-hide-selector\" value=\"' + s.hideSelector.replace(/\"/g, '&quot;') +",
336
+ " html += '<input type=\"text\" id=\"wizard-hide-selector\" value=\"' + s.hideSelector.replaceAll('\"', '&quot;') +",
368
337
  " '\" placeholder=\"e.g., .overlay, .ad-banner\">';",
369
338
  " html += '<div class=\"field-hint\">Overlay elements to hide during capture.</div>';",
370
339
  " html += '</div>';",
@@ -373,22 +342,21 @@ export function generateChannelsSubtabScript() {
373
342
  " if(step === 4) {",
374
343
  " html += '<label class=\"wizard-label\">Domain Mapping</label>';",
375
344
  " html += '<div class=\"field-hint\" style=\"margin-bottom: 12px;\">Map a streaming site domain to this profile.</div>';",
376
- " for(var di = 0; di < s.domains.length; di++) {",
377
- " var d = s.domains[di];",
345
+ " for(const [ di, d ] of s.domains.entries()) {",
378
346
  " html += '<div class=\"wizard-domain-row\" data-domain-idx=\"' + di + '\">';",
379
347
  " html += '<div class=\"field-group\">';",
380
348
  " html += '<label class=\"wizard-label\">Domain</label>';",
381
- " html += '<input type=\"text\" class=\"domain-input\" value=\"' + d.domain.replace(/\"/g, '&quot;') + '\" placeholder=\"e.g., watch.sling.com\">';",
349
+ " html += '<input type=\"text\" class=\"domain-input\" value=\"' + d.domain.replaceAll('\"', '&quot;') + '\" placeholder=\"e.g., watch.sling.com\">';",
382
350
  " html += '</div>';",
383
351
  " html += '<div class=\"field-group\">';",
384
- " html += '<label class=\"wizard-label\">Provider Name</label>';",
385
- " html += '<input type=\"text\" class=\"provider-input\" value=\"' + d.provider.replace(/\"/g, '&quot;') + '\" placeholder=\"e.g., Sling TV\">';",
352
+ " html += '<label class=\"wizard-label\">Service Name</label>';",
353
+ " html += '<input type=\"text\" class=\"provider-input\" value=\"' + d.service.replaceAll('\"', '&quot;') + '\" placeholder=\"e.g., Sling TV\">';",
386
354
  " html += '</div>';",
387
355
  " html += '<div class=\"field-group\">';",
388
- " html += '<label class=\"wizard-label\">Provider Tag <span style=\"color: var(--text-muted);\">(auto-filled)</span></label>';",
389
- " html += '<input type=\"text\" class=\"provider-tag-input\" value=\"' + d.providerTag.replace(/\"/g, '&quot;') +",
356
+ " html += '<label class=\"wizard-label\">Service Tag <span style=\"color: var(--text-muted);\">(auto-filled)</span></label>';",
357
+ " html += '<input type=\"text\" class=\"provider-tag-input\" value=\"' + d.serviceTag.replaceAll('\"', '&quot;') +",
390
358
  " '\" placeholder=\"auto-generated\">';",
391
- " html += '<div class=\"field-hint\">Identifier for the provider filter. Auto-generated from provider name.</div>';",
359
+ " html += '<div class=\"field-hint\">Identifier for the service filter. Auto-generated from service name.</div>';",
392
360
  " html += '</div>';",
393
361
  " html += '</div>';",
394
362
  " }",
@@ -401,16 +369,15 @@ export function generateChannelsSubtabScript() {
401
369
  " html += '<tr><td>Profile</td><td>' + esc(s.profileName) + '</td></tr>';",
402
370
  " html += '<tr><td>Extends</td><td>' + esc(s.baseProfile) + '</td></tr>';",
403
371
  " html += '<tr><td>Strategy</td><td>' + esc(s.strategy) + '</td></tr>';",
404
- " var domainList = s.domains.filter(function(d) { return d.domain; }).map(function(d) { return esc(d.domain); }).join(', ');",
372
+ " const domainList = s.domains.filter((d) => d.domain).map((d) => esc(d.domain)).join(', ');",
405
373
  " html += '<tr><td>Domain(s)</td><td>' + (domainList || 'none') + '</td></tr>';",
406
- " var providerList = s.domains.filter(function(d) { return d.provider; }).map(function(d) { return esc(d.provider); }).join(', ');",
407
- " html += '<tr><td>Provider</td><td>' + (providerList || 'none') + '</td></tr>';",
374
+ " const serviceList = s.domains.filter((d) => d.service).map((d) => esc(d.service)).join(', ');",
375
+ " html += '<tr><td>Service</td><td>' + (serviceList || 'none') + '</td></tr>';",
408
376
  " if(s.strategy !== 'none') {",
409
- " var reviewStrat = window.__wizardStrategies.find(function(st) { return st.id === s.strategy; });",
377
+ " const reviewStrat = window.__wizardStrategies.find((st) => st.id === s.strategy);",
410
378
  " if(reviewStrat) {",
411
- " for(var ri = 0; ri < reviewStrat.fields.length; ri++) {",
412
- " var rf = reviewStrat.fields[ri];",
413
- " var rv = s.strategyFields[rf.id];",
379
+ " for(const rf of reviewStrat.fields) {",
380
+ " const rv = s.strategyFields[rf.id];",
414
381
  " if(rf.type === 'text' && rv) {",
415
382
  " html += '<tr><td>' + esc(rf.label.replace(/ \\(CSS\\)/, '')) + '</td><td><code>' + esc(rv) + '</code></td></tr>';",
416
383
  " } else if(rf.type === 'boolean' && rv) {",
@@ -419,7 +386,7 @@ export function generateChannelsSubtabScript() {
419
386
  " }",
420
387
  " }",
421
388
  " }",
422
- " var flagNames = Object.keys(s.flags).filter(function(k) { return s.flags[k]; });",
389
+ " const flagNames = Object.keys(s.flags).filter((k) => s.flags[k]);",
423
390
  " if(flagNames.length > 0) {",
424
391
  " html += '<tr><td>Flags</td><td>' + esc(flagNames.join(', ')) + '</td></tr>';",
425
392
  " }",
@@ -436,68 +403,65 @@ export function generateChannelsSubtabScript() {
436
403
  " }",
437
404
  // Attach event handlers to dynamically rendered profile wizard inputs.
438
405
  " function attachProfileHandlers() {",
439
- " var s = profileWizard.state;",
406
+ " const s = profileWizard.state;",
440
407
  // Step 1: profile name and base profile radio.
441
- " var nameInput = document.getElementById('wizard-profile-name');",
408
+ " const nameInput = document.getElementById('wizard-profile-name');",
442
409
  " if(nameInput) nameInput.addEventListener('input', function() { s.profileName = this.value; });",
443
- " var radios = document.querySelectorAll('input[name=\"baseProfile\"]');",
444
- " for(var i = 0; i < radios.length; i++) {",
445
- " radios[i].addEventListener('change', function() { s.baseProfile = this.value; });",
410
+ " const radios = document.querySelectorAll('input[name=\"baseProfile\"]');",
411
+ " for(const radio of radios) {",
412
+ " radio.addEventListener('change', function() { s.baseProfile = this.value; });",
446
413
  " }",
447
414
  // Step 2: strategy radio and strategy field inputs. Text and boolean fields are bound by data-field attribute. Strategy radio triggers a re-render to show
448
415
  // the selected strategy's fields.
449
- " var stratRadios = document.querySelectorAll('input[name=\"strategy\"]');",
450
- " for(var i = 0; i < stratRadios.length; i++) {",
451
- " stratRadios[i].addEventListener('change', function() { s.strategy = this.value; renderProfileStep(profileWizard.getStep()); });",
416
+ " const stratRadios = document.querySelectorAll('input[name=\"strategy\"]');",
417
+ " for(const stratRadio of stratRadios) {",
418
+ " stratRadio.addEventListener('change', function() { s.strategy = this.value; renderProfileStep(profileWizard.getStep()); });",
452
419
  " }",
453
- " var sfTextInputs = document.querySelectorAll('#wizard-content input[type=\"text\"][data-field]');",
454
- " for(var i = 0; i < sfTextInputs.length; i++) {",
455
- " sfTextInputs[i].addEventListener('input', function() { s.strategyFields[this.getAttribute('data-field')] = this.value; });",
420
+ " const sfTextInputs = document.querySelectorAll('#wizard-content input[type=\"text\"][data-field]');",
421
+ " for(const sfTextInput of sfTextInputs) {",
422
+ " sfTextInput.addEventListener('input', function() { s.strategyFields[this.getAttribute('data-field')] = this.value; });",
456
423
  " }",
457
- " var sfCheckInputs = document.querySelectorAll('#wizard-content input[type=\"checkbox\"][data-field]');",
458
- " for(var i = 0; i < sfCheckInputs.length; i++) {",
459
- " sfCheckInputs[i].addEventListener('change', function() {",
424
+ " const sfCheckInputs = document.querySelectorAll('#wizard-content input[type=\"checkbox\"][data-field]');",
425
+ " for(const sfCheckInput of sfCheckInputs) {",
426
+ " sfCheckInput.addEventListener('change', function() {",
460
427
  " if(this.checked) { s.strategyFields[this.getAttribute('data-field')] = true; }",
461
428
  " else { delete s.strategyFields[this.getAttribute('data-field')]; }",
462
429
  " });",
463
430
  " }",
464
431
  // Step 3: flag checkboxes and hide selector.
465
- " var flagCbs = document.querySelectorAll('input[name^=\"flag-\"]');",
466
- " for(var i = 0; i < flagCbs.length; i++) {",
467
- " flagCbs[i].addEventListener('change', function() {",
468
- " var flagName = this.name.replace('flag-', '');",
432
+ " const flagCbs = document.querySelectorAll('input[name^=\"flag-\"]');",
433
+ " for(const flagCb of flagCbs) {",
434
+ " flagCb.addEventListener('change', function() {",
435
+ " const flagName = this.name.replace('flag-', '');",
469
436
  " if(this.checked) { s.flags[flagName] = true; } else { delete s.flags[flagName]; }",
470
437
  " });",
471
438
  " }",
472
- " var hideInput = document.getElementById('wizard-hide-selector');",
439
+ " const hideInput = document.getElementById('wizard-hide-selector');",
473
440
  " if(hideInput) hideInput.addEventListener('input', function() { s.hideSelector = this.value; });",
474
- // Step 4: add-domain button and domain inputs with auto-fill for provider tag.
475
- " var addDomainBtn = document.getElementById('wizard-add-domain-btn');",
441
+ // Step 4: add-domain button and domain inputs with auto-fill for service tag. Per-iteration let bindings from for...of.entries() capture the correct index
442
+ // in each closure — no IIFE wrapper needed.
443
+ " const addDomainBtn = document.getElementById('wizard-add-domain-btn');",
476
444
  " if(addDomainBtn) addDomainBtn.addEventListener('click', addWizardDomain);",
477
- " var domainRows = document.querySelectorAll('.wizard-domain-row');",
478
- " for(var i = 0; i < domainRows.length; i++) {",
479
- " (function(idx) {",
480
- " var row = domainRows[idx];",
481
- " var domInput = row.querySelector('.domain-input');",
482
- " var provInput = row.querySelector('.provider-input');",
483
- " var tagInput = row.querySelector('.provider-tag-input');",
484
- " if(domInput) domInput.addEventListener('input', function() { s.domains[idx].domain = this.value; });",
485
- " if(provInput) provInput.addEventListener('input', function() {",
486
- " s.domains[idx].provider = this.value;",
487
- " var tag = autoProviderTag(this.value);",
488
- " s.domains[idx].providerTag = tag;",
489
- " if(tagInput) tagInput.value = tag;",
490
- " });",
491
- " if(tagInput) tagInput.addEventListener('input', function() { s.domains[idx].providerTag = this.value; });",
492
- " })(i);",
445
+ " for(const [ idx, row ] of document.querySelectorAll('.wizard-domain-row').entries()) {",
446
+ " const domInput = row.querySelector('.domain-input');",
447
+ " const provInput = row.querySelector('.provider-input');",
448
+ " const tagInput = row.querySelector('.provider-tag-input');",
449
+ " if(domInput) domInput.addEventListener('input', function() { s.domains[idx].domain = this.value; });",
450
+ " if(provInput) provInput.addEventListener('input', function() {",
451
+ " s.domains[idx].service = this.value;",
452
+ " const tag = autoServiceTag(this.value);",
453
+ " s.domains[idx].serviceTag = tag;",
454
+ " if(tagInput) tagInput.value = tag;",
455
+ " });",
456
+ " if(tagInput) tagInput.addEventListener('input', function() { s.domains[idx].serviceTag = this.value; });",
493
457
  " }",
494
458
  // Step 5: description textarea.
495
- " var descInput = document.getElementById('wizard-description');",
459
+ " const descInput = document.getElementById('wizard-description');",
496
460
  " if(descInput) descInput.addEventListener('input', function() { s.description = this.value; });",
497
461
  " }",
498
462
  // Validate the current profile wizard step. Returns an error message or empty string if valid.
499
463
  " function validateProfileStep(step) {",
500
- " var s = profileWizard.state;",
464
+ " const s = profileWizard.state;",
501
465
  " if(step === 1) {",
502
466
  " if(!s.editMode) {",
503
467
  " if(!s.profileName.trim()) return 'Profile name is required.';",
@@ -508,10 +472,9 @@ export function generateChannelsSubtabScript() {
508
472
  " if(!s.baseProfile) return 'Select a base profile.';",
509
473
  " }",
510
474
  " if(step === 2) {",
511
- " var valStrat = window.__wizardStrategies.find(function(st) { return st.id === s.strategy; });",
475
+ " const valStrat = window.__wizardStrategies.find((st) => st.id === s.strategy);",
512
476
  " if(valStrat) {",
513
- " for(var vi = 0; vi < valStrat.fields.length; vi++) {",
514
- " var vf = valStrat.fields[vi];",
477
+ " for(const vf of valStrat.fields) {",
515
478
  " if(vf.required && (vf.type === 'text') && !(s.strategyFields[vf.id] || '').trim()) {",
516
479
  " return vf.label.replace(/ \\(CSS\\)/, '') + ' is required for the ' + valStrat.name + ' strategy.';",
517
480
  " }",
@@ -519,63 +482,61 @@ export function generateChannelsSubtabScript() {
519
482
  " }",
520
483
  " }",
521
484
  " if(step === 4) {",
522
- " var hasDomain = s.domains.some(function(d) { return d.domain.trim(); });",
485
+ " const hasDomain = s.domains.some((d) => d.domain.trim());",
523
486
  " if(!hasDomain) return 'At least one domain is required.';",
524
487
  " }",
525
488
  " return '';",
526
489
  " }",
527
490
  // Open the profile builder wizard and reset state for a new profile.
528
- " window.openWizard = function() {",
491
+ " window.openWizard = () => {",
529
492
  " profileWizard.state = {",
530
493
  " editKey: null, editMode: false, profileName: '', baseProfile: '', strategy: 'none',",
531
494
  " strategyFields: {}, flags: {}, hideSelector: '',",
532
- " domains: [{ domain: '', provider: '', providerTag: '' }], description: ''",
495
+ " domains: [{ domain: '', service: '', serviceTag: '' }], description: ''",
533
496
  " };",
534
- " profileWizard.open({ title: 'New Provider Profile' });",
497
+ " profileWizard.open({ title: 'New Service Profile' });",
535
498
  " };",
536
499
  // Add another domain row to step 4 and re-render.
537
500
  " function addWizardDomain() {",
538
- " profileWizard.state.domains.push({ domain: '', provider: '', providerTag: '' });",
501
+ " profileWizard.state.domains.push({ domain: '', service: '', serviceTag: '' });",
539
502
  " renderProfileStep(profileWizard.getStep());",
540
503
  " }",
541
504
  // Save the profile to the server.
542
- " window.saveProfile = async function(andTest) {",
543
- " var s = profileWizard.state;",
544
- " var profile = { extends: s.baseProfile };",
505
+ " window.saveProfile = async (andTest) => {",
506
+ " const s = profileWizard.state;",
507
+ " const profile = { extends: s.baseProfile };",
545
508
  " if(s.strategy !== 'none') {",
546
- " var cs = { strategy: s.strategy };",
547
- " var saveStrat = window.__wizardStrategies.find(function(st) { return st.id === s.strategy; });",
509
+ " const cs = { strategy: s.strategy };",
510
+ " const saveStrat = window.__wizardStrategies.find((st) => st.id === s.strategy);",
548
511
  " if(saveStrat) {",
549
- " for(var si = 0; si < saveStrat.fields.length; si++) {",
550
- " var sf = saveStrat.fields[si];",
551
- " var sfVal = s.strategyFields[sf.id];",
512
+ " for(const sf of saveStrat.fields) {",
513
+ " const sfVal = s.strategyFields[sf.id];",
552
514
  " if(sf.type === 'text' && sfVal) { cs[sf.id] = sfVal; }",
553
515
  " else if(sf.type === 'boolean' && sfVal) { cs[sf.id] = true; }",
554
516
  " }",
555
517
  " }",
556
518
  " profile.channelSelection = cs;",
557
519
  " }",
558
- " var flagKeys = Object.keys(s.flags).filter(function(k) { return s.flags[k]; });",
559
- " for(var i = 0; i < flagKeys.length; i++) { profile[flagKeys[i]] = true; }",
520
+ " const flagKeys = Object.keys(s.flags).filter((k) => s.flags[k]);",
521
+ " for(const fk of flagKeys) { profile[fk] = true; }",
560
522
  " if(s.hideSelector) profile.hideSelector = s.hideSelector;",
561
523
  " if(s.description) profile.description = s.description;",
562
- " var domains = {};",
563
- " for(var i = 0; i < s.domains.length; i++) {",
564
- " var d = s.domains[i];",
524
+ " const domains = {};",
525
+ " for(const d of s.domains) {",
565
526
  " if(d.domain.trim()) {",
566
527
  " domains[d.domain.trim()] = { profile: s.profileName };",
567
- " if(d.provider.trim()) domains[d.domain.trim()].provider = d.provider.trim();",
568
- " if(d.providerTag.trim()) domains[d.domain.trim()].providerTag = d.providerTag.trim();",
528
+ " if(d.service.trim()) domains[d.domain.trim()].service = d.service.trim();",
529
+ " if(d.serviceTag.trim()) domains[d.domain.trim()].serviceTag = d.serviceTag.trim();",
569
530
  " }",
570
531
  " }",
571
- " var body = { domains: domains, key: s.profileName, profile: profile };",
532
+ " const body = { domains: domains, key: s.profileName, profile: profile };",
572
533
  " try {",
573
- " var res = await fetch('/config/profiles', {",
574
- " method: 'POST',",
534
+ " const res = await fetch('/config/profiles', {",
535
+ " body: JSON.stringify(body),",
575
536
  " headers: { 'Content-Type': 'application/json' },",
576
- " body: JSON.stringify(body)",
537
+ " method: 'POST'",
577
538
  " });",
578
- " var data = await res.json();",
539
+ " const data = await res.json();",
579
540
  " if(data.success) {",
580
541
  " profileWizard.close();",
581
542
  " if(data.panelHtml) { applyProfilePanel(data.panelHtml); }",
@@ -586,27 +547,27 @@ export function generateChannelsSubtabScript() {
586
547
  " profileWizard.setError(data.error || 'Failed to save profile.');",
587
548
  " }",
588
549
  " } catch(err) {",
589
- " console.error('Profile save error:', err);",
550
+ " console.error('Profile save request failed.', err);",
590
551
  " profileWizard.setError('Request failed.');",
591
552
  " }",
592
553
  " };",
593
554
  // Test flow: open a browser to a URL with the profile applied and allow selector checking.
594
- " var testSelectors = {};",
555
+ " let testSelectors = {};",
595
556
  // Start a profile test by opening the browser to a URL.
596
- " window.startProfileTest = async function(url) {",
597
- " var s = profileWizard.state;",
557
+ " window.startProfileTest = async (url) => {",
558
+ " const s = profileWizard.state;",
598
559
  " testSelectors = {};",
599
560
  " if(s.strategy !== 'none' && s.strategyFields.matchSelector) testSelectors.matchSelector = s.strategyFields.matchSelector;",
600
561
  " if(s.strategyFields.playSelector) testSelectors.playSelector = s.strategyFields.playSelector;",
601
562
  " if(s.hideSelector) testSelectors.hideSelector = s.hideSelector;",
602
- " var testUrl = url.indexOf('://') === -1 ? 'https://' + url : url;",
563
+ " const testUrl = url.includes('://') ? url : 'https://' + url;",
603
564
  " try {",
604
- " var res = await fetch('/config/profiles/test', {",
605
- " method: 'POST',",
565
+ " const res = await fetch('/config/profiles/test', {",
566
+ " body: JSON.stringify({ url: testUrl }),",
606
567
  " headers: { 'Content-Type': 'application/json' },",
607
- " body: JSON.stringify({ url: testUrl })",
568
+ " method: 'POST'",
608
569
  " });",
609
- " var data = await res.json();",
570
+ " const data = await res.json();",
610
571
  " if(data.success) {",
611
572
  " document.getElementById('test-modal').style.display = 'flex';",
612
573
  " document.getElementById('test-selector-results').style.display = 'none';",
@@ -615,31 +576,30 @@ export function generateChannelsSubtabScript() {
615
576
  " } else {",
616
577
  " showToast('Failed to start test: ' + (data.error || 'Unknown error.'), 'error');",
617
578
  " }",
618
- " } catch(e) { showToast('Failed to start test.', 'error'); }",
579
+ " } catch(e) { console.error('Profile test failed to start.', e); showToast('Failed to start test.', 'error'); }",
619
580
  " };",
620
581
  // Check CSS selectors against the live page.
621
- " window.checkSelectors = async function() {",
582
+ " window.checkSelectors = async () => {",
622
583
  " if(Object.keys(testSelectors).length === 0) return;",
623
584
  " try {",
624
- " var res = await fetch('/config/profiles/test/check', {",
625
- " method: 'POST',",
585
+ " const res = await fetch('/config/profiles/test/check', {",
586
+ " body: JSON.stringify({ selectors: testSelectors }),",
626
587
  " headers: { 'Content-Type': 'application/json' },",
627
- " body: JSON.stringify({ selectors: testSelectors })",
588
+ " method: 'POST'",
628
589
  " });",
629
- " var data = await res.json();",
630
- " var resultsDiv = document.getElementById('test-selector-results');",
590
+ " const data = await res.json();",
591
+ " const resultsDiv = document.getElementById('test-selector-results');",
631
592
  " if(!data.success || !data.results) {",
632
593
  " resultsDiv.innerHTML = '<p style=\"color: var(--status-error-text);\">Check failed.</p>';",
633
594
  " resultsDiv.style.display = 'block';",
634
595
  " return;",
635
596
  " }",
636
- " var html = '<table style=\"width: 100%; font-size: 13px; margin: 12px 0;\">';",
637
- " var names = Object.keys(data.results);",
638
- " for(var i = 0; i < names.length; i++) {",
639
- " var name = names[i];",
640
- " var r = data.results[name];",
641
- " var color = r.valid && r.count > 0 ? 'var(--status-success-text)' : 'var(--status-error-text)';",
642
- " var text = !r.valid ? 'Invalid selector' : r.count + ' element(s)';",
597
+ " let html = '<table style=\"width: 100%; font-size: 13px; margin: 12px 0;\">';",
598
+ " const names = Object.keys(data.results);",
599
+ " for(const name of names) {",
600
+ " const r = data.results[name];",
601
+ " const color = r.valid && r.count > 0 ? 'var(--status-success-text)' : 'var(--status-error-text)';",
602
+ " const text = !r.valid ? 'Invalid selector' : r.count + ' element(s)';",
643
603
  " html += '<tr><td style=\"padding: 4px 8px;\">' + esc(name) + '</td>';",
644
604
  " html += '<td style=\"padding: 4px 8px; color: ' + color + ';\">' + text + '</td></tr>';",
645
605
  " }",
@@ -647,59 +607,59 @@ export function generateChannelsSubtabScript() {
647
607
  " resultsDiv.innerHTML = html;",
648
608
  " resultsDiv.style.display = 'block';",
649
609
  " } catch(e) {",
650
- " var resultsDiv = document.getElementById('test-selector-results');",
610
+ " const resultsDiv = document.getElementById('test-selector-results');",
651
611
  " resultsDiv.innerHTML = '<p style=\"color: var(--status-error-text);\">Check request failed.</p>';",
652
612
  " resultsDiv.style.display = 'block';",
653
613
  " }",
654
614
  " };",
655
615
  // End the test flow and close the test dialog.
656
- " window.endProfileTest = async function() {",
616
+ " window.endProfileTest = async () => {",
657
617
  " try {",
658
- " var res = await fetch('/config/profiles/test/done', { method: 'POST' });",
659
- " var data = await res.json();",
618
+ " const res = await fetch('/config/profiles/test/done', { method: 'POST' });",
619
+ " const data = await res.json();",
660
620
  " document.getElementById('test-modal').style.display = 'none';",
661
621
  " if(data.panelHtml) { applyProfilePanel(data.panelHtml); }",
662
622
  " } catch(err) {",
663
- " console.error('End profile test error:', err);",
623
+ " console.error('Profile test end request failed.', err);",
664
624
  " document.getElementById('test-modal').style.display = 'none';",
665
625
  " }",
666
626
  " };",
667
- // Read embedded JSON data for the browse modal. Provider list and guide URLs are embedded server-side. Channel state (lineup matching) is provided by
627
+ // Read embedded JSON data for the browse modal. Service list and guide URLs are embedded server-side. Channel state (lineup matching) is provided by
668
628
  // the annotated discovery response, not by embedded data.
669
- " var browseProviders = [];",
670
- " var browseGuideUrls = {};",
629
+ " let browseServices = [];",
630
+ " let browseGuideUrls = {};",
671
631
  " try {",
672
- " var pEl = document.getElementById('browse-providers-data');",
673
- " if(pEl) browseProviders = JSON.parse(pEl.textContent || '[]');",
674
- " var gEl = document.getElementById('browse-guide-urls-data');",
632
+ " const pEl = document.getElementById('browse-services-data');",
633
+ " if(pEl) browseServices = JSON.parse(pEl.textContent || '[]');",
634
+ " const gEl = document.getElementById('browse-guide-urls-data');",
675
635
  " if(gEl) browseGuideUrls = JSON.parse(gEl.textContent || '{}');",
676
- " } catch(e) {}",
677
- // Browse Channels wizard. Two steps: Provider (picker grid) and Channels (discovery + channel list). Uses the shared wizard controller for step navigation
678
- // and indicator updates. The state object tracks the selected provider slug and label, and the filtered provider list for the current session.
679
- " var browseWizard = createWizardController({",
636
+ " } catch(e) { console.warn('Embedded browse services data failed to parse.', e); }",
637
+ // Browse Channels wizard. Two steps: Service (picker grid) and Channels (discovery + channel list). Uses the shared wizard controller for step navigation
638
+ // and indicator updates. The state object tracks the selected service slug and label, and the filtered service list for the current session.
639
+ " const browseWizard = createWizardController({",
680
640
  " contentId: 'browse-content',",
681
641
  " modalId: 'browse-modal',",
682
642
  " onRender: renderBrowseStep,",
683
- " onValidate: function(step) { return (step === 1 && !browseWizard.state.slug) ? 'Select a provider.' : ''; },",
643
+ " onValidate: (step) => (step === 1 && !browseWizard.state.slug) ? 'Select a service.' : '',",
684
644
  " stepCount: 2,",
685
645
  " titleId: 'browse-title'",
686
646
  " });",
687
- // Open the browse modal. If only one provider is available (or in the filter), skip step 1 and open directly on step 2 with the provider pre-selected.
688
- " window.openBrowseModal = function() {",
689
- // Determine which providers to show. Filter to providers that are enabled in the provider filter if a filter is active.
690
- " var enabledFilter = [];",
691
- " var chips = document.querySelectorAll('.provider-chip');",
692
- " for(var i = 0; i < chips.length; i++) {",
693
- " var tag = chips[i].getAttribute('data-tag');",
647
+ // Open the browse modal. If only one service is available (or in the filter), skip step 1 and open directly on step 2 with the service pre-selected.
648
+ " window.openBrowseModal = () => {",
649
+ // Determine which services to show. Filter to services that are enabled in the service filter if a filter is active.
650
+ " const enabledFilter = [];",
651
+ " const chips = document.querySelectorAll('.provider-chip');",
652
+ " for(const chip of chips) {",
653
+ " const tag = chip.getAttribute('data-tag');",
694
654
  " if(tag) enabledFilter.push(tag);",
695
655
  " }",
696
- " var available = browseProviders;",
656
+ " let available = browseServices;",
697
657
  " if(enabledFilter.length > 0) {",
698
- " available = browseProviders.filter(function(p) { return enabledFilter.indexOf(p.slug) !== -1; });",
658
+ " available = browseServices.filter((p) => enabledFilter.includes(p.slug));",
699
659
  " }",
700
- // If no discoverable providers match the filter, show all providers.
701
- " if(available.length === 0) available = browseProviders;",
702
- // If exactly one provider is available, skip the picker and go straight to channel discovery on step 2.
660
+ // If no discoverable services match the filter, show all services.
661
+ " if(available.length === 0) available = browseServices;",
662
+ // If exactly one service is available, skip the picker and go straight to channel discovery on step 2.
703
663
  " if(available.length === 1) {",
704
664
  " browseWizard.state = { available: available, label: available[0].label, slug: available[0].slug };",
705
665
  " browseWizard.open({ highestStep: 2, step: 2 });",
@@ -708,57 +668,56 @@ export function generateChannelsSubtabScript() {
708
668
  " browseWizard.state = { available: available, label: null, slug: null };",
709
669
  " browseWizard.open();",
710
670
  " };",
711
- // Select a provider from the picker grid and advance to step 2.
712
- " function selectBrowseProvider(slug, label) {",
671
+ // Select a service from the picker grid and advance to step 2.
672
+ " function selectBrowseService(slug, label) {",
713
673
  " browseWizard.state.slug = slug;",
714
674
  " browseWizard.state.label = label;",
715
675
  " browseWizard.next();",
716
676
  " }",
717
- // Render the current browse step. Step 1: provider picker grid. Step 2: channel discovery and list.
677
+ // Render the current browse step. Step 1: service picker grid. Step 2: channel discovery and list.
718
678
  " function renderBrowseStep(step) {",
719
- " var content = document.getElementById('browse-content');",
720
- // Show/hide navigation buttons. Back is only available on step 2 when there are multiple providers.
679
+ " const content = document.getElementById('browse-content');",
680
+ // Show/hide navigation buttons. Back is only available on step 2 when there are multiple services.
721
681
  " if(step > 1 && browseWizard.state.available.length > 1) { browseWizard.show('browse-back'); }",
722
682
  " else { browseWizard.hide('browse-back'); }",
723
683
  // Reset title and apply button on each render.
724
684
  " browseWizard.setTitle('Browse Channels');",
725
685
  " browseWizard.hide('browse-add-btn');",
726
- // Step 1: Provider picker grid. Clear the provider selection so the user must pick again (prevents the step indicator from jumping to step 2 with a stale
727
- // provider after navigating back).
686
+ // Step 1: Service picker grid. Clear the service selection so the user must pick again (prevents the step indicator from jumping to step 2 with a stale
687
+ // service after navigating back).
728
688
  " if(step === 1) {",
729
689
  " browseWizard.state.slug = null;",
730
690
  " browseWizard.state.label = null;",
731
- " var available = browseWizard.state.available;",
732
- " var html = '<p class=\"wizard-hint\">Select a provider to see its available channels.</p>';",
691
+ " const available = browseWizard.state.available;",
692
+ " let html = '<p class=\"wizard-hint\">Select a service to see its available channels.</p>';",
733
693
  " html += '<div class=\"wizard-provider-grid\">';",
734
- " for(var j = 0; j < available.length; j++) {",
735
- " var p = available[j];",
694
+ " for(const p of available) {",
736
695
  " html += '<div class=\"wizard-provider-card\" data-slug=\"' + esc(p.slug) + '\" data-label=\"' + esc(p.label) + '\">';",
737
- " html += providerIconHtml(p.domain, esc(p.label), 'provider-icon', 'provider-icon-text', 'both', p.iconUrl || '');",
696
+ " html += serviceIconHtml(p.domain, esc(p.label), 'provider-icon', 'provider-icon-text', 'both', p.iconUrl || '');",
738
697
  " html += '</div>';",
739
698
  " }",
740
699
  " html += '</div>';",
741
700
  " content.innerHTML = html;",
742
- // Attach click handlers to provider cards programmatically. The handler reads slug and label from data attributes, avoiding inline onclick and global scope.
743
- " var cards = content.querySelectorAll('.wizard-provider-card');",
744
- " for(var ci = 0; ci < cards.length; ci++) {",
745
- " cards[ci].addEventListener('click', function() {",
746
- " selectBrowseProvider(this.getAttribute('data-slug'), this.getAttribute('data-label'));",
701
+ // Attach click handlers to service cards programmatically. The handler reads slug and label from data attributes, avoiding inline onclick and global scope.
702
+ " const cards = content.querySelectorAll('.wizard-provider-card');",
703
+ " for(const card of cards) {",
704
+ " card.addEventListener('click', function() {",
705
+ " selectBrowseService(this.getAttribute('data-slug'), this.getAttribute('data-label'));",
747
706
  " });",
748
707
  " }",
749
708
  " return;",
750
709
  " }",
751
710
  // Step 2: Fetch discovered channels and render the channel list.
752
- " var slug = browseWizard.state.slug;",
753
- " var label = browseWizard.state.label;",
711
+ " const slug = browseWizard.state.slug;",
712
+ " const label = browseWizard.state.label;",
754
713
  // Show a spinner while fetching channels.
755
714
  " content.innerHTML = '<div class=\"wizard-spinner\">Discovering channels from ' + esc(label) + '...</div>';",
756
- // Fetch discovered channels from the provider endpoint.
757
- " (async function() {",
715
+ // Fetch discovered channels from the service endpoint.
716
+ " (async () => {",
758
717
  " try {",
759
- " var r = await fetch('/providers/' + encodeURIComponent(slug) + '/channels?lineup=true');",
760
- " if(!r.ok) { var d = await r.json(); throw new Error(d.error || 'Discovery failed.'); }",
761
- " var channels = await r.json();",
718
+ " const r = await fetch('/services/' + encodeURIComponent(slug) + '/channels?lineup=true');",
719
+ " if(!r.ok) { const d = await r.json(); throw new Error(d.error || 'Discovery failed.'); }",
720
+ " const channels = await r.json();",
762
721
  " if(browseWizard.state.slug !== slug) return;",
763
722
  " renderBrowseChannelList(channels, slug, label);",
764
723
  " } catch(err) {",
@@ -769,7 +728,7 @@ export function generateChannelsSubtabScript() {
769
728
  " })();",
770
729
  " }",
771
730
  // Render the channel list with three-state checkboxes. Each discovered channel carries an optional lineup field from the server's annotation layer.
772
- // When lineup is present, the channel exists in the user's lineup. When lineup.currentTag matches the browsed provider slug, the channel is "current"
731
+ // When lineup is present, the channel exists in the user's lineup. When lineup.currentTag matches the browsed service slug, the channel is "current"
773
732
  // (checked). When lineup is present but the tag differs, the channel is "switch" (indeterminate). When lineup is absent, the channel is "new" (empty).
774
733
  " function renderBrowseChannelList(channels, slug, label) {",
775
734
  " if(!channels || channels.length === 0) {",
@@ -779,8 +738,8 @@ export function generateChannelsSubtabScript() {
779
738
  " return;",
780
739
  " }",
781
740
  // Build the channel list HTML with hint, legend, search filter, and three-state checkboxes.
782
- " var html = '';",
783
- " html += '<p class=\"wizard-hint\">Check to add or switch to this provider. Uncheck to remove. Changes take effect when you click Apply.</p>';",
741
+ " let html = '';",
742
+ " html += '<p class=\"wizard-hint\">Check to add or switch to this service. Uncheck to remove. Changes take effect when you click Apply.</p>';",
784
743
  " html += '<div class=\"browse-toolbar\">';",
785
744
  " html += '<label class=\"browse-select-all\">';",
786
745
  " html += '<input type=\"checkbox\" id=\"browse-select-all-cb\">';",
@@ -789,18 +748,17 @@ export function generateChannelsSubtabScript() {
789
748
  " html += '</div>';",
790
749
  " html += '<div class=\"browse-legend\">';",
791
750
  " html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" disabled> New channel</span>';",
792
- " html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" class=\"browse-legend-indeterminate\" disabled> Available via another provider</span>';",
793
- " html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" checked disabled> Active on this provider</span>';",
751
+ " html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" class=\"browse-legend-indeterminate\" disabled> Available via another service</span>';",
752
+ " html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" checked disabled> Active on this service</span>';",
794
753
  " html += '</div>';",
795
754
  " html += '<div class=\"browse-channel-list\" id=\"browse-channel-list\">';",
796
755
  // Render each discovered channel. The lineup field from the server annotation determines the checkbox state.
797
- " for(var i = 0; i < channels.length; i++) {",
798
- " var ch = channels[i];",
799
- " var lu = ch.lineup;",
756
+ " for(const ch of channels) {",
757
+ " const lu = ch.lineup;",
800
758
  // Determine the channel's state from the server-provided lineup annotation. Four states: new (no lineup data), disabled (predefined but disabled),
801
- // switch (exists via a different provider), current (already using this provider).
802
- " var state = 'new';",
803
- " var stateLabel = '';",
759
+ // switch (exists via a different service), current (already using this service).
760
+ " let state = 'new';",
761
+ " let stateLabel = '';",
804
762
  " if(lu) {",
805
763
  " if(!lu.enabled) {",
806
764
  " state = 'disabled';",
@@ -810,16 +768,16 @@ export function generateChannelsSubtabScript() {
810
768
  " stateLabel = lu.source === 'predefined' ? 'Predefined' : 'User channel';",
811
769
  " } else {",
812
770
  " state = 'switch';",
813
- " stateLabel = 'via ' + lu.currentProvider;",
771
+ " stateLabel = 'via ' + lu.currentService;",
814
772
  " }",
815
773
  " }",
816
- " var itemClass = 'browse-channel-item browse-state-' + state;",
817
- " var searchName = ((lu && lu.displayName) ? lu.displayName : ch.name).toLowerCase();",
818
- " html += '<label class=\"' + itemClass + '\" data-name=\"' + esc(searchName).replace(/\"/g, '&quot;') + '\">';",
774
+ " const itemClass = 'browse-channel-item browse-state-' + state;",
775
+ " const searchName = ((lu && lu.displayName) ? lu.displayName : ch.name).toLowerCase();",
776
+ " html += '<label class=\"' + itemClass + '\" data-name=\"' + esc(searchName).replaceAll('\"', '&quot;') + '\">';",
819
777
  // Checkbox with data attributes for state tracking and submission.
820
778
  " html += '<input type=\"checkbox\" class=\"browse-channel-cb\"';",
821
- " html += ' data-name=\"' + esc(ch.name).replace(/\"/g, '&quot;') + '\"';",
822
- " html += ' data-selector=\"' + esc(ch.channelSelector).replace(/\"/g, '&quot;') + '\"';",
779
+ " html += ' data-name=\"' + esc(ch.name).replaceAll('\"', '&quot;') + '\"';",
780
+ " html += ' data-selector=\"' + esc(ch.channelSelector).replaceAll('\"', '&quot;') + '\"';",
823
781
  " html += ' data-original=\"' + state + '\"';",
824
782
  " if(lu) html += ' data-canonical=\"' + esc(lu.canonicalKey) + '\" data-has-alternatives=\"' + (lu.hasAlternatives ? 'true' : 'false') + '\"';",
825
783
  " if(ch.stationId) html += ' data-station-id=\"' + esc(ch.stationId) + '\"';",
@@ -827,7 +785,7 @@ export function generateChannelsSubtabScript() {
827
785
  " html += '>';",
828
786
  // Channel name with optional logo and affiliate. When the channel matches a lineup entry, use the predefined display name so the browse modal shows the
829
787
  // same names as the channels table. For new channels (no lineup match), use the raw discovery name.
830
- " var channelName = (lu && lu.displayName) ? lu.displayName : ch.name;",
788
+ " const channelName = (lu && lu.displayName) ? lu.displayName : ch.name;",
831
789
  " html += '<span class=\"browse-channel-name\">';",
832
790
  " html += channelDisplayHtml(lu ? lu.logoUrl || '' : '', esc(channelName), 'browse-channel-logo', 'browse-channel-text', 'both');",
833
791
  " if(ch.affiliate) html += ' <span class=\"browse-affiliate\">(' + esc(ch.affiliate) + ')</span>';",
@@ -842,21 +800,21 @@ export function generateChannelsSubtabScript() {
842
800
  " html += '</div>';",
843
801
  " document.getElementById('browse-content').innerHTML = html;",
844
802
  " browseWizard.setTitle('Browse Channels <span class=\"browse-header-count\">(' + esc(label) + ' \\u2014 ' + channels.length + ' channels)</span>');",
845
- // Set indeterminate state on checkboxes for channels that exist via a different provider. The indeterminate property cannot be set via HTML attributes —
803
+ // Set indeterminate state on checkboxes for channels that exist via a different service. The indeterminate property cannot be set via HTML attributes —
846
804
  // it must be set via JavaScript after the elements are in the DOM.
847
- " var switchCbs = document.querySelectorAll('.browse-channel-cb[data-original=\"switch\"]');",
848
- " for(var j = 0; j < switchCbs.length; j++) switchCbs[j].indeterminate = true;",
805
+ " const switchCbs = document.querySelectorAll('.browse-channel-cb[data-original=\"switch\"]');",
806
+ " for(const sc of switchCbs) sc.indeterminate = true;",
849
807
  // Set the legend's indeterminate checkbox visual.
850
- " var legendInd = document.querySelector('.browse-legend-indeterminate');",
808
+ " const legendInd = document.querySelector('.browse-legend-indeterminate');",
851
809
  " if(legendInd) legendInd.indeterminate = true;",
852
810
  // Attach event handlers programmatically to keep them scoped to the IIFE closure.
853
- " var selectAllCb = document.getElementById('browse-select-all-cb');",
811
+ " const selectAllCb = document.getElementById('browse-select-all-cb');",
854
812
  " if(selectAllCb) selectAllCb.addEventListener('change', function() { toggleBrowseSelectAll(this.checked); });",
855
- " var searchInput = document.getElementById('browse-search');",
813
+ " const searchInput = document.getElementById('browse-search');",
856
814
  " if(searchInput) searchInput.addEventListener('input', filterBrowseChannels);",
857
- " var channelCbs = document.querySelectorAll('.browse-channel-cb');",
858
- " for(var ci = 0; ci < channelCbs.length; ci++) {",
859
- " channelCbs[ci].addEventListener('change', function() { onBrowseCheckboxChange(this); });",
815
+ " const channelCbs = document.querySelectorAll('.browse-channel-cb');",
816
+ " for(const cb of channelCbs) {",
817
+ " cb.addEventListener('change', function() { onBrowseCheckboxChange(this); });",
860
818
  " }",
861
819
  // Show the apply button and update counts.
862
820
  " browseWizard.show('browse-add-btn');",
@@ -867,8 +825,8 @@ export function generateChannelsSubtabScript() {
867
825
  // disabled channels stay empty on uncheck.
868
826
  " function onBrowseCheckboxChange(cb) {",
869
827
  " if(!cb.checked) {",
870
- " var original = cb.getAttribute('data-original');",
871
- " var hasAlt = cb.getAttribute('data-has-alternatives') === 'true';",
828
+ " const original = cb.getAttribute('data-original');",
829
+ " const hasAlt = cb.getAttribute('data-has-alternatives') === 'true';",
872
830
  " if((original === 'switch') || ((original === 'current') && hasAlt)) {",
873
831
  " cb.indeterminate = true;",
874
832
  " }",
@@ -876,19 +834,18 @@ export function generateChannelsSubtabScript() {
876
834
  " updateBrowseCount();",
877
835
  " }",
878
836
  // Toggle all actionable checkboxes. Select-all checks all channels and clears indeterminate. Deselect-all unchecks new and disabled channels, and restores
879
- // switch channels and current channels with alternatives to indeterminate (indicating the channel exists but is not selected for this provider).
837
+ // switch channels and current channels with alternatives to indeterminate (indicating the channel exists but is not selected for this service).
880
838
  " function toggleBrowseSelectAll(checked) {",
881
- " var cbs = document.querySelectorAll('.browse-channel-cb');",
882
- " for(var i = 0; i < cbs.length; i++) {",
883
- " var cb = cbs[i];",
884
- " var original = cb.getAttribute('data-original');",
885
- " var row = cb.closest('.browse-channel-item');",
839
+ " const cbs = document.querySelectorAll('.browse-channel-cb');",
840
+ " for(const cb of cbs) {",
841
+ " const original = cb.getAttribute('data-original');",
842
+ " const row = cb.closest('.browse-channel-item');",
886
843
  " if(row && row.style.display === 'none') continue;",
887
844
  " if(checked) {",
888
845
  " cb.checked = true;",
889
846
  " cb.indeterminate = false;",
890
847
  " } else {",
891
- " var hasAlt = cb.getAttribute('data-has-alternatives') === 'true';",
848
+ " const hasAlt = cb.getAttribute('data-has-alternatives') === 'true';",
892
849
  " cb.checked = false;",
893
850
  " cb.indeterminate = (original === 'switch') || ((original === 'current') && hasAlt);",
894
851
  " }",
@@ -897,143 +854,142 @@ export function generateChannelsSubtabScript() {
897
854
  " }",
898
855
  // Filter the channel list by search text. Hides rows whose channel name does not contain the search term.
899
856
  " function filterBrowseChannels() {",
900
- " var query = (document.getElementById('browse-search').value || '').toLowerCase();",
901
- " var rows = document.querySelectorAll('.browse-channel-item');",
902
- " for(var i = 0; i < rows.length; i++) {",
903
- " var name = rows[i].getAttribute('data-name') || '';",
904
- " rows[i].style.display = (!query || name.indexOf(query) !== -1) ? '' : 'none';",
857
+ " const query = (document.getElementById('browse-search').value || '').toLowerCase();",
858
+ " const rows = document.querySelectorAll('.browse-channel-item');",
859
+ " for(const row of rows) {",
860
+ " const name = row.getAttribute('data-name') || '';",
861
+ " row.style.display = (!query || name.includes(query)) ? '' : 'none';",
905
862
  " }",
906
863
  " }",
907
864
  // Update the apply button text with categorized action counts. Computes add, switch, and remove counts based on checkbox state vs data-original.
908
865
  " function updateBrowseCount() {",
909
- " var cbs = document.querySelectorAll('.browse-channel-cb');",
910
- " var addCount = 0;",
911
- " var switchCount = 0;",
912
- " var removeCount = 0;",
913
- " for(var i = 0; i < cbs.length; i++) {",
914
- " var cb = cbs[i];",
915
- " var original = cb.getAttribute('data-original');",
916
- " var row = cb.closest('.browse-channel-item');",
866
+ " const cbs = document.querySelectorAll('.browse-channel-cb');",
867
+ " let addCount = 0;",
868
+ " let switchCount = 0;",
869
+ " let removeCount = 0;",
870
+ " for(const cb of cbs) {",
871
+ " const original = cb.getAttribute('data-original');",
872
+ " const row = cb.closest('.browse-channel-item');",
917
873
  " if(row && row.style.display === 'none') continue;",
918
874
  " if((original === 'new' || original === 'disabled') && cb.checked) addCount++;",
919
875
  " if(original === 'switch' && cb.checked && !cb.indeterminate) switchCount++;",
920
876
  " if(original === 'current' && !cb.checked) removeCount++;",
921
877
  " }",
922
- " var btn = document.getElementById('browse-add-btn');",
923
- " var total = addCount + switchCount + removeCount;",
924
- " var parts = [];",
878
+ " const btn = document.getElementById('browse-add-btn');",
879
+ " const total = addCount + switchCount + removeCount;",
880
+ " const parts = [];",
925
881
  " if(addCount > 0) parts.push('add ' + addCount);",
926
882
  " if(switchCount > 0) parts.push('switch ' + switchCount);",
927
883
  " if(removeCount > 0) parts.push('remove ' + removeCount);",
928
884
  " btn.textContent = total > 0 ? 'Apply (' + parts.join(', ') + ')' : 'Apply';",
929
885
  " btn.disabled = total === 0;",
930
886
  // Update select-all checkbox state.
931
- " var allCbs = document.querySelectorAll('.browse-channel-cb');",
932
- " var allChecked = true;",
933
- " var visibleCount = 0;",
934
- " for(var j = 0; j < allCbs.length; j++) {",
935
- " var r = allCbs[j].closest('.browse-channel-item');",
887
+ " const allCbs = document.querySelectorAll('.browse-channel-cb');",
888
+ " let allChecked = true;",
889
+ " let visibleCount = 0;",
890
+ " for(const ac of allCbs) {",
891
+ " const r = ac.closest('.browse-channel-item');",
936
892
  " if(r && r.style.display === 'none') continue;",
937
893
  " visibleCount++;",
938
- " if(!allCbs[j].checked) allChecked = false;",
894
+ " if(!ac.checked) allChecked = false;",
939
895
  " }",
940
- " var selectAll = document.getElementById('browse-select-all-cb');",
896
+ " const selectAll = document.getElementById('browse-select-all-cb');",
941
897
  " if(selectAll) selectAll.checked = (visibleCount > 0) && allChecked;",
942
898
  " }",
943
899
  // Submit channel changes via the modify endpoint. Collects action types: add (new channels checked), enable (disabled channels checked), switch
944
900
  // remove (current channels unchecked). Each entry includes an action field so the server can handle them appropriately.
945
- " window.submitBrowseChannels = function() {",
946
- " var cbs = document.querySelectorAll('.browse-channel-cb');",
947
- " var guideUrl = browseGuideUrls[browseWizard.state.slug] || '';",
948
- " var entries = [];",
949
- " for(var i = 0; i < cbs.length; i++) {",
950
- " var cb = cbs[i];",
951
- " var original = cb.getAttribute('data-original');",
952
- " var row = cb.closest('.browse-channel-item');",
901
+ " window.submitBrowseChannels = () => {",
902
+ " const cbs = document.querySelectorAll('.browse-channel-cb');",
903
+ " const guideUrl = browseGuideUrls[browseWizard.state.slug] || '';",
904
+ " const entries = [];",
905
+ " for(const cb of cbs) {",
906
+ " const original = cb.getAttribute('data-original');",
907
+ " const row = cb.closest('.browse-channel-item');",
953
908
  " if(row && row.style.display === 'none') continue;",
954
- " var action = null;",
909
+ " let action = null;",
955
910
  " if(original === 'new' && cb.checked) action = 'add';",
956
911
  " if(original === 'disabled' && cb.checked) action = 'enable';",
957
912
  " if(original === 'switch' && cb.checked && !cb.indeterminate) action = 'switch';",
958
913
  " if(original === 'current' && !cb.checked) action = 'remove';",
959
914
  " if(!action) continue;",
960
- " var entry = {",
915
+ " const entry = {",
961
916
  " action: action,",
962
917
  " canonicalKey: cb.getAttribute('data-canonical') || '',",
963
918
  " channelSelector: cb.getAttribute('data-selector'),",
964
919
  " name: cb.getAttribute('data-name'),",
965
- " providerSlug: browseWizard.state.slug,",
920
+ " serviceSlug: browseWizard.state.slug,",
966
921
  " url: guideUrl",
967
922
  " };",
968
- " var sid = cb.getAttribute('data-station-id');",
923
+ " const sid = cb.getAttribute('data-station-id');",
969
924
  " if(sid) entry.stationId = sid;",
970
925
  " entries.push(entry);",
971
926
  " }",
972
927
  " if(entries.length === 0) return;",
973
928
  // Disable the button to prevent double submission.
974
- " var btn = document.getElementById('browse-add-btn');",
929
+ " const btn = document.getElementById('browse-add-btn');",
975
930
  " btn.disabled = true;",
976
931
  " btn.textContent = 'Applying...';",
977
- " (async function() {",
932
+ " (async () => {",
978
933
  " try {",
979
- " var res = await fetch('/config/channels/modify', {",
980
- " method: 'POST',",
934
+ " const res = await fetch('/config/channels/modify', {",
935
+ " body: JSON.stringify({ channels: entries }),",
981
936
  " headers: { 'Content-Type': 'application/json' },",
982
- " body: JSON.stringify({ channels: entries })",
937
+ " method: 'POST'",
983
938
  " });",
984
- " var data = await res.json();",
939
+ " const data = await res.json();",
985
940
  " if(data.success) {",
986
941
  " browseWizard.close();",
987
942
  " showToast(data.message || 'Channels added.', 'success');",
988
- " if(data.patch) { applyChannelPatch(data.patch); }",
943
+ " if(data.patch) { channelTable.applyPatch(data.patch); }",
944
+ " if(data.serviceWarning && typeof showServiceFilterWarning === 'function') { showServiceFilterWarning(data.serviceWarning); }",
989
945
  " } else {",
990
946
  " btn.disabled = false;",
991
947
  " updateBrowseCount();",
992
948
  " showToast('Failed to add channels: ' + (data.error || 'Unknown error.'), 'error');",
993
949
  " }",
994
950
  " } catch(err) {",
995
- " console.error('Browse channels error:', err);",
951
+ " console.error('Browse channels request failed.', err);",
996
952
  " btn.disabled = false;",
997
953
  " updateBrowseCount();",
998
954
  " showToast('Failed to add channels.', 'error');",
999
955
  " }",
1000
956
  " })();",
1001
957
  " };",
1002
- // Read embedded provider data for the setup wizard. Each entry includes domain, iconUrl, displayName, tag, and enabled state from the server.
1003
- " var setupProviders = [];",
958
+ // Read embedded service data for the setup wizard. Each entry includes domain, iconUrl, displayName, tag, and enabled state from the server.
959
+ " let setupServices = [];",
1004
960
  " try {",
1005
- " var spEl = document.getElementById('setup-providers-data');",
1006
- " if(spEl) setupProviders = JSON.parse(spEl.textContent || '[]');",
1007
- " } catch(e) {}",
1008
- // Validate the current setup step. Step 1 collects selected providers, validates at least one is selected, and saves the provider filter to the server
961
+ " const spEl = document.getElementById('setup-services-data');",
962
+ " if(spEl) setupServices = JSON.parse(spEl.textContent || '[]');",
963
+ " } catch(e) { console.warn('Embedded setup services data failed to parse.', e); }",
964
+ // Validate the current setup step. Step 1 collects selected services, validates at least one is selected, and saves the service filter to the server
1009
965
  // asynchronously. Returns a Promise that resolves to an error string or empty string. Step 2+ always passes (no validation needed).
1010
966
  " async function validateSetupStep(step) {",
1011
967
  " if(step === 1) {",
1012
- " var selected = [];",
1013
- " var cbs = document.querySelectorAll('.setup-provider-cb:checked');",
1014
- " for(var i = 0; i < cbs.length; i++) selected.push(cbs[i].value);",
1015
- " if(selected.length === 0) return 'Select at least one provider.';",
1016
- " setupWizard.state.selectedProviders = selected;",
968
+ " const selected = [];",
969
+ " const cbs = document.querySelectorAll('.setup-provider-cb:checked');",
970
+ " for(const cb of cbs) selected.push(cb.value);",
971
+ " if(selected.length === 0) return 'Select at least one service.';",
972
+ " setupWizard.state.selectedServices = selected;",
1017
973
  " setupWizard.state.authIndex = 0;",
1018
974
  " try {",
1019
- " await fetch('/config/provider-filter', {",
1020
- " method: 'POST',",
975
+ " await fetch('/config/service-filter', {",
976
+ " body: JSON.stringify({ enabledServices: selected }),",
1021
977
  " headers: { 'Content-Type': 'application/json' },",
1022
- " body: JSON.stringify({ enabledProviders: selected })",
978
+ " method: 'POST'",
1023
979
  " });",
1024
980
  " return '';",
1025
- " } catch(e) { return 'Failed to save provider selection.'; }",
981
+ " } catch(e) { return 'Failed to save service selection.'; }",
1026
982
  " }",
1027
983
  " return '';",
1028
984
  " }",
1029
- // Provider Setup wizard. Uses the shared wizard controller for step navigation and indicator updates. The state object tracks selected providers and
985
+ // Service Setup wizard. Uses the shared wizard controller for step navigation and indicator updates. The state object tracks selected services and
1030
986
  // the current auth index for the sequential sign-in flow.
1031
- " var setupWizard = createWizardController({",
987
+ " const setupWizard = createWizardController({",
1032
988
  " contentId: 'setup-content',",
1033
989
  " errorId: 'setup-error',",
1034
990
  " modalId: 'setup-modal',",
1035
- " onClose: function() {",
1036
- " if(setupWizard.getStep() === 2) { fetch('/auth/done', { method: 'POST' }).catch(function() {}); }",
991
+ " onClose: () => {",
992
+ " if(setupWizard.getStep() === 2) { fetch('/auth/done', { method: 'POST' }).catch((err) => { console.warn('Auth done notification failed.', err); }); }",
1037
993
  " },",
1038
994
  " onRender: renderSetupStep,",
1039
995
  " onValidate: validateSetupStep,",
@@ -1041,163 +997,349 @@ export function generateChannelsSubtabScript() {
1041
997
  " stepsId: 'setup-steps',",
1042
998
  " titleId: 'setup-title'",
1043
999
  " });",
1044
- // Open the setup wizard and render the first step. Pre-populate selected providers from the embedded enabled state so re-running the wizard shows the
1045
- // current provider filter as pre-checked.
1046
- " window.openSetupWizard = function() {",
1047
- " var preSelected = setupProviders.filter(function(p) { return p.enabled; }).map(function(p) { return p.tag; });",
1048
- " setupWizard.state = { selectedProviders: preSelected, authIndex: 0 };",
1000
+ // Open the setup wizard and render the first step. Pre-populate selected services from the embedded enabled state so re-running the wizard shows the
1001
+ // current service filter as pre-checked.
1002
+ " window.openSetupWizard = () => {",
1003
+ " const preSelected = setupServices.filter((p) => p.enabled).map((p) => p.tag);",
1004
+ " setupWizard.state = { selectedServices: preSelected, authIndex: 0 };",
1049
1005
  " setupWizard.open();",
1050
1006
  " };",
1051
1007
  // Skip the entire setup flow and mark as completed.
1052
- " window.skipSetup = async function() {",
1053
- " try { await fetch('/config/channels/setup-completed', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); }",
1054
- " catch(e) {}",
1008
+ " window.skipSetup = async () => {",
1009
+ " try { await fetch('/config/channels/setup-completed', { headers: { 'Content-Type': 'application/json' }, method: 'POST' }); }",
1010
+ " catch(e) { console.warn('Setup completion failed to persist on skip.', e); }",
1055
1011
  " setupWizard.close();",
1056
1012
  " };",
1057
- // Authenticate the current provider in step 2. Opens the browser and shows a waiting prompt.
1058
- " async function setupAuthProvider() {",
1059
- " var slug = setupWizard.state.selectedProviders[setupWizard.state.authIndex];",
1060
- " var provider = setupProviders.find(function(p) { return p.tag === slug; });",
1061
- " var url = provider && provider.domain ? 'https://' + provider.domain + '/' : '';",
1013
+ // Authenticate the current service in step 2. Opens the browser and shows a waiting prompt.
1014
+ " async function setupAuthService() {",
1015
+ " const slug = setupWizard.state.selectedServices[setupWizard.state.authIndex];",
1016
+ " const svc = setupServices.find((p) => p.tag === slug);",
1017
+ " const url = svc && svc.domain ? 'https://' + svc.domain + '/' : '';",
1062
1018
  " if(!url) { setupAdvanceAuth(); return; }",
1063
1019
  " try {",
1064
- " var res = await fetch('/auth/login', {",
1065
- " method: 'POST',",
1020
+ " const res = await fetch('/auth/login', {",
1021
+ " body: JSON.stringify({ url: url }),",
1066
1022
  " headers: { 'Content-Type': 'application/json' },",
1067
- " body: JSON.stringify({ url: url })",
1023
+ " method: 'POST'",
1068
1024
  " });",
1069
- " var data = await res.json();",
1025
+ " const data = await res.json();",
1070
1026
  " if(data.success) renderSetupAuthWaiting();",
1071
1027
  " else showToast('Failed to start login: ' + (data.error || 'Unknown error.'), 'error');",
1072
- " } catch(e) { showToast('Failed to start login.', 'error'); }",
1028
+ " } catch(e) { console.error('Login start request failed.', e); showToast('Failed to start login.', 'error'); }",
1073
1029
  " }",
1074
- // Mark the current provider auth as done and advance to the next.
1030
+ // Mark the current service auth as done and advance to the next.
1075
1031
  " function setupAuthDone() {",
1076
- " fetch('/auth/done', { method: 'POST' }).catch(function() {});",
1032
+ " fetch('/auth/done', { method: 'POST' }).catch((err) => { console.warn('Auth done notification failed.', err); });",
1077
1033
  " setupAdvanceAuth();",
1078
1034
  " }",
1079
- // Skip the current provider auth and advance.
1035
+ // Skip the current service auth and advance.
1080
1036
  " function setupAuthSkip() {",
1081
- " fetch('/auth/done', { method: 'POST' }).catch(function() {});",
1037
+ " fetch('/auth/done', { method: 'POST' }).catch((err) => { console.warn('Auth done notification failed.', err); });",
1082
1038
  " setupAdvanceAuth();",
1083
1039
  " }",
1084
- // Advance to the next provider or to step 3 if all are done.
1040
+ // Advance to the next service or to step 3 if all are done.
1085
1041
  " function setupAdvanceAuth() {",
1086
1042
  " setupWizard.state.authIndex++;",
1087
- " if(setupWizard.state.authIndex >= setupWizard.state.selectedProviders.length) {",
1043
+ " if(setupWizard.state.authIndex >= setupWizard.state.selectedServices.length) {",
1088
1044
  " setupWizard.next();",
1089
1045
  " } else {",
1090
1046
  " renderSetupStep(setupWizard.getStep());",
1091
1047
  " }",
1092
1048
  " }",
1093
1049
  // Finish the setup wizard. Mark as completed and refresh the channel table.
1094
- " window.finishSetup = async function() {",
1050
+ " window.finishSetup = async () => {",
1095
1051
  " try {",
1096
- " var res = await fetch('/config/channels/setup-completed', { method: 'POST', headers: { 'Content-Type': 'application/json' } });",
1097
- " var data = await res.json();",
1052
+ " const res = await fetch('/config/channels/setup-completed', { headers: { 'Content-Type': 'application/json' }, method: 'POST' });",
1053
+ " const data = await res.json();",
1098
1054
  " setupWizard.close();",
1099
- " if(data.patch) { applyChannelPatch(data.patch); }",
1100
- " } catch(err) { console.error('Setup finish error:', err); setupWizard.close(); }",
1055
+ " if(data.patch) { channelTable.applyPatch(data.patch); }",
1056
+ " } catch(err) { console.error('Setup finish request failed.', err); setupWizard.close(); }",
1101
1057
  " };",
1102
1058
  // Render the auth waiting state - shows a message while the user authenticates in Chrome.
1103
1059
  " function renderSetupAuthWaiting() {",
1104
- " var slug = setupWizard.state.selectedProviders[setupWizard.state.authIndex];",
1105
- " var provider = setupProviders.find(function(p) { return p.tag === slug; });",
1106
- " var label = provider ? provider.displayName : slug;",
1107
- " var content = document.getElementById('setup-content');",
1060
+ " const slug = setupWizard.state.selectedServices[setupWizard.state.authIndex];",
1061
+ " const svc = setupServices.find((p) => p.tag === slug);",
1062
+ " const label = svc ? svc.displayName : slug;",
1063
+ " const content = document.getElementById('setup-content');",
1108
1064
  " content.innerHTML = '<div class=\"wizard-spinner\">Complete authentication for ' + esc(label) +",
1109
1065
  " ' in the Chrome browser window...</div>' +",
1110
1066
  " '<div class=\"wizard-step-centered-compact\">' +",
1111
1067
  " '<button type=\"button\" class=\"btn btn-primary\" data-action=\"auth-done\">Done</button> ' +",
1112
1068
  " '<button type=\"button\" class=\"btn btn-secondary\" data-action=\"auth-skip\">Skip</button></div>';",
1113
1069
  // Attach handlers to the auth waiting buttons programmatically.
1114
- " var waitActions = content.querySelectorAll('[data-action]');",
1115
- " for(var wi = 0; wi < waitActions.length; wi++) {",
1116
- " (function(btn) {",
1117
- " var action = btn.getAttribute('data-action');",
1118
- " if(action === 'auth-done') { btn.onclick = function() { setupAuthDone(); }; }",
1119
- " else if(action === 'auth-skip') { btn.onclick = function() { setupAuthSkip(); }; }",
1120
- " })(waitActions[wi]);",
1070
+ " const waitActions = content.querySelectorAll('[data-action]');",
1071
+ " for(const btn of waitActions) {",
1072
+ " const action = btn.getAttribute('data-action');",
1073
+ " if(action === 'auth-done') { btn.onclick = () => { setupAuthDone(); }; }",
1074
+ " else if(action === 'auth-skip') { btn.onclick = () => { setupAuthSkip(); }; }",
1121
1075
  " }",
1122
1076
  " }",
1123
1077
  // Render the current setup step content and update navigation buttons. The controller handles step indicator updates and error clearing.
1124
1078
  " function renderSetupStep(step) {",
1125
- " var content = document.getElementById('setup-content');",
1079
+ " const content = document.getElementById('setup-content');",
1126
1080
  // Show/hide navigation buttons based on the current step.
1127
1081
  " if(step > 1) { setupWizard.show('setup-back'); } else { setupWizard.hide('setup-back'); }",
1128
1082
  " if(step < 3) { setupWizard.show('setup-next'); setupWizard.show('setup-skip'); }",
1129
1083
  " else { setupWizard.hide('setup-next'); setupWizard.hide('setup-skip'); }",
1130
1084
  " if(step === 3) { setupWizard.show('setup-finish'); } else { setupWizard.hide('setup-finish'); }",
1131
- " var html = '';",
1132
- // Step 1: Provider selection with multi-select cards.
1085
+ " let html = '';",
1086
+ // Step 1: Service selection with multi-select cards.
1133
1087
  " if(step === 1) {",
1134
1088
  " html += '<p class=\"wizard-hint\">Select the streaming services you subscribe to. " +
1135
1089
  "This determines which channels are available in your lineup.</p>';",
1136
1090
  " html += '<div class=\"wizard-provider-grid\">';",
1137
- " for(var j = 0; j < setupProviders.length; j++) {",
1138
- " var p = setupProviders[j];",
1139
- " var checked = (setupWizard.state.selectedProviders.indexOf(p.tag) !== -1) ? ' checked' : '';",
1091
+ " for(const p of setupServices) {",
1092
+ " const checked = setupWizard.state.selectedServices.includes(p.tag) ? ' checked' : '';",
1140
1093
  " html += '<label class=\"wizard-provider-card\">';",
1141
1094
  " html += '<input type=\"checkbox\" class=\"setup-provider-cb\" value=\"' + p.tag + '\"' + checked + '>';",
1142
- " html += providerIconHtml(p.domain || '', esc(p.displayName), 'provider-icon', 'provider-icon-text', 'both', p.iconUrl || '');",
1095
+ " html += serviceIconHtml(p.domain || '', esc(p.displayName), 'provider-icon', 'provider-icon-text', 'both', p.iconUrl || '');",
1143
1096
  " html += '</label>';",
1144
1097
  " }",
1145
1098
  " html += '</div>';",
1146
1099
  " }",
1147
1100
  // Step 2: Sequential authentication prompts.
1148
1101
  " if(step === 2) {",
1149
- " var slug = setupWizard.state.selectedProviders[setupWizard.state.authIndex];",
1150
- " var provider = setupProviders.find(function(p) { return p.tag === slug; });",
1151
- " var label = provider ? provider.displayName : slug;",
1152
- " var total = setupWizard.state.selectedProviders.length;",
1153
- " var current = setupWizard.state.authIndex + 1;",
1154
- " html += '<p class=\"wizard-hint\">Sign in to each provider in the Chrome browser window. " +
1102
+ " const slug = setupWizard.state.selectedServices[setupWizard.state.authIndex];",
1103
+ " const svc = setupServices.find((p) => p.tag === slug);",
1104
+ " const label = svc ? svc.displayName : slug;",
1105
+ " const total = setupWizard.state.selectedServices.length;",
1106
+ " const current = setupWizard.state.authIndex + 1;",
1107
+ " html += '<p class=\"wizard-hint\">Sign in to each service in the Chrome browser window. " +
1155
1108
  "PrismCast uses your login session to access live streams.</p>';",
1156
1109
  " html += '<div class=\"wizard-step-centered\">';",
1157
1110
  " html += '<p class=\"wizard-step-provider-name\">' + esc(label) + '</p>';",
1158
- " html += '<p class=\"wizard-step-provider-count\">Provider ' + current + ' of ' + total + '</p>';",
1111
+ " html += '<p class=\"wizard-step-provider-count\">Service ' + current + ' of ' + total + '</p>';",
1159
1112
  " html += '<button type=\"button\" class=\"btn btn-primary\" data-action=\"auth-start\">Sign In</button> ';",
1160
1113
  " html += '<button type=\"button\" class=\"btn btn-secondary\" data-action=\"auth-skip\">Skip</button>';",
1161
1114
  " html += '</div>';",
1162
1115
  " }",
1163
1116
  // Step 3: Summary and finish.
1164
1117
  " if(step === 3) {",
1165
- " html += '<p class=\"wizard-hint\">Your providers are ready. Click Finish to complete setup. " +
1118
+ " html += '<p class=\"wizard-hint\">Your services are ready. Click Finish to complete setup. " +
1166
1119
  "You can browse and manage channels anytime using the Browse Channels button.</p>';",
1167
1120
  " html += '<div class=\"wizard-step-centered\">';",
1168
- " html += '<p class=\"wizard-step-summary\">Selected ' + setupWizard.state.selectedProviders.length + ' provider' +",
1169
- " (setupWizard.state.selectedProviders.length === 1 ? '' : 's') + '.</p>';",
1121
+ " html += '<p class=\"wizard-step-summary\">Selected ' + setupWizard.state.selectedServices.length + ' service' +",
1122
+ " (setupWizard.state.selectedServices.length === 1 ? '' : 's') + '.</p>';",
1170
1123
  " html += '</div>';",
1171
1124
  " }",
1172
1125
  " content.innerHTML = html;",
1173
1126
  // Attach event handlers to dynamically-rendered buttons programmatically. Uses data-action attributes to identify buttons without inline onclick.
1174
- " var actions = content.querySelectorAll('[data-action]');",
1175
- " for(var ai = 0; ai < actions.length; ai++) {",
1176
- " (function(btn) {",
1177
- " var action = btn.getAttribute('data-action');",
1178
- " if(action === 'auth-start') { btn.onclick = function() { setupAuthProvider(); }; }",
1179
- " else if(action === 'auth-skip') { btn.onclick = function() { setupAuthSkip(); }; }",
1180
- " else if(action === 'auth-done') { btn.onclick = function() { setupAuthDone(); }; }",
1181
- " })(actions[ai]);",
1127
+ " const actions = content.querySelectorAll('[data-action]');",
1128
+ " for(const btn of actions) {",
1129
+ " const action = btn.getAttribute('data-action');",
1130
+ " if(action === 'auth-start') { btn.onclick = () => { setupAuthService(); }; }",
1131
+ " else if(action === 'auth-skip') { btn.onclick = () => { setupAuthSkip(); }; }",
1132
+ " else if(action === 'auth-done') { btn.onclick = () => { setupAuthDone(); }; }",
1182
1133
  " }",
1183
1134
  " }",
1135
+ // Tag Management Modal.
1136
+ // Single function for applying tag mutation responses. Updates the tag manager modal body, the column header filter dropdown, the channel table patch, and shows
1137
+ // a toast. Exposed on window so both channels.ts and config.ts (bulkToggleTag) can use the same code path. No page reload needed.
1138
+ " window.applyTagResponse = (data, toastMessage) => {",
1139
+ " const contentDiv = document.getElementById('tag-manager-modal-content');",
1140
+ " if(contentDiv && data.modalBody) { contentDiv.innerHTML = data.modalBody; }",
1141
+ " const filterMenu = document.getElementById('tag-filter-menu');",
1142
+ " if(filterMenu && data.filterContent) { filterMenu.innerHTML = data.filterContent; applyTagColumnFilter(); }",
1143
+ " if(data.patch) { channelTable.applyPatch(data.patch); }",
1144
+ " if(toastMessage && window.showToast) { window.showToast(toastMessage); }",
1145
+ " };",
1146
+ " window.openTagManager = () => {",
1147
+ " const modal = document.getElementById('tag-manager-modal');",
1148
+ " if(modal) { modal.style.display = 'flex'; }",
1149
+ " };",
1150
+ " window.closeTagManager = () => {",
1151
+ " const modal = document.getElementById('tag-manager-modal');",
1152
+ " if(modal) { modal.style.display = 'none'; }",
1153
+ " };",
1154
+ // Create a new tag via the API, then update the modal incrementally and show a toast.
1155
+ " window.createTag = async () => {",
1156
+ " const input = document.getElementById('tag-manager-input');",
1157
+ " const errorDiv = document.getElementById('tag-manager-error');",
1158
+ " const tag = input ? input.value.trim() : '';",
1159
+ " if(!tag) return;",
1160
+ " errorDiv.style.display = 'none';",
1161
+ " try {",
1162
+ " const res = await fetch('/config/tags', {",
1163
+ " body: JSON.stringify({ tag }),",
1164
+ " headers: { 'Content-Type': 'application/json' },",
1165
+ " method: 'POST'",
1166
+ " });",
1167
+ " const data = await res.json();",
1168
+ " if(!data.success) {",
1169
+ " errorDiv.textContent = data.error || 'Failed to create tag.';",
1170
+ " errorDiv.style.display = '';",
1171
+ " return;",
1172
+ " }",
1173
+ " applyTagResponse(data, 'Tag created: ' + tag);",
1174
+ " } catch(err) {",
1175
+ " console.error('Tag creation request failed.', err);",
1176
+ " errorDiv.textContent = 'Failed to create tag.';",
1177
+ " errorDiv.style.display = '';",
1178
+ " }",
1179
+ " };",
1180
+ // Delete a tag via the API, then update the modal and channel table incrementally.
1181
+ " window.deleteTag = async (tag) => {",
1182
+ " try {",
1183
+ " const res = await fetch('/config/tags/' + encodeURIComponent(tag), { method: 'DELETE' });",
1184
+ " const data = await res.json();",
1185
+ " if(!data.success) {",
1186
+ " showToast(data.error || 'Failed to delete tag.', 'error');",
1187
+ " return;",
1188
+ " }",
1189
+ " applyTagResponse(data, 'Tag deleted: ' + tag);",
1190
+ " } catch(err) {",
1191
+ " console.error('Tag deletion request failed.', err);",
1192
+ " showToast('Failed to delete tag.', 'error');",
1193
+ " }",
1194
+ " };",
1195
+ // Restore a deleted predefined tag via the API, then update the modal and channel table incrementally.
1196
+ " window.restoreTag = async (tag) => {",
1197
+ " try {",
1198
+ " const res = await fetch('/config/tags/restore', {",
1199
+ " body: JSON.stringify({ tag }),",
1200
+ " headers: { 'Content-Type': 'application/json' },",
1201
+ " method: 'POST'",
1202
+ " });",
1203
+ " const data = await res.json();",
1204
+ " if(!data.success) {",
1205
+ " showToast(data.error || 'Failed to restore tag.', 'error');",
1206
+ " return;",
1207
+ " }",
1208
+ " applyTagResponse(data, 'Tag restored: ' + tag);",
1209
+ " } catch(err) {",
1210
+ " console.error('Tag restore request failed.', err);",
1211
+ " showToast('Failed to restore tag.', 'error');",
1212
+ " }",
1213
+ " };",
1214
+ // Tag Column Filter — client-side view filter that shows/hides rows based on their data-channel-tags attribute. Transient (not persisted, resets on page load).
1215
+ // All tag checkboxes start checked (show all). Unchecking a tag hides rows that don't have ANY of the remaining checked tags.
1216
+ " window.applyTagColumnFilter = () => {",
1217
+ " const checkboxes = document.querySelectorAll('.tag-filter-checkbox');",
1218
+ " const checkedTags = [];",
1219
+ " let allChecked = true;",
1220
+ " for(const checkbox of checkboxes) {",
1221
+ " if(checkbox.checked) { checkedTags.push(checkbox.getAttribute('data-tag')); }",
1222
+ " else { allChecked = false; }",
1223
+ " }",
1224
+ " const rows = document.querySelectorAll('tr[id^=\"display-row-\"]');",
1225
+ " for(const row of rows) {",
1226
+ " const editRow = document.getElementById(row.id.replace('display-row-', 'edit-row-'));",
1227
+ " if(allChecked) {",
1228
+ " row.classList.remove('tag-filtered');",
1229
+ " if(editRow) editRow.classList.remove('tag-filtered');",
1230
+ " continue;",
1231
+ " }",
1232
+ " const tags = row.getAttribute('data-channel-tags');",
1233
+ " const channelTags = tags ? tags.split(',') : [];",
1234
+ " const visible = channelTags.some((ct) => checkedTags.includes(ct));",
1235
+ " row.classList.toggle('tag-filtered', !visible);",
1236
+ " if(editRow) editRow.classList.toggle('tag-filtered', !visible);",
1237
+ " }",
1238
+ // Show or hide the playlist hint icon based on filter state. When the filter is active, users can click the icon to see the corresponding playlist URL.
1239
+ " const hintBtn = document.getElementById('playlist-hint-btn');",
1240
+ " if(hintBtn) { hintBtn.style.display = allChecked ? 'none' : ''; }",
1241
+ // Update the toggle label to reflect the current state: "Show All" when some are unchecked, "Show None" when all are checked.
1242
+ " const toggle = document.getElementById('tag-filter-toggle');",
1243
+ " if(toggle) { toggle.textContent = allChecked ? 'Show None' : 'Show All'; }",
1244
+ " };",
1245
+ // Toggle all tag filter checkboxes. When all are checked, uncheck all. When any are unchecked, check all. Matches the browse modal's Select All pattern.
1246
+ " window.toggleTagColumnFilter = () => {",
1247
+ " const checkboxes = document.querySelectorAll('.tag-filter-checkbox');",
1248
+ " let allChecked = true;",
1249
+ " for(const checkbox of checkboxes) { if(!checkbox.checked) { allChecked = false; break; } }",
1250
+ " for(const checkbox of checkboxes) { checkbox.checked = !allChecked; }",
1251
+ " applyTagColumnFilter();",
1252
+ " };",
1253
+ // Playlist hint popover. Builds the content dynamically from the current filter state and shows it using the existing dropdown portal pattern. Uses whichever
1254
+ // form (include or exclude) produces a shorter URL. The label names the specific tags so the user sees exactly what the URL produces.
1255
+ " window.showPlaylistHint = (btn) => {",
1256
+ " const checkboxes = document.querySelectorAll('.tag-filter-checkbox');",
1257
+ " const checked = [];",
1258
+ " const unchecked = [];",
1259
+ " for(const checkbox of checkboxes) {",
1260
+ " const tag = checkbox.getAttribute('data-tag');",
1261
+ " if(checkbox.checked) { checked.push(tag); }",
1262
+ " else { unchecked.push(tag); }",
1263
+ " }",
1264
+ " const useExclude = unchecked.length < checked.length;",
1265
+ " const tagParam = useExclude ? unchecked.map((t) => '-' + t).join(',') : checked.join(',');",
1266
+ " const url = location.origin + '/playlist?tag=' + tagParam;",
1267
+ " const label = useExclude ? 'all channels except those tagged <strong>' + unchecked.join(', ') + '</strong>'",
1268
+ " : 'channels tagged <strong>' + checked.join(', ') + '</strong>';",
1269
+ " toggleDropdown(btn, { onOpen: (menu) => {",
1270
+ " menu.innerHTML = '<div class=\"playlist-hint-content\">' +",
1271
+ " '<p>Playlist URL for Channels DVR: ' + label + '.</p>' +",
1272
+ " '<div class=\"playlist-hint-url\">' +",
1273
+ " '<code>' + url + '</code>' +",
1274
+ " '<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"copyPlaylistHintUrl()\">Copy</button>' +",
1275
+ " '</div></div>';",
1276
+ " }});",
1277
+ " };",
1278
+ // Copy the playlist hint URL to the clipboard. Thin wrapper that reads the URL from the popover's code element and delegates to the shared copyToClipboard utility.
1279
+ " window.copyPlaylistHintUrl = () => {",
1280
+ " const code = document.querySelector('.playlist-hint-content code');",
1281
+ " if(code) { copyToClipboard(code.textContent, 'Playlist URL copied to clipboard.'); }",
1282
+ " };",
1283
+ // Inline tag rename — replaces the tag pill with an input field. The original span is saved so it can be restored on cancel or error without a page reload.
1284
+ " window.startTagRename = (span, oldTag) => {",
1285
+ " const originalSpan = span;",
1286
+ " const input = document.createElement('input');",
1287
+ " input.type = 'text';",
1288
+ " input.value = oldTag;",
1289
+ " input.className = 'tag-rename-input';",
1290
+ " input.maxLength = 30;",
1291
+ " let submitted = false;",
1292
+ " function restore() { if(input.parentNode) input.replaceWith(originalSpan); }",
1293
+ " async function submit() {",
1294
+ " if(submitted) return;",
1295
+ " submitted = true;",
1296
+ " const newTag = input.value.trim();",
1297
+ " if(!newTag || (newTag === oldTag)) { restore(); return; }",
1298
+ " try {",
1299
+ " const res = await fetch('/config/tags/rename', {",
1300
+ " body: JSON.stringify({ newTag, oldTag }),",
1301
+ " headers: { 'Content-Type': 'application/json' },",
1302
+ " method: 'POST'",
1303
+ " });",
1304
+ " const data = await res.json();",
1305
+ " if(!data.success) {",
1306
+ " restore();",
1307
+ " showToast(data.error || 'Failed to rename tag.', 'error');",
1308
+ " return;",
1309
+ " }",
1310
+ " applyTagResponse(data, 'Tag renamed: ' + oldTag + ' \\u2192 ' + newTag);",
1311
+ " } catch(err) {",
1312
+ " console.error('Tag rename request failed.', err);",
1313
+ " restore();",
1314
+ " showToast('Failed to rename tag.', 'error');",
1315
+ " }",
1316
+ " }",
1317
+ " input.onkeydown = (e) => {",
1318
+ " if(e.key === 'Enter') { e.preventDefault(); submit(); }",
1319
+ " if(e.key === 'Escape') { e.preventDefault(); restore(); }",
1320
+ " };",
1321
+ " input.onblur = () => { submit(); };",
1322
+ " span.replaceWith(input);",
1323
+ " input.focus();",
1324
+ " input.select();",
1325
+ " };",
1184
1326
  // Auto-show the setup wizard on first visit if setupCompleted is false.
1185
- " var setupModal = document.getElementById('setup-modal');",
1327
+ " const setupModal = document.getElementById('setup-modal');",
1186
1328
  " if(setupModal && setupModal.getAttribute('data-setup-completed') === 'false') {",
1187
1329
  " openSetupWizard();",
1188
1330
  " }",
1189
- // Process channel logos and provider icons on page load. These shared functions are also called after DOM mutations (row insertion, chip rebuild) to ensure
1331
+ // Process channel logos and service icons on page load. These shared functions are also called after DOM mutations (row insertion, chip rebuild) to ensure
1190
1332
  // dynamically added elements get the same icon rendering as the initial server-rendered content.
1191
- " processChannelLogos();",
1192
- " processProviderDisplays();",
1333
+ " channelTable.processLogos();",
1334
+ " processServiceDisplays();",
1193
1335
  // Initialize channels subtab on load: hash > localStorage > default.
1194
- " var initialSubtab = window.initialChannelsHashSubtab;",
1195
- " if(!initialSubtab) {",
1196
- " try { initialSubtab = localStorage.getItem('prismcast-channels-subtab'); } catch(e) {}",
1197
- " }",
1198
- " if(initialSubtab && document.querySelector('.channels-subtab-btn[data-channels-subtab=\"' + initialSubtab + '\"]')) {",
1199
- " switchChannelsSubtab(initialSubtab, false);",
1200
- " }",
1336
+ " initSubtab({",
1337
+ " btnSelector: '.channels-subtab-btn',",
1338
+ " dataAttr: 'data-channels-subtab',",
1339
+ " hashVar: window.initialChannelsHashSubtab,",
1340
+ " storageKey: 'prismcast-channels-subtab',",
1341
+ " switchFn: switchChannelsSubtab",
1342
+ " });",
1201
1343
  "})();",
1202
1344
  "</script>"
1203
1345
  ].join("\n");