prismcast 1.8.0 → 1.10.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 (392) hide show
  1. package/README.md +6 -6
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +41 -13
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.d.ts +4 -6
  6. package/dist/browser/cdp.js +18 -22
  7. package/dist/browser/cdp.js.map +1 -1
  8. package/dist/browser/channelSelection.d.ts +41 -11
  9. package/dist/browser/channelSelection.js +75 -24
  10. package/dist/browser/channelSelection.js.map +1 -1
  11. package/dist/browser/display.d.ts +1 -1
  12. package/dist/browser/index.d.ts +8 -8
  13. package/dist/browser/index.js +44 -45
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/login.d.ts +2 -2
  16. package/dist/browser/login.js +1 -1
  17. package/dist/browser/manifestInterceptor.d.ts +77 -0
  18. package/dist/browser/manifestInterceptor.js +323 -0
  19. package/dist/browser/manifestInterceptor.js.map +1 -0
  20. package/dist/browser/precaching.d.ts +1 -1
  21. package/dist/browser/precaching.js +23 -23
  22. package/dist/browser/precaching.js.map +1 -1
  23. package/dist/browser/tuning/comcastPolymer.d.ts +1 -1
  24. package/dist/browser/tuning/cox.d.ts +1 -1
  25. package/dist/browser/tuning/directv.d.ts +1 -1
  26. package/dist/browser/tuning/directv.js +65 -69
  27. package/dist/browser/tuning/directv.js.map +1 -1
  28. package/dist/browser/tuning/fox.d.ts +1 -1
  29. package/dist/browser/tuning/fox.js +172 -15
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.d.ts +1 -1
  32. package/dist/browser/tuning/hbo.js +7 -7
  33. package/dist/browser/tuning/hbo.js.map +1 -1
  34. package/dist/browser/tuning/hulu.d.ts +1 -1
  35. package/dist/browser/tuning/hulu.js +120 -113
  36. package/dist/browser/tuning/hulu.js.map +1 -1
  37. package/dist/browser/tuning/shared.d.ts +3 -3
  38. package/dist/browser/tuning/shared.js +5 -5
  39. package/dist/browser/tuning/sling.d.ts +1 -1
  40. package/dist/browser/tuning/sling.js +30 -30
  41. package/dist/browser/tuning/sling.js.map +1 -1
  42. package/dist/browser/tuning/spectrum.d.ts +1 -1
  43. package/dist/browser/tuning/spectrum.js +21 -22
  44. package/dist/browser/tuning/spectrum.js.map +1 -1
  45. package/dist/browser/tuning/thumbnailRow.d.ts +1 -1
  46. package/dist/browser/tuning/thumbnailRow.js +1 -1
  47. package/dist/browser/tuning/tileClick.d.ts +1 -1
  48. package/dist/browser/tuning/tileClick.js +5 -5
  49. package/dist/browser/tuning/xfinity.d.ts +1 -1
  50. package/dist/browser/tuning/youtubeTv.d.ts +1 -1
  51. package/dist/browser/tuning/youtubeTv.js +17 -18
  52. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  53. package/dist/browser/video.d.ts +20 -6
  54. package/dist/browser/video.js +30 -38
  55. package/dist/browser/video.js.map +1 -1
  56. package/dist/channels/index.d.ts +1 -2
  57. package/dist/channels/index.js +475 -442
  58. package/dist/channels/index.js.map +1 -1
  59. package/dist/config/channelForm.d.ts +58 -0
  60. package/dist/config/channelForm.js +129 -0
  61. package/dist/config/channelForm.js.map +1 -0
  62. package/dist/config/consistencyProbe.d.ts +7 -0
  63. package/dist/config/consistencyProbe.js +143 -0
  64. package/dist/config/consistencyProbe.js.map +1 -0
  65. package/dist/config/health.d.ts +7 -6
  66. package/dist/config/health.js +78 -65
  67. package/dist/config/health.js.map +1 -1
  68. package/dist/config/index.d.ts +1 -1
  69. package/dist/config/index.js +21 -14
  70. package/dist/config/index.js.map +1 -1
  71. package/dist/config/paths.d.ts +9 -3
  72. package/dist/config/paths.js +12 -4
  73. package/dist/config/paths.js.map +1 -1
  74. package/dist/config/persistence.context.d.ts +8 -0
  75. package/dist/config/persistence.context.js +30 -0
  76. package/dist/config/persistence.context.js.map +1 -0
  77. package/dist/config/persistence.d.ts +157 -0
  78. package/dist/config/persistence.js +446 -0
  79. package/dist/config/persistence.js.map +1 -0
  80. package/dist/config/presets.d.ts +14 -4
  81. package/dist/config/presets.js +84 -75
  82. package/dist/config/presets.js.map +1 -1
  83. package/dist/config/profiles.d.ts +2 -2
  84. package/dist/config/profiles.js +7 -9
  85. package/dist/config/profiles.js.map +1 -1
  86. package/dist/config/servicePacks.d.ts +46 -0
  87. package/dist/config/{providerPacks.js → servicePacks.js} +50 -53
  88. package/dist/config/servicePacks.js.map +1 -0
  89. package/dist/config/services.d.ts +228 -0
  90. package/dist/config/services.js +772 -0
  91. package/dist/config/services.js.map +1 -0
  92. package/dist/config/sites.d.ts +3 -3
  93. package/dist/config/sites.js +75 -75
  94. package/dist/config/sites.js.map +1 -1
  95. package/dist/config/userChannels.d.ts +522 -54
  96. package/dist/config/userChannels.js +1343 -366
  97. package/dist/config/userChannels.js.map +1 -1
  98. package/dist/config/userConfig.d.ts +108 -13
  99. package/dist/config/userConfig.js +296 -143
  100. package/dist/config/userConfig.js.map +1 -1
  101. package/dist/config/userProfiles.d.ts +34 -16
  102. package/dist/config/userProfiles.js +189 -100
  103. package/dist/config/userProfiles.js.map +1 -1
  104. package/dist/hdhr/channelMap.d.ts +1 -1
  105. package/dist/hdhr/channelMap.js +10 -16
  106. package/dist/hdhr/channelMap.js.map +1 -1
  107. package/dist/hdhr/deviceId.js +10 -9
  108. package/dist/hdhr/deviceId.js.map +1 -1
  109. package/dist/hdhr/discover.js +4 -5
  110. package/dist/hdhr/discover.js.map +1 -1
  111. package/dist/hdhr/index.js +12 -19
  112. package/dist/hdhr/index.js.map +1 -1
  113. package/dist/index.js +12 -12
  114. package/dist/index.js.map +1 -1
  115. package/dist/native/codecInference.d.ts +44 -0
  116. package/dist/native/codecInference.js +327 -0
  117. package/dist/native/codecInference.js.map +1 -0
  118. package/dist/native/decrypt.d.ts +1 -1
  119. package/dist/native/index.d.ts +5 -5
  120. package/dist/native/index.js +45 -67
  121. package/dist/native/index.js.map +1 -1
  122. package/dist/native/probe.d.ts +27 -9
  123. package/dist/native/probe.js +159 -68
  124. package/dist/native/probe.js.map +1 -1
  125. package/dist/native/proxy.d.ts +4 -2
  126. package/dist/native/proxy.js +56 -53
  127. package/dist/native/proxy.js.map +1 -1
  128. package/dist/native/tokenExpiry.d.ts +22 -0
  129. package/dist/native/tokenExpiry.js +153 -0
  130. package/dist/native/tokenExpiry.js.map +1 -0
  131. package/dist/routes/assets.js +3 -3
  132. package/dist/routes/assets.js.map +1 -1
  133. package/dist/routes/auth.js +9 -13
  134. package/dist/routes/auth.js.map +1 -1
  135. package/dist/routes/components.js +2 -2
  136. package/dist/routes/components.js.map +1 -1
  137. package/dist/routes/config/channels/endpoints/browse.d.ts +6 -0
  138. package/dist/routes/config/channels/endpoints/browse.js +195 -0
  139. package/dist/routes/config/channels/endpoints/browse.js.map +1 -0
  140. package/dist/routes/config/channels/endpoints/bulk.d.ts +6 -0
  141. package/dist/routes/config/channels/endpoints/bulk.js +128 -0
  142. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -0
  143. package/dist/routes/config/channels/endpoints/crud.d.ts +6 -0
  144. package/dist/routes/config/channels/endpoints/crud.js +504 -0
  145. package/dist/routes/config/channels/endpoints/crud.js.map +1 -0
  146. package/dist/routes/config/channels/endpoints/importExport.d.ts +6 -0
  147. package/dist/routes/config/channels/endpoints/importExport.js +131 -0
  148. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -0
  149. package/dist/routes/config/channels/endpoints/predefined.d.ts +6 -0
  150. package/dist/routes/config/channels/endpoints/predefined.js +78 -0
  151. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -0
  152. package/dist/routes/config/channels/endpoints/prefs.d.ts +6 -0
  153. package/dist/routes/config/channels/endpoints/prefs.js +58 -0
  154. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -0
  155. package/dist/routes/config/channels/endpoints/service.d.ts +6 -0
  156. package/dist/routes/config/channels/endpoints/service.js +149 -0
  157. package/dist/routes/config/channels/endpoints/service.js.map +1 -0
  158. package/dist/routes/config/channels/endpoints/tags.d.ts +6 -0
  159. package/dist/routes/config/channels/endpoints/tags.js +185 -0
  160. package/dist/routes/config/channels/endpoints/tags.js.map +1 -0
  161. package/dist/routes/config/channels/http/handler.d.ts +10 -0
  162. package/dist/routes/config/channels/http/handler.js +20 -0
  163. package/dist/routes/config/channels/http/handler.js.map +1 -0
  164. package/dist/routes/config/channels/http/playlistHint.d.ts +25 -0
  165. package/dist/routes/config/channels/http/playlistHint.js +40 -0
  166. package/dist/routes/config/channels/http/playlistHint.js.map +1 -0
  167. package/dist/routes/config/channels/http/serviceWarning.d.ts +10 -0
  168. package/dist/routes/config/channels/http/serviceWarning.js +28 -0
  169. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -0
  170. package/dist/routes/config/channels/index.d.ts +3 -3
  171. package/dist/routes/config/channels/index.js +2 -2
  172. package/dist/routes/config/channels/index.js.map +1 -1
  173. package/dist/routes/config/channels/{routes.d.ts → setup.d.ts} +1 -1
  174. package/dist/routes/config/channels/setup.js +23 -0
  175. package/dist/routes/config/channels/setup.js.map +1 -0
  176. package/dist/routes/config/channels/table.d.ts +13 -13
  177. package/dist/routes/config/channels/table.js +291 -184
  178. package/dist/routes/config/channels/table.js.map +1 -1
  179. package/dist/routes/config/http/envelope.d.ts +111 -0
  180. package/dist/routes/config/http/envelope.js +103 -0
  181. package/dist/routes/config/http/envelope.js.map +1 -0
  182. package/dist/routes/config/index.d.ts +9 -9
  183. package/dist/routes/config/index.js +5 -5
  184. package/dist/routes/config/index.js.map +1 -1
  185. package/dist/routes/config/{providers.js → services.js} +105 -99
  186. package/dist/routes/config/services.js.map +1 -0
  187. package/dist/routes/config/settings.d.ts +1 -1
  188. package/dist/routes/config/settings.js +85 -60
  189. package/dist/routes/config/settings.js.map +1 -1
  190. package/dist/routes/debug.js +17 -18
  191. package/dist/routes/debug.js.map +1 -1
  192. package/dist/routes/health.js +1 -1
  193. package/dist/routes/health.js.map +1 -1
  194. package/dist/routes/icons.js +2 -2
  195. package/dist/routes/icons.js.map +1 -1
  196. package/dist/routes/index.js +2 -2
  197. package/dist/routes/index.js.map +1 -1
  198. package/dist/routes/logs.js +23 -34
  199. package/dist/routes/logs.js.map +1 -1
  200. package/dist/routes/mpegts.js +1 -1
  201. package/dist/routes/playlist.d.ts +5 -5
  202. package/dist/routes/playlist.js +84 -61
  203. package/dist/routes/playlist.js.map +1 -1
  204. package/dist/routes/root/content.js +82 -61
  205. package/dist/routes/root/content.js.map +1 -1
  206. package/dist/routes/root/index.js +2 -5
  207. package/dist/routes/root/index.js.map +1 -1
  208. package/dist/routes/root/scripts/channels.js +538 -581
  209. package/dist/routes/root/scripts/channels.js.map +1 -1
  210. package/dist/routes/root/scripts/config.js +868 -1022
  211. package/dist/routes/root/scripts/config.js.map +1 -1
  212. package/dist/routes/root/scripts/index.d.ts +4 -4
  213. package/dist/routes/root/scripts/shared.js +445 -197
  214. package/dist/routes/root/scripts/shared.js.map +1 -1
  215. package/dist/routes/root/scripts/status.d.ts +4 -0
  216. package/dist/routes/root/scripts/status.handlers.d.ts +182 -0
  217. package/dist/routes/root/scripts/status.handlers.js +713 -0
  218. package/dist/routes/root/scripts/status.handlers.js.map +1 -0
  219. package/dist/routes/root/scripts/status.js +85 -436
  220. package/dist/routes/root/scripts/status.js.map +1 -1
  221. package/dist/routes/root/styles.js +16 -8
  222. package/dist/routes/root/styles.js.map +1 -1
  223. package/dist/routes/services.d.ts +6 -0
  224. package/dist/routes/{providers.js → services.js} +58 -55
  225. package/dist/routes/services.js.map +1 -0
  226. package/dist/routes/sse.d.ts +16 -0
  227. package/dist/routes/sse.js +29 -0
  228. package/dist/routes/sse.js.map +1 -0
  229. package/dist/routes/streams.js +10 -23
  230. package/dist/routes/streams.js.map +1 -1
  231. package/dist/routes/ui.d.ts +2 -2
  232. package/dist/routes/ui.js +2 -0
  233. package/dist/routes/ui.js.map +1 -1
  234. package/dist/routes/upgrade.js +6 -7
  235. package/dist/routes/upgrade.js.map +1 -1
  236. package/dist/service/commands.context.d.ts +6 -0
  237. package/dist/service/commands.context.js +47 -0
  238. package/dist/service/commands.context.js.map +1 -0
  239. package/dist/service/commands.d.ts +53 -8
  240. package/dist/service/commands.js +172 -192
  241. package/dist/service/commands.js.map +1 -1
  242. package/dist/service/generators.context.d.ts +6 -0
  243. package/dist/service/generators.context.js +45 -0
  244. package/dist/service/generators.context.js.map +1 -0
  245. package/dist/service/generators.d.ts +56 -16
  246. package/dist/service/generators.js +509 -291
  247. package/dist/service/generators.js.map +1 -1
  248. package/dist/service/index.d.ts +2 -2
  249. package/dist/streaming/clients.d.ts +2 -2
  250. package/dist/streaming/clients.js +2 -2
  251. package/dist/streaming/codec.d.ts +2 -2
  252. package/dist/streaming/codec.js +0 -1
  253. package/dist/streaming/codec.js.map +1 -1
  254. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  255. package/dist/streaming/fmp4Segmenter.js +12 -12
  256. package/dist/streaming/hls.d.ts +7 -7
  257. package/dist/streaming/hls.js +104 -92
  258. package/dist/streaming/hls.js.map +1 -1
  259. package/dist/streaming/hlsResume.d.ts +3 -3
  260. package/dist/streaming/hlsResume.js +6 -6
  261. package/dist/streaming/hlsResume.js.map +1 -1
  262. package/dist/streaming/hlsSegments.d.ts +5 -2
  263. package/dist/streaming/hlsSegments.js +14 -9
  264. package/dist/streaming/hlsSegments.js.map +1 -1
  265. package/dist/streaming/monitor.d.ts +12 -12
  266. package/dist/streaming/monitor.js +98 -66
  267. package/dist/streaming/monitor.js.map +1 -1
  268. package/dist/streaming/mp4Parser.d.ts +34 -4
  269. package/dist/streaming/mp4Parser.js +111 -29
  270. package/dist/streaming/mp4Parser.js.map +1 -1
  271. package/dist/streaming/mpegts.d.ts +1 -1
  272. package/dist/streaming/mpegts.js +13 -10
  273. package/dist/streaming/mpegts.js.map +1 -1
  274. package/dist/streaming/playlistBuilder.d.ts +1 -1
  275. package/dist/streaming/playlistBuilder.js +2 -2
  276. package/dist/streaming/preroll.d.ts +9 -9
  277. package/dist/streaming/preroll.js +40 -42
  278. package/dist/streaming/preroll.js.map +1 -1
  279. package/dist/streaming/pretune.js +4 -4
  280. package/dist/streaming/pretune.js.map +1 -1
  281. package/dist/streaming/recovery.d.ts +3 -3
  282. package/dist/streaming/recovery.js +4 -4
  283. package/dist/streaming/registry.d.ts +6 -6
  284. package/dist/streaming/registry.js +4 -8
  285. package/dist/streaming/registry.js.map +1 -1
  286. package/dist/streaming/setup.d.ts +11 -10
  287. package/dist/streaming/setup.js +120 -71
  288. package/dist/streaming/setup.js.map +1 -1
  289. package/dist/streaming/showInfo.d.ts +9 -3
  290. package/dist/streaming/showInfo.js +33 -27
  291. package/dist/streaming/showInfo.js.map +1 -1
  292. package/dist/streaming/statusEmitter.d.ts +8 -7
  293. package/dist/streaming/statusEmitter.js +5 -5
  294. package/dist/streaming/statusEmitter.js.map +1 -1
  295. package/dist/types/channels.d.ts +139 -36
  296. package/dist/types/channels.js +39 -1
  297. package/dist/types/channels.js.map +1 -1
  298. package/dist/types/config.d.ts +22 -3
  299. package/dist/types/index.d.ts +9 -8
  300. package/dist/types/index.js +1 -0
  301. package/dist/types/index.js.map +1 -1
  302. package/dist/types/profiles.d.ts +14 -13
  303. package/dist/types/selection.d.ts +49 -5
  304. package/dist/types/shared.d.ts +1 -1
  305. package/dist/types/streaming.d.ts +2 -2
  306. package/dist/upgrade/commands.context.d.ts +6 -0
  307. package/dist/upgrade/commands.context.js +29 -0
  308. package/dist/upgrade/commands.context.js.map +1 -0
  309. package/dist/upgrade/commands.d.ts +29 -2
  310. package/dist/upgrade/commands.js +87 -134
  311. package/dist/upgrade/commands.js.map +1 -1
  312. package/dist/upgrade/detection.context.d.ts +6 -0
  313. package/dist/upgrade/detection.context.js +24 -0
  314. package/dist/upgrade/detection.context.js.map +1 -0
  315. package/dist/upgrade/detection.d.ts +140 -10
  316. package/dist/upgrade/detection.js +153 -66
  317. package/dist/upgrade/detection.js.map +1 -1
  318. package/dist/upgrade/index.d.ts +2 -2
  319. package/dist/utils/chromeFetch.d.ts +1 -1
  320. package/dist/utils/chromeFetch.js +2 -2
  321. package/dist/utils/clock.d.ts +14 -0
  322. package/dist/utils/clock.js +28 -0
  323. package/dist/utils/clock.js.map +1 -0
  324. package/dist/utils/debugFilter.d.ts +1 -1
  325. package/dist/utils/debugFilter.js +8 -6
  326. package/dist/utils/debugFilter.js.map +1 -1
  327. package/dist/utils/delay.d.ts +2 -3
  328. package/dist/utils/delay.js +10 -16
  329. package/dist/utils/delay.js.map +1 -1
  330. package/dist/utils/evaluate.js +12 -14
  331. package/dist/utils/evaluate.js.map +1 -1
  332. package/dist/utils/ffmpeg.context.d.ts +6 -0
  333. package/dist/utils/ffmpeg.context.js +31 -0
  334. package/dist/utils/ffmpeg.context.js.map +1 -0
  335. package/dist/utils/ffmpeg.d.ts +85 -29
  336. package/dist/utils/ffmpeg.js +126 -129
  337. package/dist/utils/ffmpeg.js.map +1 -1
  338. package/dist/utils/fileLogger.d.ts +18 -2
  339. package/dist/utils/fileLogger.js +60 -31
  340. package/dist/utils/fileLogger.js.map +1 -1
  341. package/dist/utils/format.d.ts +7 -1
  342. package/dist/utils/format.js +22 -2
  343. package/dist/utils/format.js.map +1 -1
  344. package/dist/utils/html.js +1 -3
  345. package/dist/utils/html.js.map +1 -1
  346. package/dist/utils/index.d.ts +21 -20
  347. package/dist/utils/index.js +1 -0
  348. package/dist/utils/index.js.map +1 -1
  349. package/dist/utils/logger.js +31 -45
  350. package/dist/utils/logger.js.map +1 -1
  351. package/dist/utils/m3u.d.ts +18 -3
  352. package/dist/utils/m3u.js +28 -14
  353. package/dist/utils/m3u.js.map +1 -1
  354. package/dist/utils/memo.d.ts +19 -0
  355. package/dist/utils/memo.js +53 -0
  356. package/dist/utils/memo.js.map +1 -0
  357. package/dist/utils/morganStream.js +4 -4
  358. package/dist/utils/morganStream.js.map +1 -1
  359. package/dist/utils/pid.d.ts +2 -2
  360. package/dist/utils/pid.js +2 -2
  361. package/dist/utils/platform.d.ts +1 -1
  362. package/dist/utils/platform.js +3 -2
  363. package/dist/utils/platform.js.map +1 -1
  364. package/dist/utils/retry.d.ts +2 -0
  365. package/dist/utils/retry.js +9 -7
  366. package/dist/utils/retry.js.map +1 -1
  367. package/dist/utils/sanitize.d.ts +2 -2
  368. package/dist/utils/sanitize.js +4 -4
  369. package/dist/utils/streamContext.d.ts +7 -0
  370. package/dist/utils/streamContext.js +10 -1
  371. package/dist/utils/streamContext.js.map +1 -1
  372. package/dist/utils/timing.d.ts +6 -3
  373. package/dist/utils/timing.js +10 -6
  374. package/dist/utils/timing.js.map +1 -1
  375. package/dist/utils/version.d.ts +3 -2
  376. package/dist/utils/version.js +15 -28
  377. package/dist/utils/version.js.map +1 -1
  378. package/package.json +14 -10
  379. package/dist/config/providerPacks.d.ts +0 -46
  380. package/dist/config/providerPacks.js.map +0 -1
  381. package/dist/config/providers.d.ts +0 -175
  382. package/dist/config/providers.js +0 -691
  383. package/dist/config/providers.js.map +0 -1
  384. package/dist/native/intercept.d.ts +0 -35
  385. package/dist/native/intercept.js +0 -184
  386. package/dist/native/intercept.js.map +0 -1
  387. package/dist/routes/config/channels/routes.js +0 -1477
  388. package/dist/routes/config/channels/routes.js.map +0 -1
  389. package/dist/routes/config/providers.js.map +0 -1
  390. package/dist/routes/providers.d.ts +0 -6
  391. package/dist/routes/providers.js.map +0 -1
  392. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -1,11 +1,7 @@
1
- /* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
- *
3
- * table.ts: Channel table rendering for the PrismCast configuration interface.
4
- */
5
1
  import { ICON_BOLT, ICON_COPY, ICON_DELETE, ICON_DISABLE, ICON_EDIT, ICON_ENABLE, ICON_FILTER, ICON_HEALTH, ICON_LINK, ICON_LOGIN, ICON_MANAGE, ICON_REVERT, ICON_TRANSFER } from "../../icons.js";
6
- import { compareChannelSort, getAllProviderTags, getAuthDomainForChannel, getChannelProviderLabel, getChannelProviderTags, getChannelSortKey, getEnabledProviders, getProviderGroup, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolvePredefinedVariant, resolveProviderKey } from "../../../config/providers.js";
2
+ import { compareChannelSort, getAllServiceTags, getAuthDomainForChannel, getChannelServiceLabel, getChannelServiceTags, getChannelSortKey, getEnabledServices, getPredefinedDomainMap, getServiceGroup, hasMultipleServices, isChannelAvailableByService, isServiceTagEnabled, resolvePredefinedVariant, resolveServiceKey } from "../../../config/services.js";
7
3
  import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
8
- import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
4
+ import { getActiveTagVocabulary, getChannelCustomizations, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getEffectiveHdhrEnabled, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, isVisibleChannel, tagsMatch } from "../../../config/userChannels.js";
9
5
  import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
10
6
  import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
11
7
  import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
@@ -14,22 +10,22 @@ import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
14
10
  import { categorizeProfiles } from "../index.js";
15
11
  import { generateWizardModal } from "../../components.js";
16
12
  /**
17
- * Generates an annotated provider display span. The client-side page-load script processes these elements via providerIconHtml, rendering the appropriate
18
- * icon + text combination. The server just emits the text with data attributes — all icon rendering is client-side through the single providerIconHtml path.
19
- * @param name - The provider display name.
20
- * @param domain - The provider's domain for icon fallback derivation. Undefined for providers without a known domain.
13
+ * Generates an annotated service display span. The client-side page-load script processes these elements via serviceIconHtml, rendering the appropriate
14
+ * icon + text combination. The server just emits the text with data attributes - all icon rendering is client-side through the single serviceIconHtml path.
15
+ * @param name - The service display name.
16
+ * @param domain - The service's domain for icon fallback derivation. Undefined for services without a known domain.
21
17
  * @param iconUrl - Optional explicit icon URL to try before domain-derived fallbacks.
22
18
  * @param small - When true, uses the small icon variant for chips.
23
19
  * @returns HTML span string with data attributes for client-side processing.
24
20
  */
25
- function providerDisplaySpan(name, domain, iconUrl, small) {
21
+ function serviceDisplaySpan(name, domain, iconUrl, small) {
26
22
  const domainAttr = domain ? " data-domain=\"" + escapeHtml(domain) + "\"" : "";
27
23
  const iconAttr = iconUrl ? " data-icon-url=\"" + escapeHtml(iconUrl) + "\"" : "";
28
24
  const sizeAttr = small ? " data-sm" : "";
29
25
  return "<span class=\"provider-display\"" + domainAttr + iconAttr + sizeAttr + ">" + escapeHtml(name) + "</span>";
30
26
  }
31
27
  // Optional column definitions for the channels table. These columns can be shown or hidden by the user via the column picker. The order here determines the
32
- // column order in the table, slotted between Provider and Actions.
28
+ // column order in the table, slotted between Service and Actions.
33
29
  export const OPTIONAL_COLUMNS = [
34
30
  { align: "center", cssClass: "col-chnum", field: "channelNumber", label: "Number", width: "70px" },
35
31
  { align: "center", cssClass: "col-hdhr", field: "hdhrEnabled", label: "HDHR", width: "55px" },
@@ -58,10 +54,18 @@ function generateTextField(id, name, label, value, options = {}) {
58
54
  const required = options.required ? " required" : "";
59
55
  const pattern = options.pattern ? " pattern=\"" + options.pattern + "\"" : "";
60
56
  const placeholder = options.placeholder ? " placeholder=\"" + escapeHtml(options.placeholder) + "\"" : "";
61
- lines.push("<div class=\"form-row\">");
62
- lines.push("<label for=\"" + id + "\">" + label + "</label>");
57
+ // Modification tracking is driven by storage provenance (isCustomized), not value comparison. A field is customized iff the user has explicitly set it on
58
+ // the canonical or active variant stored entry - that is the single source of truth, supplied by getChannelCustomizations in the storage layer.
59
+ const isModified = options.isCustomized === true;
60
+ const modifiedClass = isModified ? " modified" : "";
61
+ const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
62
+ const defaultAttr = (options.defaultValue !== undefined) ? " data-default=\"" + escapeHtml(options.defaultValue) + "\"" : "";
63
+ const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
64
+ "onclick=\"resetChannelField('" + id + "')\">&#8635;</button>" : "";
65
+ lines.push("<div class=\"form-row" + modifiedClass + "\">");
66
+ lines.push(modifiedDot + "<label for=\"" + id + "\">" + label + "</label>");
63
67
  lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
64
- placeholder + " value=\"" + escapeHtml(value) + "\">");
68
+ placeholder + defaultAttr + " value=\"" + escapeHtml(value) + "\">" + resetBtn);
65
69
  lines.push("</div>");
66
70
  // When a datalist ID is specified, append an empty <datalist> element outside the form-row flex container. The client-side JavaScript populates it dynamically
67
71
  // based on the URL field value.
@@ -79,11 +83,21 @@ function generateTextField(id, name, label, value, options = {}) {
79
83
  * @param selectedProfile - The currently selected profile (empty string for autodetect).
80
84
  * @param profiles - List of available profiles with descriptions and summaries.
81
85
  * @param showHint - Whether to show the hint text with profile reference link.
86
+ * @param defaultProfile - When provided, used as the data-default attribute for the per-field reset button. Should be supplied alongside isCustomized=true.
87
+ * @param isCustomized - True when the user has explicitly customized the profile field (presence in stored deltas). Drives the modified visual treatment and
88
+ * the per-field reset button. Independent of value comparison - storage provenance is the single source of truth.
82
89
  * @returns Array of HTML strings for the form row.
83
90
  */
84
- function generateProfileDropdown(id, selectedProfile, profiles, showHint = true) {
91
+ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true, defaultProfile, isCustomized) {
85
92
  const lines = [];
86
93
  const groups = categorizeProfiles(profiles);
94
+ // Modification tracking is driven by storage provenance (isCustomized), not value comparison. Symmetric with generateTextField.
95
+ const isModified = isCustomized === true;
96
+ const modifiedClass = isModified ? " modified" : "";
97
+ const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
98
+ const defaultAttr = (defaultProfile !== undefined) ? " data-default=\"" + escapeHtml(defaultProfile) + "\"" : "";
99
+ const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
100
+ "onclick=\"resetChannelField('" + id + "')\">&#8635;</button>" : "";
87
101
  // Helper to generate option elements for a profile.
88
102
  const renderOption = (profile) => {
89
103
  const selected = (profile.name === selectedProfile) ? " selected" : "";
@@ -91,9 +105,9 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
91
105
  const displayText = profile.summary ? profile.name + " \u2014 " + profile.summary : profile.name;
92
106
  return "<option value=\"" + escapeHtml(profile.name) + "\"" + title + selected + ">" + escapeHtml(displayText) + "</option>";
93
107
  };
94
- lines.push("<div class=\"form-row\">");
95
- lines.push("<label for=\"" + id + "\">Profile</label>");
96
- lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\">");
108
+ lines.push("<div class=\"form-row" + modifiedClass + "\">");
109
+ lines.push(modifiedDot + "<label for=\"" + id + "\">Profile</label>");
110
+ lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\"" + defaultAttr + ">");
97
111
  lines.push("<option value=\"\">Autodetect (Recommended)</option>");
98
112
  // Fullscreen API profiles (most common).
99
113
  if (groups.api.length > 0) {
@@ -135,7 +149,7 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
135
149
  }
136
150
  lines.push("</optgroup>");
137
151
  }
138
- lines.push("</select>");
152
+ lines.push("</select>" + resetBtn);
139
153
  lines.push("</div>");
140
154
  if (showHint) {
141
155
  lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
@@ -248,7 +262,7 @@ function generateProfileReference(profiles) {
248
262
  if (groups.custom.length > 0) {
249
263
  lines.push("<div class=\"profile-category\">");
250
264
  lines.push("<h4>Custom Profiles</h4>");
251
- lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from provider packs.</p>");
265
+ lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from service packs.</p>");
252
266
  lines.push("<dl class=\"profile-list\">");
253
267
  for (const profile of groups.custom) {
254
268
  lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
@@ -260,8 +274,14 @@ function generateProfileReference(profiles) {
260
274
  lines.push("</div>");
261
275
  return lines.join("\n");
262
276
  }
277
+ /**
278
+ * Generates HTML for the advanced fields section (station ID, channel selector, channel number, guide title, logo URL, HDHomeRun toggle, tags).
279
+ * @param idPrefix - Prefix for element IDs ("add" or "edit").
280
+ * @param options - Field values, defaults, and customization metadata. See AdvancedFieldOptions.
281
+ * @returns Array of HTML strings for the advanced fields section.
282
+ */
263
283
  function generateAdvancedFields(idPrefix, options = {}) {
264
- const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "", tagsValue = "" } = options;
284
+ const { channelNumberValue = "", channelSelectorValue = "", guideTitleValue = "", hdhrEnabled = true, logoUrlValue = "", showHints = true, stationIdValue = "", tagsValue = "" } = options;
265
285
  const lines = [];
266
286
  // Advanced fields toggle.
267
287
  lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
@@ -270,8 +290,18 @@ function generateAdvancedFields(idPrefix, options = {}) {
270
290
  lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
271
291
  // Station ID.
272
292
  const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
273
- lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { hint: stationIdHint, placeholder: showHints ? "e.g., 12345" : undefined }));
274
- // Channel selector. The guide-based provider list is derived from the provider module registry so it stays current as providers are added.
293
+ const defs = options.defaults;
294
+ const customized = options.customizedFields;
295
+ lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { defaultValue: defs?.stationId, hint: stationIdHint, isCustomized: customized?.has("stationId"), placeholder: showHints ? "e.g., 12345" : undefined }));
296
+ // Guide title override. When set, this value replaces the channel name in the M3U playlist's tvg-name attribute and guide placeholder airings.
297
+ const guideTitleHint = showHints ? "Optional title for guide display. When set, overrides the channel name in the M3U playlist (tvg-name)." : undefined;
298
+ lines.push(...generateTextField(idPrefix + "-guideTitle", "guideTitle", "Guide Title", guideTitleValue, { defaultValue: defs?.guideTitle, hint: guideTitleHint, isCustomized: customized?.has("guideTitle"),
299
+ placeholder: showHints ? "e.g., Flighty Airport Delays" : undefined }));
300
+ // Custom logo URL. When set, this value is emitted as the tvg-logo attribute in the M3U playlist.
301
+ const logoUrlHint = showHints ? "Optional logo URL for the channel. Overrides any logo derived from Channels DVR." : undefined;
302
+ lines.push(...generateTextField(idPrefix + "-logoUrl", "logoUrl", "Logo URL", logoUrlValue, { defaultValue: defs?.logoUrl, hint: logoUrlHint, isCustomized: customized?.has("logoUrl"),
303
+ placeholder: showHints ? "https://example.com/logo.png" : undefined, type: "url" }));
304
+ // Channel selector. The guide-based service list is derived from the provider module registry so it stays current as services are added.
275
305
  const guideProviderNames = getProviderModuleInfo().map((p) => p.label).sort().join(", ");
276
306
  const channelSelectorHint = showHints ?
277
307
  "Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
@@ -279,23 +309,30 @@ function generateAdvancedFields(idPrefix, options = {}) {
279
309
  "For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
280
310
  "(typically a portion of the image src URL)." :
281
311
  undefined;
282
- lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
312
+ lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { defaultValue: defs?.channelSelector, hint: channelSelectorHint, isCustomized: customized?.has("channelSelector"),
313
+ list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
283
314
  // Channel number for Channels DVR and Plex integration.
284
315
  const channelNumberHint = showHints ?
285
316
  "Optional numeric channel number for guide matching in Channels DVR and Plex." :
286
317
  undefined;
287
- lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
318
+ lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { defaultValue: defs?.channelNumber, hint: channelNumberHint, isCustomized: customized?.has("channelNumber"),
319
+ placeholder: showHints ? "e.g., 501" : undefined }));
288
320
  // Tags field: checkbox grid of the active tag vocabulary. Checked tags are collected into a hidden input as a comma-separated value on form submission.
289
321
  // This prevents users from entering tag names that don't exist in the managed vocabulary.
290
322
  const vocabulary = getActiveTagVocabulary();
291
- const currentTags = new Set(tagsValue ? tagsValue.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0) : []);
323
+ const currentTags = tagsValue ? tagsValue.split(",").map((t) => t.trim()).filter((t) => t.length > 0) : [];
292
324
  if (vocabulary.length > 0) {
293
- lines.push("<div class=\"form-row\">");
294
- lines.push("<label>Tags</label>");
295
- lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\">");
325
+ // Tags are an identity field stored on the canonical entry. Customization is presence in the canonical-stored entry, surfaced via customizedFields.
326
+ const tagsModified = customized?.has("tags") === true;
327
+ const tagsModifiedClass = tagsModified ? " modified" : "";
328
+ const tagsModifiedDot = tagsModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
329
+ const tagsDefaultAttr = (defs?.tags !== undefined) ? " data-default=\"" + escapeHtml(defs.tags) + "\"" : "";
330
+ lines.push("<div class=\"form-row" + tagsModifiedClass + "\">");
331
+ lines.push(tagsModifiedDot + "<label>Tags</label>");
332
+ lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\"" + tagsDefaultAttr + ">");
296
333
  lines.push("<div class=\"tag-checkbox-grid\">");
297
334
  for (const tag of vocabulary) {
298
- const checked = currentTags.has(tag) ? " checked" : "";
335
+ const checked = currentTags.some((t) => tagsMatch(t, tag)) ? " checked" : "";
299
336
  lines.push("<label class=\"tag-checkbox-label\">" +
300
337
  "<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
301
338
  " onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
@@ -310,22 +347,29 @@ function generateAdvancedFields(idPrefix, options = {}) {
310
347
  // HDHomeRun lineup inclusion. A hidden input provides the "false" value when the checkbox is unchecked (unchecked checkboxes are not submitted in FormData).
311
348
  // When checked, the checkbox value "true" overwrites the hidden input's "false" since it appears later in DOM order.
312
349
  const hdhrChecked = hdhrEnabled ? " checked" : "";
350
+ // Customization is presence in stored deltas (identity field, lives on canonical entry); surfaced via customizedFields. The default value (when present)
351
+ // arrives pre-stringified as "true" or "false" from resetValueFor -> getEffectiveHdhrEnabled, so it goes directly into the data-default attribute.
352
+ const hdhrModified = customized?.has("hdhrEnabled") === true;
353
+ const hdhrModifiedClass = hdhrModified ? " modified" : "";
354
+ const hdhrModifiedDot = hdhrModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
355
+ const hdhrDefaultAttr = (defs?.hdhrEnabled !== undefined) ? " data-default=\"" + defs.hdhrEnabled + "\"" : "";
313
356
  const hdhrHint = showHints ? "When unchecked, this channel is hidden from the HDHomeRun lineup and not available in Plex." : undefined;
314
- lines.push("<div class=\"form-row form-row-checkbox\">");
315
- lines.push("<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
357
+ lines.push("<div class=\"form-row form-row-checkbox" + hdhrModifiedClass + "\">");
358
+ lines.push(hdhrModifiedDot + "<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
316
359
  lines.push("<input type=\"hidden\" name=\"hdhrEnabled\" value=\"false\">");
317
- lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + ">");
360
+ lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + hdhrDefaultAttr + ">");
318
361
  lines.push("</div>");
319
362
  if (hdhrHint) {
320
363
  lines.push("<div class=\"hint\">" + hdhrHint + "</div>");
321
364
  }
322
- lines.push("</div>"); // End advanced fields.
365
+ // Close the advanced-fields wrapper opened at the top of this builder.
366
+ lines.push("</div>");
323
367
  return lines;
324
368
  }
325
369
  /**
326
370
  * Generates JavaScript variables for channel selector datalist population. Produces three variables: `channelSelectorsByDomain` maps URL hostnames to known
327
- * channel selector values (from predefined channels and cached provider discovery), `providerByDomain` maps provider guide URL hostnames to provider slugs for
328
- * client-side async discovery, and `providerGuideUrl` maps provider slugs to their guide URLs for URL correction hints. Embedded as a `<script>` block in the
371
+ * channel selector values (from predefined channels and cached service discovery), `serviceByDomain` maps service guide URL hostnames to service slugs for
372
+ * client-side async discovery, and `serviceGuideUrl` maps service slugs to their guide URLs for URL correction hints. Embedded as a `<script>` block in the
329
373
  * channels panel.
330
374
  *
331
375
  * @returns JavaScript variable declarations ready to embed in a `<script>` tag.
@@ -339,8 +383,12 @@ function generateChannelSelectorData() {
339
383
  }
340
384
  // Use the resolved channel (with inheritance from the canonical) so stationId, name, and other inherited fields are populated correctly for variants.
341
385
  // The channelSelector is captured from the raw entry (already validated as non-null) to avoid non-null assertions on the resolved channel's optional field.
386
+ // resolvePredefinedVariant returns a fully-typed ResolvedChannel for both canonical and variant keys; defensive skip for the rare missing-canonical case.
342
387
  const selector = raw.channelSelector;
343
- const channel = resolvePredefinedVariant(key) ?? raw;
388
+ const channel = resolvePredefinedVariant(key);
389
+ if (!channel) {
390
+ continue;
391
+ }
344
392
  const hostname = new URL(channel.url).hostname;
345
393
  seen[hostname] ??= new Set();
346
394
  if (seen[hostname].has(selector)) {
@@ -350,32 +398,33 @@ function generateChannelSelectorData() {
350
398
  byDomain[hostname] ??= [];
351
399
  byDomain[hostname].push({ label: channel.name ?? selector, stationId: channel.stationId, value: selector });
352
400
  }
353
- // Merge cached provider-discovered channels into the domain map. Predefined entries take precedence — we only add discovered channels whose channelSelector
354
- // value is not already present for that domain. This enriches the datalist with the full provider lineup when precaching or prior discovery has run.
401
+ // Merge cached service-discovered channels into the domain map. Predefined entries take precedence - we only add discovered channels whose channelSelector
402
+ // value is not already present for that domain. This enriches the datalist with the full service lineup when precaching or prior discovery has run.
355
403
  for (const provider of getCachedProviderChannels()) {
356
- seen[provider.hostname] ??= new Set();
357
- byDomain[provider.hostname] ??= [];
404
+ const seenForHost = seen[provider.hostname] ??= new Set();
405
+ const entriesForHost = byDomain[provider.hostname] ??= [];
358
406
  for (const entry of provider.entries) {
359
- if (!seen[provider.hostname].has(entry.value)) {
360
- seen[provider.hostname].add(entry.value);
361
- byDomain[provider.hostname].push(entry);
407
+ if (!seenForHost.has(entry.value)) {
408
+ seenForHost.add(entry.value);
409
+ entriesForHost.push(entry);
362
410
  }
363
411
  }
364
412
  }
365
- // Build the hostname→slug map and slug→guideUrl map for all providers (including those with cold caches) so the client-side fetch can trigger discovery for any
366
- // provider domain and the URL hint can suggest the correct guide URL.
367
- const providerByDomain = getProviderDomainMap();
368
- const providerGuideUrl = getProviderGuideUrls();
413
+ // Build the hostname->slug map and slug->guideUrl map for all services (including those with cold caches) so the client-side fetch can trigger discovery for any
414
+ // service domain and the URL hint can suggest the correct guide URL.
415
+ const serviceByDomain = getProviderDomainMap();
416
+ const serviceGuideUrl = getProviderGuideUrls();
369
417
  // Sort entries within each domain alphabetically by label for consistent ordering in the datalist dropdown.
370
418
  for (const entries of Object.values(byDomain)) {
371
419
  entries.sort((a, b) => a.label.localeCompare(b.label));
372
420
  }
373
421
  return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";\n" +
374
- "var providerByDomain = " + JSON.stringify(providerByDomain) + ";\n" +
375
- "var providerGuideUrl = " + JSON.stringify(providerGuideUrl) + ";";
422
+ "var serviceByDomain = " + JSON.stringify(serviceByDomain) + ";\n" +
423
+ "var serviceGuideUrl = " + JSON.stringify(serviceGuideUrl) + ";\n" +
424
+ "var predefinedByDomain = " + JSON.stringify(getPredefinedDomainMap()) + ";";
376
425
  }
377
426
  /**
378
- * Generates the tag column filter dropdown content. This is the single source of truth for the filter checkbox list — used for both the initial header render
427
+ * Generates the tag column filter dropdown content. This is the single source of truth for the filter checkbox list - used for both the initial header render
379
428
  * and for incremental updates after tag CRUD operations. The dropdown shell (button, dropdown-menu wrapper) is rendered by the header generator; this function
380
429
  * provides only the inner content (checkbox labels + "Show All" item).
381
430
  * @returns HTML string for the filter dropdown content.
@@ -395,7 +444,7 @@ export function generateTagFilterContent() {
395
444
  return lines.join("");
396
445
  }
397
446
  /**
398
- * Generates the Tag Management modal body HTML. This is the single source of truth for tag manager content — used for both the initial server render and for
447
+ * Generates the Tag Management modal body HTML. This is the single source of truth for tag manager content - used for both the initial server render and for
399
448
  * incremental updates after tag CRUD operations. The endpoints return this HTML in the response so the client can replace the modal content without a page reload.
400
449
  * @returns HTML string for the tag manager body (tag list, input field, deleted tags section).
401
450
  */
@@ -411,7 +460,7 @@ export function generateTagManagerBody() {
411
460
  "<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete tag\" onclick=\"deleteTag('" + escapeHtml(tag) +
412
461
  "')\">" + ICON_DELETE + "</button></div>");
413
462
  }
414
- // Deleted predefined tags section — show restore option for tags the user has deleted.
463
+ // Deleted predefined tags section - show restore option for tags the user has deleted.
415
464
  const deletedItems = [];
416
465
  for (const tag of registry.deletedTags) {
417
466
  deletedItems.push("<div class=\"tag-manager-item tag-deleted\" data-tag=\"" + escapeHtml(tag) + "\">" +
@@ -424,7 +473,7 @@ export function generateTagManagerBody() {
424
473
  "for playlist filtering and organization.</p>" +
425
474
  "<div class=\"tag-manager-add\">" +
426
475
  "<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
427
- "pattern=\"[a-z0-9]([a-z0-9-]*[a-z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
476
+ "pattern=\"[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
428
477
  "<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
429
478
  "</div>" +
430
479
  "<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
@@ -452,13 +501,13 @@ function generateTagManagementModal() {
452
501
  });
453
502
  }
454
503
  /**
455
- * Generates the Browse Channels 2-step wizard modal. Step 1 is the provider picker grid, step 2 is channel discovery and management. Channel state detection
456
- * (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Provider and guide URL data are
504
+ * Generates the Browse Channels 2-step wizard modal. Step 1 is the service picker grid, step 2 is channel discovery and management. Channel state detection
505
+ * (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Service and guide URL data are
457
506
  * embedded as JSON data blocks for the client-side wizard controller.
458
507
  * @returns HTML string for the browse modal.
459
508
  */
460
509
  function generateBrowseModal() {
461
- // Build the provider info array from the provider module registry. The client uses this to render the provider picker.
510
+ // Build the service info array from the provider module registry. The client uses this to render the service picker.
462
511
  const providers = getProviderModuleInfo();
463
512
  const guideUrls = getProviderGuideUrls();
464
513
  return generateWizardModal({
@@ -469,26 +518,26 @@ function generateBrowseModal() {
469
518
  ],
470
519
  contentId: "browse-content",
471
520
  dataBlocks: [
472
- "<script type=\"application/json\" id=\"browse-providers-data\">" + JSON.stringify(providers) + "</script>",
521
+ "<script type=\"application/json\" id=\"browse-services-data\">" + JSON.stringify(providers) + "</script>",
473
522
  "<script type=\"application/json\" id=\"browse-guide-urls-data\">" + JSON.stringify(guideUrls) + "</script>"
474
523
  ],
475
- description: "Manage your channel lineup by provider. Add new channels, switch providers for existing channels, or remove channels you no longer need.",
524
+ description: "Manage your channel lineup by service. Add new channels, switch services for existing channels, or remove channels you no longer need.",
476
525
  id: "browse-modal",
477
- steps: ["Provider", "Channels"],
526
+ steps: ["Service", "Channels"],
478
527
  title: "Browse Channels",
479
528
  titleId: "browse-title"
480
529
  });
481
530
  }
482
531
  /**
483
- * Generates the Provider Setup 3-step wizard modal. Steps: Providers (multi-select), Sign In (sequential auth), Channels (summary + finish). The
532
+ * Generates the Service Setup 3-step wizard modal. Steps: Services (multi-select), Sign In (sequential auth), Channels (summary + finish). The
484
533
  * setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
485
534
  * @returns HTML string for the setup wizard modal.
486
535
  */
487
536
  function generateSetupWizardModal() {
488
- const enabled = getEnabledProviders();
489
- // Build provider data with enabled state from the current provider filter. getAllProviderTags is the single source of truth for provider metadata
490
- // (domain, iconUrl, displayName). The enabled field lets the client pre-check providers on re-run without scraping DOM state.
491
- const tags = getAllProviderTags()
537
+ const enabled = getEnabledServices();
538
+ // Build service data with enabled state from the current service filter. getAllServiceTags is the single source of truth for service metadata
539
+ // (domain, iconUrl, displayName). The enabled field lets the client pre-check services on re-run without reading DOM state.
540
+ const tags = getAllServiceTags()
492
541
  .filter((t) => t.tag !== "direct")
493
542
  .map((t) => ({ ...t, enabled: enabled.includes(t.tag) }));
494
543
  return generateWizardModal({
@@ -501,19 +550,19 @@ function generateSetupWizardModal() {
501
550
  contentId: "setup-content",
502
551
  dataAttributes: { "setup-completed": CONFIG.channels.setupCompleted ? "true" : "false" },
503
552
  dataBlocks: [
504
- "<script type=\"application/json\" id=\"setup-providers-data\">" + JSON.stringify(tags) + "</script>"
553
+ "<script type=\"application/json\" id=\"setup-services-data\">" + JSON.stringify(tags) + "</script>"
505
554
  ],
506
- description: "Set up your streaming providers in three steps: select your services, sign in, and choose your channels.",
555
+ description: "Set up your streaming services in three steps: select your services, sign in, and choose your channels.",
507
556
  errorId: "setup-error",
508
557
  id: "setup-modal",
509
- steps: ["Providers", "Sign In", "Channels"],
558
+ steps: ["Services", "Sign In", "Channels"],
510
559
  stepsId: "setup-steps",
511
- title: "Provider Setup",
560
+ title: "Service Setup",
512
561
  titleId: "setup-title"
513
562
  });
514
563
  }
515
564
  /**
516
- * Generates the HTML for a single channel's table rows (display row and edit form row). All channels — predefined, override, and user-defined — get both rows.
565
+ * Generates the HTML for a single channel's table rows (display row and edit form row). All channels - predefined, override, and user-defined - get both rows.
517
566
  * The edit form is pre-populated with the effective (resolved) values so users see what they're changing. When called from generateChannelsPanel() which already
518
567
  * has the listing entry, pass it via the entry parameter to avoid redundant getChannelListing() calls. POST handlers that generate a single row omit the
519
568
  * parameter to trigger an internal lookup.
@@ -534,60 +583,66 @@ export function generateChannelRowHtml(key, profiles, entry) {
534
583
  const isPredefined = isPredefinedChannel(key);
535
584
  const isOverride = isPredefined && isUser;
536
585
  const isDisabled = isPredefinedChannelDisabled(key);
537
- const isAvailableByProvider = isChannelAvailableByProvider(key);
538
- // Check if this channel has multiple providers.
539
- const providerGroup = getProviderGroup(key);
540
- // Build the provider tags data attribute for client-side filtering.
541
- const providerTags = getChannelProviderTags(key).join(",");
542
- // Generate display row. User channels get one CSS class, disabled predefined get another, provider-filtered get a third.
586
+ const isAvailableByService = isChannelAvailableByService(key);
587
+ // Check if this channel has multiple services.
588
+ const serviceGroup = getServiceGroup(key);
589
+ // Build the service tags data attribute for client-side filtering.
590
+ const serviceTags = getChannelServiceTags(key).join(",");
591
+ // Generate display row. User-created channels and predefined overrides get distinct CSS classes - user-created channels have a subtle background tint
592
+ // ("user-channel"), while predefined overrides get a left-border accent and blue dot indicator via CSS ::before ("channel-override") matching the settings
593
+ // form's visual language for "modified from defaults." Disabled and service-filtered rows get additional classes for opacity and visibility control.
543
594
  const displayLines = [];
544
595
  const rowClasses = [];
545
- if (isUser) {
596
+ if (isOverride) {
597
+ rowClasses.push("channel-override");
598
+ }
599
+ else if (isUser) {
546
600
  rowClasses.push("user-channel");
547
601
  }
548
602
  if (isDisabled) {
549
603
  rowClasses.push("channel-disabled");
550
604
  }
551
- if (!isAvailableByProvider) {
605
+ if (!isAvailableByService) {
552
606
  rowClasses.push("channel-unavailable");
553
607
  }
554
608
  const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
555
- // Compute effective tags early — used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
609
+ const rowTitleAttr = isOverride ? " title=\"Customized from predefined defaults\"" : "";
610
+ // Compute effective tags early - used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
556
611
  const effectiveTags = getChannelEffectiveTags(channel);
557
612
  const channelTagsAttr = (effectiveTags.length > 0) ? " data-channel-tags=\"" + escapeHtml(effectiveTags.join(",")) + "\"" : "";
558
- displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) +
613
+ displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + rowTitleAttr + " data-provider-tags=\"" + escapeHtml(serviceTags) +
559
614
  "\"" + channelTagsAttr + ">");
560
615
  displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
561
616
  const channelLogoUrl = getChannelLogo(key) ?? "";
562
617
  const logoAttr = channelLogoUrl ? " data-logo=\"" + escapeHtml(channelLogoUrl) + "\"" : "";
563
618
  displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "name")) + "\"" + logoAttr +
564
619
  "><span class=\"channel-name-cell\">" + escapeHtml(channel.name ?? key) + "</span></td>");
565
- // Provider column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers"
566
- // label alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
567
- displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "provider")) + "\">");
568
- const labelHidden = isAvailableByProvider ? " style=\"display:none\"" : "";
569
- const contentHidden = isAvailableByProvider ? "" : " style=\"display:none\"";
570
- displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available providers</em>");
571
- if (hasMultipleProviders(key) && providerGroup) {
572
- // Multi-provider: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the provider selection changes. Filtered-out
573
- // options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls filterChannelRows()
620
+ // Service column: dropdown for multi-service channels, static service name for single-service. Both states always render a hidden "No available services"
621
+ // label alongside the service content so that client-side channelTable.filter() can toggle between them without a page reload.
622
+ displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "service")) + "\">");
623
+ const labelHidden = isAvailableByService ? " style=\"display:none\"" : "";
624
+ const contentHidden = isAvailableByService ? "" : " style=\"display:none\"";
625
+ displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available services</em>");
626
+ if (hasMultipleServices(key) && serviceGroup) {
627
+ // Multi-service: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the service selection changes. Filtered-out
628
+ // options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls channelTable.filter()
574
629
  // to remove them from the DOM.
575
- const currentSelection = resolveProviderKey(key);
630
+ const currentSelection = resolveServiceKey(key);
576
631
  displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
577
- "\" title=\"Choose which streaming provider delivers this channel\" onchange=\"updateProviderSelection(this)\"" + contentHidden + ">");
578
- for (const variant of providerGroup.variants) {
632
+ "\" title=\"Choose which streaming service delivers this channel\" onchange=\"updateServiceSelection(this)\"" + contentHidden + ">");
633
+ for (const variant of serviceGroup.variants) {
579
634
  const selected = (variant.key === currentSelection) ? " selected" : "";
580
- const optionHidden = !isProviderTagEnabled(variant.tag) ? " hidden" : "";
635
+ const optionHidden = !isServiceTagEnabled(variant.tag) ? " hidden" : "";
581
636
  displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(variant.tag) + "\"" + selected + optionHidden + ">" +
582
637
  escapeHtml(variant.label) + "</option>");
583
638
  }
584
639
  displayLines.push("</select>");
585
640
  }
586
641
  else {
587
- // Single-provider: wrap the provider name in a span so client-side JS can toggle it with the no-provider label. Uses profile-aware label resolution so
588
- // channels with explicit profile assignments show the profile's provider name rather than the built-in name for the URL domain.
642
+ // Single-service: wrap the service name in a span so client-side JS can toggle it with the no-service label. Uses profile-aware label resolution so
643
+ // channels with explicit profile assignments show the profile's service name rather than the built-in name for the URL domain.
589
644
  displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
590
- escapeHtml(getChannelProviderLabel(channel)) + "</span>");
645
+ escapeHtml(getChannelServiceLabel(channel)) + "</span>");
591
646
  }
592
647
  displayLines.push("</td>");
593
648
  // Optional columns: Number, HDHR, Station ID, Profile, Selector. All five are always rendered; visibility is controlled by CSS classes on the table element.
@@ -597,7 +652,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
597
652
  "\" onclick=\"startInlineEdit(this)\">" + (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "<span class=\"text-muted\">&ndash;</span>") +
598
653
  "</td>");
599
654
  // HDHR column: inline checkbox for quick toggling. The checkbox submits changes via the toggleHdhr() client-side function.
600
- const hdhrCheckedAttr = (channel.hdhrEnabled !== false) ? " checked" : "";
655
+ const hdhrCheckedAttr = getEffectiveHdhrEnabled(channel) ? " checked" : "";
601
656
  displayLines.push("<td class=\"col-hdhr\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "hdhrEnabled")) +
602
657
  "\"><input type=\"checkbox\" data-key=\"" + cellKey + "\"" + hdhrCheckedAttr +
603
658
  " onchange=\"toggleHdhr(this)\" title=\"Include in HDHomeRun/Plex lineup\"></td>");
@@ -612,7 +667,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
612
667
  else {
613
668
  const resolved = getProfileForChannel(channel);
614
669
  if (resolved.profileName !== "default") {
615
- const label = getChannelProviderLabel(channel);
670
+ const label = getChannelServiceLabel(channel);
616
671
  displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey +
617
672
  "\"><span class=\"text-muted\">" + escapeHtml(label + " (auto)") + "</span></td>");
618
673
  }
@@ -634,14 +689,14 @@ export function generateChannelRowHtml(key, profiles, entry) {
634
689
  displayLines.push("<td>");
635
690
  displayLines.push("<div class=\"btn-group\">");
636
691
  const escapedKey = escapeHtml(key);
637
- // Resolve the auth domain for the currently selected provider variant. Used for both login icon color and health icon lookups.
638
- const variantKey = resolveProviderKey(key);
692
+ // Resolve the auth domain for the currently selected service variant. Used for both login icon color and health icon lookups.
693
+ const variantKey = resolveServiceKey(key);
639
694
  const authDomain = getAuthDomainForChannel(variantKey);
640
695
  // Position 1: Edit (all channels).
641
696
  displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
642
697
  "')\">" + ICON_EDIT + "</button>");
643
698
  // Position 2: Login for enabled channels (with domain auth color), placeholder for disabled predefined. Custom channels (user-defined, not predefined) skip
644
- // login coloring because they have no provider concept.
699
+ // login coloring because they have no service concept.
645
700
  if (!isDisabled) {
646
701
  const authTimestamp = (isPredefined || isOverride) ? getDomainAuth(authDomain) : null;
647
702
  const loginColorClass = authTimestamp ? " health-success" : "";
@@ -666,7 +721,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
666
721
  displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
667
722
  }
668
723
  // Position 4: enable/disable toggle for predefined channels (regardless of override state), delete for user-defined channels. The enable/disable state and
669
- // property overrides are independent concerns — a predefined channel can be disabled AND have customizations, and the toggle always reflects the visibility state.
724
+ // property overrides are independent concerns - a predefined channel can be disabled AND have customizations, and the toggle always reflects the visibility state.
670
725
  if (isPredefined) {
671
726
  if (isDisabled) {
672
727
  displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
@@ -694,8 +749,8 @@ export function generateChannelRowHtml(key, profiles, entry) {
694
749
  displayLines.push("</td>");
695
750
  displayLines.push("</tr>");
696
751
  const displayRow = displayLines.join("\n");
697
- // Generate edit form row for all channels. Pre-populate with the currently selected provider's values so the user sees what they're actually streaming, not the
698
- // canonical definition which may differ when a provider variant is selected.
752
+ // Generate edit form row for all channels. Pre-populate with the currently selected service's values so the user sees what they're actually streaming, not the
753
+ // canonical definition which may differ when a service variant is selected.
699
754
  const editLines = [];
700
755
  editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
701
756
  editLines.push("<td colspan=\"" + String(TOTAL_COLUMN_COUNT) + "\">");
@@ -704,24 +759,72 @@ export function generateChannelRowHtml(key, profiles, entry) {
704
759
  editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
705
760
  editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
706
761
  editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
762
+ // For override channels, fetch the customization provenance once. The accessor returns which fields the user has explicitly customized (across canonical and
763
+ // active-variant stored entries) and the per-field reset value (looked up from the appropriate predefined entry, with variant-inheritance fallback). This is
764
+ // the single source of truth for the modified-field treatment - it produces no false positives when service variants are resolved, because storage provenance,
765
+ // not value comparison, drives the determination.
766
+ //
767
+ // For non-override channels (predefined-only or pure user-defined), the customization concept does not apply: predefined-only channels have no user
768
+ // customizations to mark, and pure user channels have no predefined baseline to compare against. Both paths skip the accessor and pass undefined defaults.
769
+ const customizations = isOverride ? getChannelCustomizations(key).customizations : undefined;
770
+ const customizedFields = customizations ? new Set(customizations.keys()) : undefined;
771
+ // resetValueFor stringifies the raw resetValue from the accessor for use in input data-default attributes. Returns undefined when the field is not
772
+ // customized, signalling the field generators to skip data-default and the modified treatment entirely. Array values (tags) are joined with ", " to match
773
+ // the comma-separated form representation; primitive values are stringified normally; null/undefined become the empty string. Field parameter is typed as
774
+ // CustomizableField so the call sites below get compile-time validation that they reference real customizable fields.
775
+ const resetValueFor = (field) => {
776
+ const entry = customizations?.get(field);
777
+ if (!entry) {
778
+ return undefined;
779
+ }
780
+ const value = entry.resetValue;
781
+ if ((value === undefined) || (value === null)) {
782
+ return "";
783
+ }
784
+ if (Array.isArray(value)) {
785
+ return value.join(", ");
786
+ }
787
+ if ((typeof value === "string") || (typeof value === "number") || (typeof value === "boolean")) {
788
+ return String(value);
789
+ }
790
+ return "";
791
+ };
707
792
  // Channel name.
708
793
  editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", channel.name ?? key, {
794
+ defaultValue: resetValueFor("name"),
709
795
  hint: "Friendly name shown in the playlist and UI.",
796
+ isCustomized: customizedFields?.has("name"),
710
797
  required: true
711
798
  }));
712
799
  // Channel URL.
713
800
  editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", channel.url, {
801
+ defaultValue: resetValueFor("url"),
714
802
  hint: "The URL of the streaming page to capture.",
803
+ isCustomized: customizedFields?.has("url"),
715
804
  required: true,
716
805
  type: "url"
717
806
  }));
718
807
  // Profile dropdown.
719
- editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles));
720
- // Advanced fields.
808
+ editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles, true, resetValueFor("profile"), customizedFields?.has("profile")));
809
+ // Advanced fields. Every reset value flows through resetValueFor, which delegates to getChannelCustomizations -> computeResetValue. The accessor applies the
810
+ // effective-view rules (vocabulary-filtered tags, implicit-true hdhrEnabled) and looks up the right predefined source (canonical or variant with inheritance
811
+ // fallback) so the form sees a uniformly stringified default for every field.
721
812
  editLines.push(...generateAdvancedFields("edit-" + key, {
722
813
  channelNumberValue: channel.channelNumber ? String(channel.channelNumber) : "",
723
814
  channelSelectorValue: channel.channelSelector ?? "",
724
- hdhrEnabled: channel.hdhrEnabled !== false,
815
+ customizedFields,
816
+ defaults: isOverride ? {
817
+ channelNumber: resetValueFor("channelNumber"),
818
+ channelSelector: resetValueFor("channelSelector"),
819
+ guideTitle: resetValueFor("guideTitle"),
820
+ hdhrEnabled: resetValueFor("hdhrEnabled"),
821
+ logoUrl: resetValueFor("logoUrl"),
822
+ stationId: resetValueFor("stationId"),
823
+ tags: resetValueFor("tags")
824
+ } : undefined,
825
+ guideTitleValue: channel.guideTitle ?? "",
826
+ hdhrEnabled: getEffectiveHdhrEnabled(channel),
827
+ logoUrlValue: channel.logoUrl ?? "",
725
828
  stationIdValue: channel.stationId ?? "",
726
829
  tagsValue: effectiveTags.join(", ")
727
830
  }));
@@ -742,7 +845,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
742
845
  return { displayRow, editRow };
743
846
  }
744
847
  /**
745
- * Computes the complete channel table state — summary counts and scope toggle counts — from the current channel listing. This is the server-side single source
848
+ * Computes the complete channel table state - summary counts and scope toggle counts - from the current channel listing. This is the server-side single source
746
849
  * of truth for all count computation, replacing the client-side DOM-scanning approach. Called once per mutation as part of building a patch.
747
850
  * @param listing - Optional pre-fetched listing. When omitted, fetches from getChannelListing(). Passing the listing avoids a redundant computation when
748
851
  * buildChannelTablePatch already has it.
@@ -760,7 +863,7 @@ export function buildChannelTableState(listing) {
760
863
  else {
761
864
  predefined++;
762
865
  }
763
- if (!entry.enabled || !entry.availableByProvider) {
866
+ if (!entry.enabled || !entry.availableByService) {
764
867
  disabled++;
765
868
  }
766
869
  }
@@ -772,7 +875,7 @@ export function buildChannelTableState(listing) {
772
875
  };
773
876
  }
774
877
  /**
775
- * Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled, provider-available channels (the same set visible in the table).
878
+ * Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled, service-available channels (the same set visible in the table).
776
879
  * @param listing - The channel listing entries.
777
880
  * @returns An object with `enabled` (HDHR-included) and `total` counts.
778
881
  */
@@ -780,18 +883,18 @@ function getHdhrCounts(listing) {
780
883
  let enabled = 0;
781
884
  let total = 0;
782
885
  for (const entry of listing) {
783
- if (!entry.enabled || !entry.availableByProvider) {
886
+ if (!entry.enabled || !entry.availableByService) {
784
887
  continue;
785
888
  }
786
889
  total++;
787
- if (entry.channel.hdhrEnabled !== false) {
890
+ if (getEffectiveHdhrEnabled(entry.channel)) {
788
891
  enabled++;
789
892
  }
790
893
  }
791
894
  return { enabled, total };
792
895
  }
793
896
  /**
794
- * Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, provider-available
897
+ * Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, service-available
795
898
  * channels have that tag in their effective tags. Returns undefined when the tags column is not visible (no tag toggles to update).
796
899
  * @param listing - The channel listing entries.
797
900
  * @returns A record mapping tag names to { count, total }, or undefined when hidden.
@@ -807,18 +910,18 @@ function getTagCounts(listing) {
807
910
  }
808
911
  const result = {};
809
912
  let total = 0;
810
- // Pre-compute the vocabulary Set once for the per-channel effective tags filter.
811
- const vocabularySet = new Set(vocabulary);
812
913
  const counts = new Map();
813
914
  for (const tag of vocabulary) {
814
915
  counts.set(tag, 0);
815
916
  }
816
917
  for (const entry of listing) {
817
- if (!entry.enabled || !entry.availableByProvider) {
918
+ if (!entry.enabled || !entry.availableByService) {
818
919
  continue;
819
920
  }
820
921
  total++;
821
- const effectiveTags = entry.channel.tags ? entry.channel.tags.filter((tag) => vocabularySet.has(tag)) : [];
922
+ // Use the canonical helper for vocabulary-filtered tags. Inline filtering would diverge from the rest of the codebase on the case-insensitive matching
923
+ // policy that getChannelEffectiveTags applies via tagsMatch.
924
+ const effectiveTags = getChannelEffectiveTags(entry.channel);
822
925
  for (const tag of effectiveTags) {
823
926
  const current = counts.get(tag);
824
927
  if (current !== undefined) {
@@ -857,21 +960,21 @@ export function buildChannelTablePatch(affectedKeys, profiles) {
857
960
  return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts, tagCounts: getTagCounts(listing) };
858
961
  }
859
962
  /**
860
- * Generates the provider filter toolbar HTML with a multi-select dropdown and dismissable chips.
861
- * @returns HTML string for the provider filter toolbar.
963
+ * Generates the service filter toolbar HTML with a multi-select dropdown and dismissable chips.
964
+ * @returns HTML string for the service filter toolbar.
862
965
  */
863
- export function generateProviderFilterToolbar() {
864
- const allTags = getAllProviderTags();
865
- const enabled = getEnabledProviders();
966
+ export function generateServiceFilterToolbar() {
967
+ const allTags = getAllServiceTags();
968
+ const enabled = getEnabledServices();
866
969
  const hasFilter = enabled.length > 0;
867
970
  const lines = [];
868
971
  lines.push("<div class=\"provider-toolbar\">");
869
- // Provider filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
972
+ // Service filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
870
973
  lines.push("<div class=\"toolbar-group\">");
871
974
  lines.push("<div class=\"dropdown provider-dropdown\">");
872
- const buttonText = hasFilter ? "Filtered" : "All Providers";
873
- lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-dropdown-btn\" id=\"provider-filter-btn\" " +
874
- "title=\"Filter channels by streaming provider\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " &#9662;</button>");
975
+ const buttonText = hasFilter ? "Filtered" : "All Services";
976
+ lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-icon-btn\" id=\"provider-filter-btn\" " +
977
+ "title=\"Filter channels by streaming service\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " &#9662;</button>");
875
978
  lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
876
979
  for (const tagInfo of allTags) {
877
980
  const isDirectTag = tagInfo.tag === "direct";
@@ -880,7 +983,7 @@ export function generateProviderFilterToolbar() {
880
983
  const disabledAttr = isDirectTag ? " disabled" : "";
881
984
  lines.push("<label class=\"provider-option\">");
882
985
  lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
883
- " onchange=\"toggleProviderTag(this)\"> " + providerDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
986
+ " onchange=\"toggleServiceTag(this)\"> " + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
884
987
  lines.push("</label>");
885
988
  }
886
989
  lines.push("</div>");
@@ -895,9 +998,9 @@ export function generateProviderFilterToolbar() {
895
998
  const chipTag = allTags.find((t) => t.tag === tag);
896
999
  const displayName = chipTag?.displayName ?? tag;
897
1000
  lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" +
898
- providerDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
1001
+ serviceDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
899
1002
  "<button type=\"button\" class=\"chip-close\" title=\"Remove " + escapeHtml(displayName) + " from filter\" aria-label=\"Remove " +
900
- escapeHtml(displayName) + "\" onclick=\"removeProviderChip('" + escapeHtml(tag) + "')\">&times;</button></span>");
1003
+ escapeHtml(displayName) + "\" onclick=\"removeServiceChip('" + escapeHtml(tag) + "')\">&times;</button></span>");
901
1004
  }
902
1005
  }
903
1006
  lines.push("</div>");
@@ -916,26 +1019,26 @@ export function generateProviderFilterToolbar() {
916
1019
  * @returns HTML string for the Channels panel content.
917
1020
  */
918
1021
  export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
919
- // Get the canonical channel listing (provider variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
920
- // it handles predefined/user merging, disabled state, and provider availability.
1022
+ // Get the canonical channel listing (service variants already filtered out, sorted by key). This is the single source of truth for merged channel data -
1023
+ // it handles predefined/user merging, disabled state, and service availability.
921
1024
  const listing = getChannelListing();
922
1025
  const profiles = getProfiles();
923
- // Count channels hidden from the default view: disabled predefined channels OR channels with no available providers.
924
- const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByProvider).length;
1026
+ // Count channels hidden from the default view: disabled predefined channels OR channels with no available services.
1027
+ const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByService).length;
925
1028
  const lines = [];
926
1029
  // Panel description.
927
1030
  lines.push("<div class=\"settings-panel-description\">");
928
1031
  lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
929
- lines.push("<p class=\"description-hint\">Tip: Use the <strong>provider filter</strong> above to show only channels from services you subscribe to &mdash; ", "this also controls which channels Channels DVR sees in the playlist. Use the <strong>provider dropdown</strong> on any multi-provider channel to choose ", "which streaming service delivers it (e.g., Comedy Central via Hulu vs Sling). Click the <strong>edit icon</strong> to customize any channel's name, ", "Gracenote station ID, URL, or other properties.</p>");
1032
+ lines.push("<p class=\"description-hint\">Tip: Use the <strong>service filter</strong> above to show only channels from services you subscribe to &mdash; ", "this also controls which channels Channels DVR sees in the playlist. Use the <strong>service dropdown</strong> on any multi-service channel to choose ", "which streaming service delivers it (e.g., Comedy Central via Hulu vs Sling). Click the <strong>edit icon</strong> to customize any channel's name, ", "Gracenote station ID, URL, or other properties.</p>");
930
1033
  lines.push("</div>");
931
1034
  // Toolbar with three dropdown menus: Manage Channels (primary actions), Import/Export (data I/O), and Quick Actions (bulk operations). Each dropdown has an
932
1035
  // inline SVG icon + label + chevron for visual discoverability. Grouped menus reduce visual clutter and separate channel management from data I/O and bulk
933
1036
  // operations.
934
1037
  lines.push("<div class=\"channel-toolbar\">");
935
1038
  lines.push("<div class=\"toolbar-group\">");
936
- // Manage Channels dropdown — primary channel creation and setup actions.
1039
+ // Manage Channels dropdown - primary channel creation and setup actions.
937
1040
  lines.push("<div class=\"dropdown\">");
938
- lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-dropdown-btn\" title=\"Add, browse, or set up channels\" " +
1041
+ lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-icon-btn\" title=\"Add, browse, or set up channels\" " +
939
1042
  "onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels &#9662;</button>");
940
1043
  lines.push("<div class=\"dropdown-menu\">");
941
1044
  // Dropdown item icons: tinted variants of the shared icons for visual differentiation in menu items. Each uses a distinct color so items are scannable at
@@ -951,47 +1054,47 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
951
1054
  const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
952
1055
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1.5 8.7V2.5a1 1 0 011-1h6.2a1 1 0 01.7.3l5.1 5.1a1 1 0 010 1.4l-5.5 5.5a1 " +
953
1056
  "1 0 01-1.4 0L1.8 9.4a1 1 0 01-.3-.7z\"/><circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>";
954
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); " +
1057
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); " +
955
1058
  "document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
956
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openBrowseModal()\">" + TINTED_BROWSE +
957
- " Browse Provider Channels</div>");
958
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openTagManager()\">" + TINTED_TAG +
1059
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openBrowseModal()\">" + TINTED_BROWSE +
1060
+ " Browse Service Channels</div>");
1061
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openTagManager()\">" + TINTED_TAG +
959
1062
  " Manage Tags</div>");
960
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openSetupWizard()\">" + TINTED_SETUP +
961
- " Provider Setup</div>");
1063
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openSetupWizard()\">" + TINTED_SETUP +
1064
+ " Service Setup</div>");
962
1065
  lines.push("</div>");
963
1066
  lines.push("</div>");
964
- // Import / Export dropdown — data I/O operations.
1067
+ // Import / Export dropdown - data I/O operations.
965
1068
  lines.push("<div class=\"dropdown\">");
966
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-dropdown-btn\" title=\"Import or export channel data\" " +
1069
+ lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Import or export channel data\" " +
967
1070
  "onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export &#9662;</button>");
968
1071
  lines.push("<div class=\"dropdown-menu\">");
969
1072
  const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
970
1073
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 2v8M5 7l3 3 3-3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
971
1074
  const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
972
1075
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 10V2M5 5l3-3 3 3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
973
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); document.getElementById('import-channels-file').click()\">" +
1076
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-channels-file').click()\">" +
974
1077
  TINTED_IMPORT + " Import Channels (JSON)</div>");
975
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); document.getElementById('import-m3u-file').click()\">" +
1078
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-m3u-file').click()\">" +
976
1079
  TINTED_IMPORT + " Import M3U Playlist</div>");
977
1080
  lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
978
1081
  lines.push("<div class=\"dropdown-divider\"></div>");
979
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); exportChannels()\">" +
1082
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); exportChannels()\">" +
980
1083
  TINTED_EXPORT + " Export Channels (JSON)</div>");
981
1084
  lines.push("</div>");
982
1085
  lines.push("</div>");
983
1086
  lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
984
1087
  // Visible column set, used by Quick Actions to gate column-dependent options and by the table header to set initial hide classes.
985
1088
  const visibleCols = new Set(CONFIG.channels.visibleColumns);
986
- // Quick Actions dropdown — bulk operations for predefined channels.
1089
+ // Quick Actions dropdown - bulk operations for predefined channels.
987
1090
  lines.push("<div class=\"dropdown quick-actions-dropdown\">");
988
- lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-dropdown-btn\" title=\"Bulk operations for predefined channels\" " +
1091
+ lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Bulk operations for predefined channels\" " +
989
1092
  "onclick=\"toggleDropdown(this)\">" + ICON_BOLT + " Quick Actions &#9662;</button>");
990
1093
  lines.push("<div class=\"dropdown-menu\">");
991
- // Compute initial toggle counts for predefined channel scopes. The server is the single source of truth — the client renders what we return here.
1094
+ // Compute initial toggle counts for predefined channel scopes. The server is the single source of truth - the client renders what we return here.
992
1095
  const scopeCounts = getPredefinedScopeCounts();
993
1096
  // Three toggle rows: checkbox + label + count. Clicking toggles the group via bulkTogglePredefined(). The onclick uses event.preventDefault() to stop the
994
- // native checkbox toggle — the server response drives the update.
1097
+ // native checkbox toggle - the server response drives the update.
995
1098
  const scopes = [
996
1099
  { count: scopeCounts.all.enabled, label: "All Predefined", scope: "all", total: scopeCounts.all.total },
997
1100
  { count: scopeCounts.east.enabled, label: "East Variants", scope: "east", total: scopeCounts.east.total },
@@ -1005,15 +1108,15 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1005
1108
  "\">" + String(s.count) + " of " + String(s.total) + " enabled</span></label>");
1006
1109
  }
1007
1110
  lines.push("<div class=\"dropdown-divider\"></div>");
1008
- // Bulk assign select — switch all multi-provider channels to a single provider.
1009
- const allTags = getAllProviderTags();
1010
- const enabled = getEnabledProviders();
1111
+ // Bulk assign select - switch all multi-service channels to a single service.
1112
+ const allTags = getAllServiceTags();
1113
+ const enabled = getEnabledServices();
1011
1114
  const hasFilter = enabled.length > 0;
1012
1115
  lines.push("<div class=\"bulk-assign-row\">");
1013
1116
  lines.push("<span>Set all channels to:</span>");
1014
- lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-provider channels to this provider\" " +
1015
- "onchange=\"if(this.value) { closeDropdowns(); bulkAssignProvider(this.value); this.value = ''; }\">");
1016
- lines.push("<option value=\"\">Select provider</option>");
1117
+ lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-service channels to this service\" " +
1118
+ "onchange=\"if(this.value) { dropdowns.close(); bulkAssignService(this.value); this.value = ''; }\">");
1119
+ lines.push("<option value=\"\">Select service</option>");
1017
1120
  for (const tagInfo of allTags) {
1018
1121
  const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
1019
1122
  lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\" data-provider-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + hidden + ">" +
@@ -1024,17 +1127,17 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1024
1127
  // Divider before column-gated quick actions. Hidden when neither the channel number nor HDHR column is visible.
1025
1128
  const dividerVisible = (visibleCols.has("channelNumber") || visibleCols.has("hdhrEnabled")) ? "" : " style=\"display: none;\"";
1026
1129
  lines.push("<div class=\"dropdown-divider\" id=\"quick-action-divider\"" + dividerVisible + "></div>");
1027
- // Auto-number channels — assign sequential channel numbers to visible channels in current sort order. Visible when the channel number column is shown.
1130
+ // Auto-number channels - assign sequential channel numbers to visible channels in current sort order. Visible when the channel number column is shown.
1028
1131
  const autoNumberVisible = visibleCols.has("channelNumber") ? "" : " style=\"display: none;\"";
1029
1132
  lines.push("<div id=\"quick-action-autonumber\"" + autoNumberVisible + ">");
1030
1133
  lines.push("<div class=\"bulk-assign-row\">");
1031
1134
  lines.push("<span>Auto-number from:</span>");
1032
1135
  lines.push("<input type=\"number\" id=\"auto-number-start\" class=\"auto-number-input\" value=\"1\" min=\"0\" max=\"99999\" " +
1033
1136
  "placeholder=\"Clear\" onclick=\"event.stopPropagation()\">");
1034
- lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"closeDropdowns(); autoNumberChannels()\">Apply</button>");
1137
+ lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"dropdowns.close(); autoNumberChannels()\">Apply</button>");
1035
1138
  lines.push("</div>");
1036
1139
  lines.push("</div>");
1037
- // HDHR bulk toggle — tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
1140
+ // HDHR bulk toggle - tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
1038
1141
  const hdhrVisible = visibleCols.has("hdhrEnabled") ? "" : " style=\"display: none;\"";
1039
1142
  const hdhrCounts = getHdhrCounts(listing);
1040
1143
  const hdhrCheckedAttr = (hdhrCounts.enabled === hdhrCounts.total) ? " checked" : "";
@@ -1046,13 +1149,13 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1046
1149
  lines.push("<span class=\"quick-action-count\" id=\"hdhr-bulk-count\">" + String(hdhrCounts.enabled) + " of " + String(hdhrCounts.total) + "</span>");
1047
1150
  lines.push("</label>");
1048
1151
  lines.push("</div>");
1049
- // Bulk tag toggles — tristate checkboxes for each tag in the active vocabulary. Visible when the tags column is shown. Each checkbox shows how many enabled
1152
+ // Bulk tag toggles - tristate checkboxes for each tag in the active vocabulary. Visible when the tags column is shown. Each checkbox shows how many enabled
1050
1153
  // channels have the tag, and clicking it adds or removes the tag on all visible channels.
1051
1154
  const tagsVisible = visibleCols.has("tags") ? "" : " style=\"display: none;\"";
1052
1155
  const tagsVocabulary = getActiveTagVocabulary();
1053
1156
  if (tagsVocabulary.length > 0) {
1054
- // Count how many enabled, provider-available channels have each tag.
1055
- const enabledListing = listing.filter((entry) => entry.enabled && entry.availableByProvider);
1157
+ // Count how many enabled, service-available channels have each tag.
1158
+ const enabledListing = listing.filter(isVisibleChannel);
1056
1159
  const totalEnabled = enabledListing.length;
1057
1160
  const tagCountMap = new Map();
1058
1161
  for (const tag of tagsVocabulary) {
@@ -1131,12 +1234,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1131
1234
  lines.push("<h3>Add New Channel</h3>");
1132
1235
  lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
1133
1236
  lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
1134
- // Provider pills. Clicking a provider auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
1135
- // resolution). The pills use providerDisplaySpan for icon rendering via the shared processProviderDisplays path. Guide-grid providers fill with their
1136
- // guideUrl; non-guide-grid providers fill with https://{domain}/.
1137
- const addFormTags = getAllProviderTags();
1237
+ // Service pills. Clicking a service auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
1238
+ // resolution). The pills use serviceDisplaySpan for icon rendering via the shared processServiceDisplays path. Guide-grid services fill with their
1239
+ // guideUrl; non-guide-grid services fill with https://{domain}/.
1240
+ const addFormTags = getAllServiceTags();
1138
1241
  const addFormGuideUrls = getProviderGuideUrls();
1139
- lines.push("<div class=\"form-row\"><label>Provider</label>");
1242
+ lines.push("<div class=\"form-row\"><label>Service</label>");
1140
1243
  lines.push("<div class=\"provider-pills\">");
1141
1244
  for (const tagInfo of addFormTags) {
1142
1245
  if (tagInfo.tag === "direct") {
@@ -1144,11 +1247,11 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1144
1247
  }
1145
1248
  const pillUrl = addFormGuideUrls[tagInfo.tag] ?? (tagInfo.domain ? "https://" + tagInfo.domain + "/" : "");
1146
1249
  lines.push("<button type=\"button\" class=\"provider-pill\" data-slug=\"" + escapeHtml(tagInfo.tag) + "\" data-url=\"" + escapeHtml(pillUrl) +
1147
- "\" onclick=\"selectProviderPill(this)\">" + providerDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
1250
+ "\" onclick=\"selectServicePill(this)\">" + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
1148
1251
  }
1149
1252
  lines.push("</div>");
1150
1253
  lines.push("</div>");
1151
- lines.push("<div class=\"hint\">Select a provider to auto-fill the URL, or enter one manually below.</div>");
1254
+ lines.push("<div class=\"hint\">Select a service to auto-fill the URL, or enter one manually below.</div>");
1152
1255
  // Channel key (add form only).
1153
1256
  lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
1154
1257
  hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
@@ -1169,9 +1272,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1169
1272
  required: true,
1170
1273
  type: "url"
1171
1274
  }));
1275
+ // Inline hint for predefined channel matches. Hidden by default, shown by the URL field change listener in config.ts when the entered URL's domain matches a
1276
+ // predefined channel. This extends the form's existing URL-based intelligence (channelSelector suggestions, stationId auto-fill, profile auto-detection).
1277
+ lines.push("<div id=\"add-predefined-hint\" class=\"hint predefined-hint\" style=\"display: none;\"></div>");
1172
1278
  // Profile dropdown.
1173
1279
  lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
1174
- // Advanced fields (station ID, channel selector, channel number, HDHR).
1280
+ // Advanced fields (station ID, guide title, logo URL, channel selector, channel number, tags, HDHR).
1175
1281
  lines.push(...generateAdvancedFields("add", {
1176
1282
  channelNumberValue: formValues?.get("channelNumber") ?? "",
1177
1283
  channelSelectorValue: formValues?.get("channelSelector") ?? "",
@@ -1184,7 +1290,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1184
1290
  lines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"document.getElementById('add-channel-form').style.display='none';\">Cancel</button>");
1185
1291
  lines.push("</div>");
1186
1292
  lines.push("</form>");
1187
- lines.push("</div>"); // End add-channel-form.
1293
+ // Close the add-channel-form wrapper opened at the top of this builder.
1294
+ lines.push("</div>");
1188
1295
  // Profile reference section (hidden by default, toggled via link in profile dropdown hint).
1189
1296
  lines.push(generateProfileReference(profiles));
1190
1297
  // Channels table. Disabled predefined channels are hidden by default and revealed via the "Show disabled" toggle. The wrapper div enables horizontal scrolling on
@@ -1197,8 +1304,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1197
1304
  }
1198
1305
  const sortField = CONFIG.channels.channelSortField;
1199
1306
  const sortDir = CONFIG.channels.channelSortDirection;
1200
- // Provider filter toolbar with multi-select dropdown and chips.
1201
- lines.push(generateProviderFilterToolbar());
1307
+ // Service filter toolbar with multi-select dropdown and chips.
1308
+ lines.push(generateServiceFilterToolbar());
1202
1309
  // Channel summary line with predefined/user breakdown, placed directly above the table for visual proximity. The user-count span contains the entire user
1203
1310
  // portion (comma, count, and label) so the client can toggle it by setting textContent. When there are no user channels, the span is empty.
1204
1311
  const userPortion = (userCount > 0) ? ", " + String(userCount) + " user" : "";
@@ -1216,20 +1323,20 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1216
1323
  const sortableHeaders = [
1217
1324
  { cssClass: "col-key", field: "key", label: "Key" },
1218
1325
  { cssClass: "col-name", field: "name", label: "Name" },
1219
- { cssClass: "col-provider", field: "provider", label: "Provider" },
1326
+ { cssClass: "col-provider", field: "provider", label: "Service" },
1220
1327
  ...OPTIONAL_COLUMNS
1221
1328
  ];
1222
1329
  // All sortable headers use the same DOM structure: <th onclick> wraps a <span class="sort-label"> for the label text. The sort update logic targets
1223
- // .sort-label to modify only the label — never touching other children like the Tags filter dropdown. Clicking anywhere on the <th> triggers sort;
1330
+ // .sort-label to modify only the label - never touching other children like the Tags filter dropdown. Clicking anywhere on the <th> triggers sort;
1224
1331
  // additional children (like the filter button) use event.stopPropagation() to prevent sort when interacting with them.
1225
1332
  for (const hdr of sortableHeaders) {
1226
1333
  const isActive = (sortField === hdr.field);
1227
1334
  const activeIndicator = isActive ? ((sortDir === "asc") ? " &#9650;" : " &#9660;") : "";
1228
1335
  lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
1229
- "\" onclick=\"sortChannelTable('" + hdr.field + "')\">");
1336
+ "\" onclick=\"channelTable.sort('" + hdr.field + "')\">");
1230
1337
  lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
1231
1338
  // Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
1232
- // shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() — the single source of truth
1339
+ // shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() - the single source of truth
1233
1340
  // shared with the tag CRUD incremental update path.
1234
1341
  if (hdr.field === "tags") {
1235
1342
  lines.push("<div class=\"dropdown tag-filter-dropdown\" style=\"display: inline;\">");
@@ -1269,7 +1376,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1269
1376
  lines.push("</thead>");
1270
1377
  lines.push("<tbody>");
1271
1378
  // Sort the listing by the user's preferred field and direction before rendering rows. The canonical getChannelListing() order is preserved for other callers.
1272
- const sortedListing = [...listing].sort((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
1379
+ const sortedListing = listing.toSorted((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
1273
1380
  // Generate rows for all channels using the shared row generator.
1274
1381
  for (const entry of sortedListing) {
1275
1382
  const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
@@ -1282,16 +1389,16 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1282
1389
  // Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
1283
1390
  // multi-channel site like Disney+ or USA Network.
1284
1391
  lines.push("<script>" + generateChannelSelectorData() + "</script>");
1285
- // Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (provider picker, channel list) is rendered
1286
- // client-side after fetching discovered channels from the provider endpoint.
1392
+ // Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (service picker, channel list) is rendered
1393
+ // client-side after fetching discovered channels from the services endpoint.
1287
1394
  lines.push(generateBrowseModal());
1288
- // Provider Setup wizard modal. Follows the same wizard pattern as the provider profile builder. Three steps: pick providers, authenticate, browse channels.
1395
+ // Service Setup wizard modal. Follows the same wizard pattern as the service profile builder. Three steps: pick services, authenticate, browse channels.
1289
1396
  // The setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
1290
1397
  lines.push(generateSetupWizardModal());
1291
1398
  // Tag Management modal. A simple dialog for creating, deleting, and restoring organizational tags. Client-side handlers drive the CRUD operations via the
1292
1399
  // tag management API endpoints.
1293
1400
  lines.push(generateTagManagementModal());
1294
- // Inline tag edit portal — a shared dropdown portaled to <body> on first use by getTagPortal() in config.ts. One instance shared across all channel rows;
1401
+ // Inline tag edit portal - a shared dropdown portaled to <body> on first use by getTagPortal() in config.ts. One instance shared across all channel rows;
1295
1402
  // populated dynamically from the clicked cell's data-tags attribute. Positioned via getBoundingClientRect() to escape the table wrapper's overflow:auto clipping.
1296
1403
  const vocabulary = getActiveTagVocabulary();
1297
1404
  lines.push("<div id=\"inline-tag-portal\" class=\"dropdown-menu inline-tag-menu\">");