prismcast 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +26 -1
  2. package/dist/app.js +29 -2
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.js +1 -1
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +31 -1
  7. package/dist/browser/channelSelection.js +94 -977
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/index.d.ts +18 -4
  10. package/dist/browser/index.js +202 -36
  11. package/dist/browser/index.js.map +1 -1
  12. package/dist/browser/tuning/fox.d.ts +19 -0
  13. package/dist/browser/tuning/fox.js +82 -0
  14. package/dist/browser/tuning/fox.js.map +1 -0
  15. package/dist/browser/tuning/hbo.d.ts +22 -0
  16. package/dist/browser/tuning/hbo.js +207 -0
  17. package/dist/browser/tuning/hbo.js.map +1 -0
  18. package/dist/browser/tuning/hulu.d.ts +30 -0
  19. package/dist/browser/tuning/hulu.js +526 -0
  20. package/dist/browser/tuning/hulu.js.map +1 -0
  21. package/dist/browser/tuning/sling.d.ts +22 -0
  22. package/dist/browser/tuning/sling.js +298 -0
  23. package/dist/browser/tuning/sling.js.map +1 -0
  24. package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
  25. package/dist/browser/tuning/thumbnailRow.js +107 -0
  26. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  27. package/dist/browser/tuning/tileClick.d.ts +18 -0
  28. package/dist/browser/tuning/tileClick.js +102 -0
  29. package/dist/browser/tuning/tileClick.js.map +1 -0
  30. package/dist/browser/tuning/youtubeTv.d.ts +17 -0
  31. package/dist/browser/tuning/youtubeTv.js +142 -0
  32. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  33. package/dist/browser/video.d.ts +2 -2
  34. package/dist/browser/video.js +19 -9
  35. package/dist/browser/video.js.map +1 -1
  36. package/dist/channels/index.js +55 -9
  37. package/dist/channels/index.js.map +1 -1
  38. package/dist/config/profiles.js +11 -0
  39. package/dist/config/profiles.js.map +1 -1
  40. package/dist/config/providers.d.ts +46 -1
  41. package/dist/config/providers.js +178 -13
  42. package/dist/config/providers.js.map +1 -1
  43. package/dist/config/sites.d.ts +2 -0
  44. package/dist/config/sites.js +43 -14
  45. package/dist/config/sites.js.map +1 -1
  46. package/dist/config/userChannels.js +21 -3
  47. package/dist/config/userChannels.js.map +1 -1
  48. package/dist/config/userConfig.d.ts +2 -1
  49. package/dist/config/userConfig.js +12 -4
  50. package/dist/config/userConfig.js.map +1 -1
  51. package/dist/index.js +10 -4
  52. package/dist/index.js.map +1 -1
  53. package/dist/routes/auth.js +6 -0
  54. package/dist/routes/auth.js.map +1 -1
  55. package/dist/routes/config.d.ts +5 -0
  56. package/dist/routes/config.js +277 -35
  57. package/dist/routes/config.js.map +1 -1
  58. package/dist/routes/debug.d.ts +6 -0
  59. package/dist/routes/debug.js +274 -0
  60. package/dist/routes/debug.js.map +1 -0
  61. package/dist/routes/index.d.ts +1 -0
  62. package/dist/routes/index.js +3 -0
  63. package/dist/routes/index.js.map +1 -1
  64. package/dist/routes/logs.js +17 -5
  65. package/dist/routes/logs.js.map +1 -1
  66. package/dist/routes/root.js +506 -127
  67. package/dist/routes/root.js.map +1 -1
  68. package/dist/routes/theme.js +7 -0
  69. package/dist/routes/theme.js.map +1 -1
  70. package/dist/service/commands.js +8 -8
  71. package/dist/service/commands.js.map +1 -1
  72. package/dist/service/generators.js +22 -16
  73. package/dist/service/generators.js.map +1 -1
  74. package/dist/streaming/fmp4Segmenter.d.ts +7 -0
  75. package/dist/streaming/fmp4Segmenter.js +124 -15
  76. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  77. package/dist/streaming/hls.d.ts +74 -6
  78. package/dist/streaming/hls.js +210 -129
  79. package/dist/streaming/hls.js.map +1 -1
  80. package/dist/streaming/hlsSegments.js +11 -4
  81. package/dist/streaming/hlsSegments.js.map +1 -1
  82. package/dist/streaming/lifecycle.js +1 -1
  83. package/dist/streaming/lifecycle.js.map +1 -1
  84. package/dist/streaming/monitor.d.ts +1 -1
  85. package/dist/streaming/monitor.js +120 -49
  86. package/dist/streaming/monitor.js.map +1 -1
  87. package/dist/streaming/mp4Parser.d.ts +29 -1
  88. package/dist/streaming/mp4Parser.js +243 -18
  89. package/dist/streaming/mp4Parser.js.map +1 -1
  90. package/dist/streaming/mpegts.d.ts +5 -2
  91. package/dist/streaming/mpegts.js +122 -29
  92. package/dist/streaming/mpegts.js.map +1 -1
  93. package/dist/streaming/registry.d.ts +1 -1
  94. package/dist/streaming/registry.js.map +1 -1
  95. package/dist/streaming/setup.d.ts +13 -1
  96. package/dist/streaming/setup.js +136 -36
  97. package/dist/streaming/setup.js.map +1 -1
  98. package/dist/streaming/showInfo.js +9 -9
  99. package/dist/streaming/showInfo.js.map +1 -1
  100. package/dist/streaming/statusEmitter.js +5 -6
  101. package/dist/streaming/statusEmitter.js.map +1 -1
  102. package/dist/types/index.d.ts +12 -3
  103. package/dist/types/index.js +5 -1
  104. package/dist/types/index.js.map +1 -1
  105. package/dist/utils/debugFilter.d.ts +38 -0
  106. package/dist/utils/debugFilter.js +157 -0
  107. package/dist/utils/debugFilter.js.map +1 -0
  108. package/dist/utils/evaluate.js.map +1 -1
  109. package/dist/utils/ffmpeg.d.ts +5 -0
  110. package/dist/utils/ffmpeg.js +13 -2
  111. package/dist/utils/ffmpeg.js.map +1 -1
  112. package/dist/utils/fileLogger.d.ts +2 -1
  113. package/dist/utils/fileLogger.js +4 -2
  114. package/dist/utils/fileLogger.js.map +1 -1
  115. package/dist/utils/index.d.ts +1 -0
  116. package/dist/utils/index.js +1 -0
  117. package/dist/utils/index.js.map +1 -1
  118. package/dist/utils/logEmitter.d.ts +1 -0
  119. package/dist/utils/logEmitter.js.map +1 -1
  120. package/dist/utils/logger.d.ts +9 -8
  121. package/dist/utils/logger.js +52 -28
  122. package/dist/utils/logger.js.map +1 -1
  123. package/dist/utils/retry.d.ts +1 -1
  124. package/dist/utils/retry.js +3 -3
  125. package/dist/utils/retry.js.map +1 -1
  126. package/dist/utils/version.js +4 -9
  127. package/dist/utils/version.js.map +1 -1
  128. package/package.json +6 -6
@@ -1,974 +1,113 @@
1
- import { LOG, delay, evaluateWithAbort, formatError } from "../utils/index.js";
2
- import { CONFIG } from "../config/index.js";
3
- /* Multi-channel streaming sites (like USA Network) present multiple channels on a single page, with a program guide for each channel. Users must select which
4
- * channel they want to watch by clicking on a show in the guide. This module provides a strategy-based system for automating that channel selection.
5
- *
6
- * The strategy pattern allows different sites to have different selection mechanisms:
7
- * - guideGrid: Scroll a virtualized channel grid to the target channel via binary search on document.documentElement.scrollTop, then click the on-now program
8
- * cell and play button (Hulu Live). Supports position-based inference for local affiliate call signs and a linear scan fallback.
9
- * - hboGrid: Discover the HBO tab page URL from the homepage menu bar, scrape the live channel tile rail for a matching channel name, and navigate to the
10
- * extracted watch URL. Caches the tab URL across tunes with stale-cache fallback rediscovery (HBO Max).
11
- * - thumbnailRow: Find channel by matching image URL slug, click adjacent show entry on the same row (USA Network)
12
- * - tileClick: Find channel tile by matching image URL slug, click tile, then click play button on modal (Disney+ live)
13
- * - youtubeGrid: Find channel by aria-label in a non-virtualized EPG grid, extract the watch URL, and navigate directly (YouTube TV)
1
+ /* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
14
2
  *
15
- * Each strategy is a self-contained function that takes the page and channel identifier, and returns a success/failure result. The main selectChannel() function
16
- * delegates to the appropriate strategy based on the profile configuration.
17
- */
18
- // Base URLs for provider strategies that navigate to watch pages. Centralizing these avoids scattering the same origin string across scraper functions, navigation
19
- // calls, and URL construction sites within each strategy.
20
- const HBO_MAX_BASE_URL = "https://play.hbomax.com";
21
- const YOUTUBE_TV_BASE_URL = "https://tv.youtube.com";
22
- // Guide grid row number cache. Maps lowercased, trimmed channel names from data-testid attributes to their row numbers (from sr-only text). Populated passively
23
- // during binary search iterations and used for direct-scroll optimization on subsequent tunes. Session-scoped — cleared when the browser restarts.
24
- const guideRowCache = new Map();
3
+ * channelSelection.ts: Channel selection coordinator for multi-channel streaming sites.
4
+ */
5
+ import { isChannelSelectionProfile } from "../types/index.js";
6
+ import { LOG, delay } from "../utils/index.js";
7
+ import { clearHboCache, hboGridStrategy } from "./tuning/hbo.js";
8
+ import { clearHuluCache, guideGridStrategy } from "./tuning/hulu.js";
9
+ import { clearSlingCache, slingGridStrategy } from "./tuning/sling.js";
10
+ import { CHANNELS } from "../channels/index.js";
11
+ import { CONFIG } from "../config/index.js";
12
+ import { foxGridStrategy } from "./tuning/fox.js";
13
+ import { thumbnailRowStrategy } from "./tuning/thumbnailRow.js";
14
+ import { tileClickStrategy } from "./tuning/tileClick.js";
15
+ import { youtubeGridStrategy } from "./tuning/youtubeTv.js";
16
+ // Strategy dispatch registry. Maps strategy names from ChannelSelectionStrategy to their implementation functions.
17
+ const strategies = {
18
+ foxGrid: foxGridStrategy,
19
+ guideGrid: guideGridStrategy,
20
+ hboGrid: hboGridStrategy,
21
+ slingGrid: slingGridStrategy,
22
+ thumbnailRow: thumbnailRowStrategy,
23
+ tileClick: tileClickStrategy,
24
+ youtubeGrid: youtubeGridStrategy
25
+ };
25
26
  /**
26
27
  * Clears all channel selection caches. Called by handleBrowserDisconnect() in browser/index.ts when the browser restarts, since cached state (guide row positions,
27
28
  * discovered page URLs) may be stale in a new browser session.
28
29
  */
29
30
  export function clearChannelSelectionCaches() {
30
- guideRowCache.clear();
31
- hboTabUrl = null;
31
+ clearHboCache();
32
+ clearHuluCache();
33
+ clearSlingCache();
32
34
  }
33
- /* These utilities are shared across channel selection strategies. They handle common operations like finding elements, scrolling, and clicking.
34
- */
35
35
  /**
36
36
  * Clicks at the specified coordinates after a brief settle delay. The delay allows scroll animations and lazy-loaded content to finish before the click fires.
37
37
  * Callers are responsible for scrolling the target element into view (typically via scrollIntoView inside a page.evaluate call) before invoking this function.
38
+ * Exported for use by tuning strategy files (thumbnailRow, tileClick, hulu).
38
39
  * @param page - The Puppeteer page object.
39
40
  * @param target - The x/y coordinates to click.
40
41
  * @returns True if the click was executed.
41
42
  */
42
- async function scrollAndClick(page, target) {
43
+ export async function scrollAndClick(page, target) {
43
44
  // Brief delay after scrolling for any animations or lazy-loaded content to settle.
44
45
  await delay(200);
45
46
  // Click the target coordinates to switch to the channel.
46
47
  await page.mouse.click(target.x, target.y);
47
48
  return true;
48
49
  }
49
- /* Each strategy implements a different approach to finding and selecting channels. Strategies are self-contained functions that can be tested independently.
50
- */
51
- /**
52
- * Thumbnail row strategy: finds a channel by matching the slug in thumbnail image URLs, then clicks an adjacent clickable element on the same row. This strategy
53
- * works for sites like USA Network where channels are displayed as rows with a thumbnail on the left and program entries to the right.
54
- *
55
- * The selection process:
56
- * 1. Search all images on the page for one whose src URL contains the channel slug
57
- * 2. Verify the image has dimensions (is rendered and visible)
58
- * 3. Walk up the DOM to find a container wide enough to hold both thumbnail and guide entries
59
- * 4. Search for clickable elements (links, buttons, cards) to the right of the thumbnail on the same row
60
- * 5. Fall back to divs with cursor:pointer if no semantic clickables found
61
- * 6. Click the found element to switch to the channel
62
- * @param page - The Puppeteer page object.
63
- * @param channelSlug - The literal string to match in thumbnail image URLs.
64
- * @returns Result object with success status and optional failure reason.
65
- */
66
- async function thumbnailRowStrategy(page, channelSlug) {
67
- // Find clickable element by evaluating DOM. The logic walks through the page looking for channel thumbnail images, then finds clickable show entries on the
68
- // same row.
69
- const clickTarget = await evaluateWithAbort(page, (slug) => {
70
- const images = document.querySelectorAll("img");
71
- for (const img of Array.from(images)) {
72
- // Channel thumbnails have URLs containing the channel slug pattern. Match against the src URL.
73
- if (img.src && img.src.includes(slug)) {
74
- const imgRect = img.getBoundingClientRect();
75
- // Verify the image has dimensions (is actually rendered and visible).
76
- if ((imgRect.width > 0) && (imgRect.height > 0)) {
77
- // Found the channel thumbnail. Now walk up the DOM tree to find a container that holds both the thumbnail and the guide entries for this row.
78
- let rowContainer = img.parentElement;
79
- while (rowContainer && (rowContainer !== document.body)) {
80
- const containerRect = rowContainer.getBoundingClientRect();
81
- // Look for a container significantly wider than the thumbnail (indicating it contains more than just the image). The factor of 2 is a heuristic
82
- // that works for typical channel guide layouts.
83
- if (containerRect.width > (imgRect.width * 2)) {
84
- // This container is wide enough to contain guide entries. Search for clickable elements (show cards) to the right of the thumbnail.
85
- const clickables = rowContainer.querySelectorAll("a, button, [role=\"button\"], [onclick], [class*=\"card\"], [class*=\"program\"], [class*=\"show\"], [class*=\"episode\"]");
86
- const imgCenterY = imgRect.y + (imgRect.height / 2);
87
- for (const clickable of Array.from(clickables)) {
88
- const clickRect = clickable.getBoundingClientRect();
89
- const clickCenterY = clickRect.y + (clickRect.height / 2);
90
- // The guide entry must meet these criteria:
91
- // - To the right of the thumbnail (with small tolerance for overlapping borders)
92
- // - Has dimensions (is visible)
93
- // - On the same row (vertical center within thumbnail height)
94
- const isRightOfThumbnail = clickRect.x > (imgRect.x + imgRect.width - 10);
95
- const hasDimensions = (clickRect.width > 0) && (clickRect.height > 0);
96
- const isSameRow = Math.abs(clickCenterY - imgCenterY) < imgRect.height;
97
- if (isRightOfThumbnail && hasDimensions && isSameRow) {
98
- // Found a suitable click target. Scroll it into view and return its center coordinates.
99
- clickable.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
100
- const newRect = clickable.getBoundingClientRect();
101
- return { x: newRect.x + (newRect.width / 2), y: newRect.y + (newRect.height / 2) };
102
- }
103
- }
104
- // Fallback: if no semantically clickable elements found, look for divs with cursor: pointer styling. These are often custom-styled click
105
- // handlers.
106
- const allDivs = rowContainer.querySelectorAll("div");
107
- for (const div of Array.from(allDivs)) {
108
- const divRect = div.getBoundingClientRect();
109
- const divCenterY = divRect.y + (divRect.height / 2);
110
- const style = window.getComputedStyle(div);
111
- const isRightOfThumbnail = divRect.x > (imgRect.x + imgRect.width - 10);
112
- const hasDimensions = (divRect.width > 20) && (divRect.height > 20);
113
- const isClickable = style.cursor === "pointer";
114
- const isSameRow = Math.abs(divCenterY - imgCenterY) < imgRect.height;
115
- if (isRightOfThumbnail && hasDimensions && isClickable && isSameRow) {
116
- div.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
117
- const newRect = div.getBoundingClientRect();
118
- return { x: newRect.x + (newRect.width / 2), y: newRect.y + (newRect.height / 2) };
119
- }
120
- }
121
- }
122
- rowContainer = rowContainer.parentElement;
123
- }
124
- // Ultimate fallback: click a fixed offset to the right of the thumbnail. This is a last resort if the guide structure doesn't match our
125
- // expectations.
126
- img.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
127
- const newImgRect = img.getBoundingClientRect();
128
- return { x: newImgRect.x + newImgRect.width + 50, y: newImgRect.y + (newImgRect.height / 2) };
129
- }
130
- }
131
- }
132
- // Channel thumbnail not found in any images.
133
- return null;
134
- }, [channelSlug]);
135
- if (clickTarget) {
136
- await scrollAndClick(page, clickTarget);
137
- // Poll for the video readyState to drop below 3, indicating the channel switch has started loading new content. This replaces a fixed post-click delay with
138
- // early exit. If no video exists yet or readyState never drops (channel already selected), the timeout expires harmlessly and waitForVideoReady() handles the
139
- // rest.
140
- try {
141
- await page.waitForFunction(() => {
142
- const v = document.querySelector("video");
143
- return !v || (v.readyState < 3);
144
- }, { timeout: CONFIG.playback.channelSwitchDelay });
145
- }
146
- catch {
147
- // Timeout — readyState never dropped. Proceed normally.
148
- }
149
- return { success: true };
150
- }
151
- return { reason: "Channel thumbnail not found in page images.", success: false };
152
- }
153
- /**
154
- * Tile click strategy: finds a channel by matching the slug in tile image URLs, clicks the tile to open an entity modal, then clicks a "watch live" play button on
155
- * the modal. This strategy works for sites like Disney+ where live channels are displayed as tiles in a horizontal shelf, and selecting one opens a modal with a
156
- * play button to start the live stream.
157
- *
158
- * The selection process:
159
- * 1. Search all images on the page for one whose src URL contains the channel slug
160
- * 2. Walk up the DOM to find the nearest clickable ancestor (the tile container)
161
- * 3. Scroll the tile into view and click it
162
- * 4. Wait for the play button to appear on the resulting modal
163
- * 5. Click the play button to start live playback
164
- * @param page - The Puppeteer page object.
165
- * @param channelSlug - The literal string to match in tile image URLs.
166
- * @returns Result object with success status and optional failure reason.
167
- */
168
- async function tileClickStrategy(page, channelSlug) {
169
- // Step 1: Find the channel tile by matching the slug in a descendant image's src URL. Live channels are displayed as tiles in a horizontal shelf, each containing
170
- // an image with the network name in the URL label parameter (e.g., "poster_linear_espn_none"). We match the image, then walk up the DOM to find the nearest
171
- // clickable ancestor that represents the entire tile.
172
- const tileTarget = await evaluateWithAbort(page, (slug) => {
173
- const images = document.querySelectorAll("img");
174
- for (const img of Array.from(images)) {
175
- if (img.src && img.src.includes(slug)) {
176
- const imgRect = img.getBoundingClientRect();
177
- // Verify the image has dimensions (is actually rendered and visible). This matches the pattern in thumbnailRowStrategy and provides defense-in-depth if the
178
- // wait phase timed out before the image fully loaded.
179
- if ((imgRect.width > 0) && (imgRect.height > 0)) {
180
- // Walk up the DOM to find the nearest clickable ancestor wrapping the tile. Check for semantic clickable elements (<a>, <button>, role="button") and
181
- // elements with explicit click handlers first. Track cursor:pointer elements as a fallback for sites using custom click handlers without semantic markup.
182
- let ancestor = img.parentElement;
183
- let pointerFallback = null;
184
- while (ancestor && (ancestor !== document.body)) {
185
- const tag = ancestor.tagName;
186
- // Semantic clickable elements are the most reliable indicators of an interactive tile container.
187
- if ((tag === "A") || (tag === "BUTTON") || (ancestor.getAttribute("role") === "button") || ancestor.hasAttribute("onclick")) {
188
- ancestor.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
189
- const rect = ancestor.getBoundingClientRect();
190
- if ((rect.width > 0) && (rect.height > 0)) {
191
- return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
192
- }
193
- }
194
- // Track the nearest cursor:pointer ancestor with reasonable dimensions as a fallback.
195
- if (!pointerFallback) {
196
- const rect = ancestor.getBoundingClientRect();
197
- if ((rect.width > 20) && (rect.height > 20) && (window.getComputedStyle(ancestor).cursor === "pointer")) {
198
- pointerFallback = ancestor;
199
- }
200
- }
201
- ancestor = ancestor.parentElement;
202
- }
203
- // Fallback: use cursor:pointer ancestor if no semantic clickable was found above.
204
- if (pointerFallback) {
205
- pointerFallback.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
206
- const rect = pointerFallback.getBoundingClientRect();
207
- if ((rect.width > 0) && (rect.height > 0)) {
208
- return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
209
- }
210
- }
211
- }
212
- }
213
- }
214
- return null;
215
- }, [channelSlug]);
216
- if (!tileTarget) {
217
- return { reason: "Channel tile not found in page images.", success: false };
218
- }
219
- // Click the channel tile to open the entity modal.
220
- await scrollAndClick(page, tileTarget);
221
- // Step 2: Wait for the "WATCH LIVE" button to appear on the entity modal. The button is an <a> element with a specific data-testid attribute. After clicking the
222
- // tile, the site performs a SPA navigation that renders a modal with playback options.
223
- const playButtonSelector = "[data-testid=\"live-modal-watch-live-action-button\"]";
224
- try {
225
- await page.waitForSelector(playButtonSelector, { timeout: CONFIG.streaming.videoTimeout });
226
- }
227
- catch {
228
- return { reason: "Play button did not appear after clicking channel tile.", success: false };
229
- }
230
- // Get the play button coordinates for clicking.
231
- const playTarget = await evaluateWithAbort(page, (selector) => {
232
- const button = document.querySelector(selector);
233
- if (!button) {
234
- return null;
235
- }
236
- button.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
237
- const rect = button.getBoundingClientRect();
238
- if ((rect.width > 0) && (rect.height > 0)) {
239
- return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
240
- }
241
- return null;
242
- }, [playButtonSelector]);
243
- if (!playTarget) {
244
- return { reason: "Play button found but has no dimensions.", success: false };
245
- }
246
- // Click the play button to start live playback.
247
- await scrollAndClick(page, playTarget);
248
- return { success: true };
249
- }
250
- /**
251
- * YouTube TV grid strategy: finds a channel in the non-virtualized EPG grid at tv.youtube.com/live by querying the aria-label attribute on thumbnail endpoint
252
- * elements. All ~256 channel rows are present in the DOM simultaneously, so a single querySelector locates the target channel. The strategy extracts the watch
253
- * URL from the matching anchor element and navigates directly — no scrolling, clicking, or timing workarounds needed.
254
- *
255
- * The selection process:
256
- * 1. Wait for ytu-epg-row elements to confirm the guide grid has loaded.
257
- * 2. Find the target channel using a case-insensitive aria-label CSS selector.
258
- * 3. Extract the href attribute and validate it starts with "watch/" (not "live" or "browse/").
259
- * 4. Navigate to the full watch URL via page.goto().
260
- * @param page - The Puppeteer page object.
261
- * @param channelName - The channel name or network name to match against aria-label attributes (e.g., "CNN", "ESPN", "NBC" for local affiliates).
262
- * @returns Result object with success status and optional failure reason.
263
- */
264
- async function youtubeGridStrategy(page, channelName) {
265
- // Wait for the EPG grid to render. All ~256 rows load simultaneously (no virtualization), so once any row exists, all channels are queryable.
266
- try {
267
- await page.waitForSelector("ytu-epg-row", { timeout: CONFIG.streaming.videoTimeout });
268
- }
269
- catch {
270
- return { reason: "YouTube TV guide grid did not load.", success: false };
271
- }
272
- // Known alternate channel names for affiliates that vary by market. CW appears as "WGN" in some markets. PBS affiliates appear under local call letters (e.g.,
273
- // WTTW, KQED) rather than "PBS", so we list the major market call letters to cover most users. Each alternate is tried after the primary name fails both exact and
274
- // prefix+digit matching. Users in smaller markets override via custom channel entries with their local call letters as the channelSelector.
275
- const CHANNEL_ALTERNATES = {
276
- "cw": ["WGN"],
277
- "pbs": [
278
- "GBH", "KAET", "KBTC", "KCET", "KCTS", "KERA", "KLCS", "KOCE", "KPBS", "KQED", "KRMA", "KUHT", "KVIE", "MPT", "NJ PBS", "THIRTEEN", "TPT", "WETA", "WGBH", "WHYY",
279
- "WLIW", "WNED", "WNET", "WNIT", "WPBA", "WPBT", "WTTW", "WTVS", "WXEL"
280
- ]
281
- };
282
- // Build the list of names to try: the primary name first, then any known alternates for markets where the affiliate uses a different name. The eslint disable is
283
- // needed because TypeScript's Record indexing doesn't capture that the key may not exist at runtime.
284
- const alternates = CHANNEL_ALTERNATES[channelName.toLowerCase()];
285
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
286
- const namesToTry = alternates ? [channelName, ...alternates] : [channelName];
287
- // Find the watch URL for the target channel. For each name in the list, first tries an exact case-insensitive aria-label match. If that fails, falls back to a
288
- // prefix+digit match for local affiliates — YouTube TV displays locals as "{Network} {Number}" (e.g., "NBC 5", "ABC 7", "FOX 32"), so a channelSelector of "NBC"
289
- // can automatically resolve to the user's local affiliate. The prefix fallback requires a space followed by a digit after the network name to avoid false positives
290
- // like "NBC Sports Chicago".
291
- const watchPath = await evaluateWithAbort(page, (names) => {
292
- // Helper to extract and validate a watch URL from an anchor element. Returns the href if it points to a streamable watch page, null otherwise.
293
- const extractWatchHref = (anchor) => {
294
- if (!anchor) {
295
- return null;
296
- }
297
- const href = anchor.getAttribute("href");
298
- // Validate the href points to a streamable watch page. Channels with "live" or "browse/" hrefs are premium add-ons or info pages that cannot be streamed.
299
- if (!href || !href.startsWith("watch/")) {
300
- return null;
301
- }
302
- return href;
303
- };
304
- // Try each name in order. The primary channel name is tried first, followed by any known alternates.
305
- for (const name of names) {
306
- // Try exact match first. The CSS "i" flag enables case-insensitive matching to handle variations in capitalization between the channel selector and the guide.
307
- const exactSelector = "ytu-endpoint.tenx-thumb[aria-label=\"watch " + name + "\" i] a";
308
- const exactResult = extractWatchHref(document.querySelector(exactSelector));
309
- if (exactResult) {
310
- return exactResult;
311
- }
312
- // Fallback: prefix + digit match for local affiliates. Find all thumbnails whose aria-label starts with "watch {Name} " and filter to those where the next
313
- // character is a digit, matching the "{Network} {Number}" pattern (e.g., "NBC 5", "ABC 7") while excluding unrelated channels (e.g., "NBC Sports Chicago").
314
- const prefixSelector = "ytu-endpoint.tenx-thumb[aria-label^=\"watch " + name + " \" i] a";
315
- const candidates = document.querySelectorAll(prefixSelector);
316
- const prefix = "watch " + name + " ";
317
- for (const candidate of Array.from(candidates)) {
318
- const parent = candidate.closest("ytu-endpoint.tenx-thumb");
319
- const label = parent?.getAttribute("aria-label") ?? "";
320
- const suffix = label.slice(prefix.length);
321
- // Accept only if the remainder starts with a digit — this is the local affiliate channel number.
322
- if ((suffix.length > 0) && (suffix.charCodeAt(0) >= 48) && (suffix.charCodeAt(0) <= 57)) {
323
- return extractWatchHref(candidate);
324
- }
325
- }
326
- }
327
- return null;
328
- }, [namesToTry]);
329
- if (!watchPath) {
330
- return { reason: "Channel " + channelName + " not found in YouTube TV guide or is not streamable.", success: false };
331
- }
332
- // Navigate directly to the watch URL. This auto-starts playback without any click interaction needed.
333
- const watchUrl = YOUTUBE_TV_BASE_URL + "/" + watchPath;
334
- LOG.info("Navigating to YouTube TV watch URL for %s.", channelName);
335
- try {
336
- await page.goto(watchUrl, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "load" });
337
- }
338
- catch (error) {
339
- return { reason: "Failed to navigate to YouTube TV watch page: " + formatError(error) + ".", success: false };
340
- }
341
- return { success: true };
342
- }
343
- // Module-level cache for the HBO tab page URL discovered from the homepage menu bar. Cleared on browser disconnect (via clearChannelSelectionCaches) and inline
344
- // when the cached URL turns out to be stale (the channel rail is not found at the cached URL).
345
- let hboTabUrl = null;
346
- /**
347
- * Scrapes the HBO tab URL from the homepage menu bar. The HBO brand page is linked via an `a[aria-label="H B O"]` element in the top navigation. The href attribute
348
- * contains a relative path like `/channel/c0d1f27a-...` which we combine with the base URL to form the full page URL.
349
- * @param page - The Puppeteer page object, expected to be on the HBO Max homepage.
350
- * @returns The full HBO tab page URL, or null if the tab link was not found.
351
- */
352
- async function scrapeHboTabUrl(page) {
353
- // Wait for the HBO tab link to appear in the menu bar. The homepage is a single-page application that renders the navigation dynamically after the initial HTML
354
- // shell loads. Without this wait, the evaluate call below would run against an incomplete DOM and fail to find the tab link.
355
- const HBO_TAB_SELECTOR = "a[aria-label=\"H B O\"]";
356
- try {
357
- await page.waitForSelector(HBO_TAB_SELECTOR, { timeout: 5000 });
358
- }
359
- catch {
360
- return null;
361
- }
362
- const href = await evaluateWithAbort(page, (selector) => {
363
- const tab = document.querySelector(selector);
364
- if (!tab) {
365
- return null;
366
- }
367
- return tab.getAttribute("href");
368
- }, [HBO_TAB_SELECTOR]);
369
- if (!href) {
370
- return null;
371
- }
372
- return HBO_MAX_BASE_URL + href;
373
- }
374
- /**
375
- * Scrapes the HBO Channels tile rail on the HBO tab page for a watch URL matching the given channel name. The rail section contains tiles for each live channel,
376
- * each with a backup text `<p aria-hidden="true">` element containing the channel name and an `<a>` with href pointing to the watch page.
377
- * @param page - The Puppeteer page object, expected to be on the HBO tab page.
378
- * @param channelName - The channel name to match (e.g., "HBO", "HBO Hits"). Case-insensitive.
379
- * @returns Object with `railFound` indicating whether the rail section was present, and `watchPath` containing the relative watch URL if the channel was found.
380
- */
381
- async function scrapeHboChannelRail(page, channelName) {
382
- const HBO_RAIL_SELECTOR = "section[data-testid=\"hbo-page-rail-distribution-channels-us_rail\"]";
383
- // Wait for the distribution channels rail section to appear. If it doesn't appear, the tab URL may be stale or the page structure changed.
384
- try {
385
- await page.waitForSelector(HBO_RAIL_SELECTOR, { timeout: CONFIG.streaming.videoTimeout });
386
- }
387
- catch {
388
- return { railFound: false, watchPath: null };
389
- }
390
- // The rail uses lazy loading via IntersectionObserver — tile content only populates when the rail is visible in the viewport. The rail section element appears
391
- // immediately with skeleton PhantomTile placeholders, but the actual channel tiles (with names and watch URLs) are fetched asynchronously after the rail scrolls
392
- // into view. We scroll the rail into view and then wait for anchor elements to appear, indicating the tiles have loaded.
393
- await page.evaluate((selector) => {
394
- document.querySelector(selector)?.scrollIntoView({ behavior: "instant", block: "center" });
395
- }, HBO_RAIL_SELECTOR);
396
- try {
397
- await page.waitForSelector(HBO_RAIL_SELECTOR + " a", { timeout: 5000 });
398
- }
399
- catch {
400
- return { railFound: false, watchPath: null };
401
- }
402
- // Scrape the rail for the target channel's watch URL. Each tile in the rail contains an anchor with the watch URL and a backup text paragraph with the channel name.
403
- const watchPath = await evaluateWithAbort(page, (selector, target) => {
404
- const rail = document.querySelector(selector);
405
- if (!rail) {
406
- return null;
407
- }
408
- const targetLower = target.toLowerCase();
409
- const anchors = rail.querySelectorAll("a");
410
- for (const anchor of Array.from(anchors)) {
411
- // The channel name appears in a <p aria-hidden="true"> element within the tile. This is the backup text that displays the channel name when the tile
412
- // image fails to load.
413
- const nameEl = anchor.querySelector("p[aria-hidden=\"true\"]");
414
- if (!nameEl) {
415
- continue;
416
- }
417
- const name = nameEl.textContent.trim().toLowerCase();
418
- if (name !== targetLower) {
419
- continue;
420
- }
421
- const href = anchor.getAttribute("href");
422
- // Validate that the href points to a live channel watch page. Watch URLs follow the pattern /channel/watch/{channelUUID}/{programUUID}.
423
- if (href && href.includes("/channel/watch/")) {
424
- return href;
425
- }
426
- }
427
- return null;
428
- }, [HBO_RAIL_SELECTOR, channelName]);
429
- return { railFound: true, watchPath };
430
- }
431
- /**
432
- * HBO grid strategy: discovers the HBO channels tab URL from the homepage menu bar, navigates to the tab page, scrapes the live channel rail for the target channel's
433
- * watch URL, and navigates to it. Caches the tab URL across tunes and falls back to rediscovery if the cached URL is stale (rail not found at the cached location).
434
- *
435
- * The strategy handles three navigations per tune:
436
- * 1. Homepage (already loaded by navigateToPage) → scrape menu bar for tab URL (or use cache)
437
- * 2. Tab page → scrape channel rail for watch URL
438
- * 3. Watch page → video playback begins
439
- *
440
- * When the cached tab URL is stale (rail section not found), the strategy clears the cache, navigates back to the homepage, rediscovers the tab URL, and retries.
441
- * This fallback triggers at most once per tune attempt.
442
- * @param page - The Puppeteer page object, expected to be on the HBO Max homepage.
443
- * @param channelName - The channel name to find in the rail (e.g., "HBO", "HBO Hits").
444
- * @returns Result object with success status and optional failure reason.
445
- */
446
- async function hboGridStrategy(page, channelName) {
447
- let usedCache = false;
448
- // Phase 1: Navigate to the HBO tab page. Use cached URL if available, otherwise discover it from the homepage menu bar.
449
- if (hboTabUrl) {
450
- usedCache = true;
451
- LOG.debug("Using cached HBO tab URL: %s.", hboTabUrl);
452
- }
453
- else {
454
- const discovered = await scrapeHboTabUrl(page);
455
- if (!discovered) {
456
- return { reason: "HBO tab not found in homepage menu bar. HBO Max subscription may not be active.", success: false };
457
- }
458
- hboTabUrl = discovered;
459
- LOG.debug("Discovered HBO tab URL: %s.", hboTabUrl);
460
- }
461
- try {
462
- await page.goto(hboTabUrl, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "load" });
463
- }
464
- catch (error) {
465
- return { reason: "Failed to navigate to HBO tab page: " + formatError(error) + ".", success: false };
466
- }
467
- // Phase 2: Scrape the channel rail for the target channel's watch URL.
468
- let railResult = await scrapeHboChannelRail(page, channelName);
469
- // Fallback: if the rail was not found and we used a cached URL, the cache may be stale. Clear it, navigate back to the homepage, rediscover the tab URL, and retry.
470
- if (!railResult.railFound && usedCache) {
471
- LOG.info("HBO channel rail not found at cached URL. Rediscovering tab URL from homepage.");
472
- hboTabUrl = null;
473
- try {
474
- await page.goto(HBO_MAX_BASE_URL, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "load" });
475
- }
476
- catch (error) {
477
- return { reason: "Failed to navigate back to HBO Max homepage: " + formatError(error) + ".", success: false };
478
- }
479
- const rediscovered = await scrapeHboTabUrl(page);
480
- if (!rediscovered) {
481
- return { reason: "HBO tab not found in homepage menu bar after cache invalidation.", success: false };
482
- }
483
- hboTabUrl = rediscovered;
484
- LOG.debug("Rediscovered HBO tab URL: %s.", hboTabUrl);
485
- try {
486
- await page.goto(hboTabUrl, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "load" });
487
- }
488
- catch (error) {
489
- return { reason: "Failed to navigate to rediscovered HBO tab page: " + formatError(error) + ".", success: false };
490
- }
491
- railResult = await scrapeHboChannelRail(page, channelName);
492
- if (!railResult.railFound) {
493
- return { reason: "HBO channel rail not found at rediscovered URL. Site structure may have changed.", success: false };
494
- }
495
- }
496
- if (!railResult.railFound) {
497
- return { reason: "HBO channel rail not found on tab page.", success: false };
498
- }
499
- if (!railResult.watchPath) {
500
- return { reason: "Channel " + channelName + " not found in HBO channel rail.", success: false };
501
- }
502
- // Phase 3: Navigate to the watch URL to start playback.
503
- const watchUrl = HBO_MAX_BASE_URL + railResult.watchPath;
504
- LOG.info("Navigating to HBO Max watch URL for %s.", channelName);
505
- try {
506
- await page.goto(watchUrl, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "load" });
507
- }
508
- catch (error) {
509
- return { reason: "Failed to navigate to HBO Max watch page: " + formatError(error) + ".", success: false };
510
- }
511
- return { success: true };
512
- }
513
- /**
514
- * Reads all rendered channel containers from the guide grid, extracting their names from data-testid attributes and row numbers from sr-only text. Populates the
515
- * row number cache as a side effect.
516
- * @param page - The Puppeteer page object.
517
- * @returns Array of rendered channels in DOM order, or null if no channels are rendered.
518
- */
519
- async function readRenderedChannels(page) {
520
- const channels = await page.evaluate(() => {
521
- const containers = document.querySelectorAll("[data-testid^=\"live-guide-channel-kyber-\"]");
522
- if (containers.length === 0) {
523
- return null;
524
- }
525
- const prefix = "live-guide-channel-kyber-";
526
- const results = [];
527
- for (const el of Array.from(containers)) {
528
- const testid = el.getAttribute("data-testid") ?? "";
529
- const name = testid.slice(prefix.length).trim().replace(/\s+/g, " ").toLowerCase();
530
- // Extract row number from sr-only text. Format: "{Name} Details, row {N} of {Total}. ..."
531
- let rowNumber = -1;
532
- const btn = el.querySelector("[data-testid=\"live-guide-channel-button\"]");
533
- if (btn) {
534
- const srOnly = btn.querySelector(".sr-only, [class*=\"sr-only\"]");
535
- if (srOnly) {
536
- const match = srOnly.textContent.match(/row (\d+) of/);
537
- if (match) {
538
- // Row numbers in sr-only text are 1-based. Convert to 0-based for scroll offset calculation.
539
- rowNumber = parseInt(match[1], 10) - 1;
540
- }
541
- }
542
- }
543
- results.push({ name, rowNumber });
544
- }
545
- return results;
546
- });
547
- if (!channels) {
548
- return null;
549
- }
550
- // Assign DOM indices and populate the row number cache.
551
- const rendered = [];
552
- for (let i = 0; i < channels.length; i++) {
553
- const ch = channels[i];
554
- rendered.push({ domIndex: i, name: ch.name, rowNumber: ch.rowNumber });
555
- // Cache the row number for future direct-scroll lookups.
556
- if (ch.rowNumber >= 0) {
557
- guideRowCache.set(ch.name, ch.rowNumber);
558
- }
559
- }
560
- return rendered;
561
- }
562
- /**
563
- * Locates the on-now program cell for the channel at the given data-testid name (lowercased, trimmed), scrolls it into view, and returns its center coordinates
564
- * for a subsequent page.mouse.click(). We return coordinates rather than clicking inside the evaluate because page.mouse.click() generates the full pointer event
565
- * chain (pointerdown → mousedown → pointerup → mouseup → click) that React's event delegation requires, whereas a bare DOM .click() dispatches only a synthetic
566
- * click event that may not be processed reliably in a Puppeteer automation context.
567
- * @param page - The Puppeteer page object.
568
- * @param targetName - The lowercased, trimmed channel name to match against data-testid.
569
- * @returns Center coordinates of the on-now cell, or null if not found.
570
- */
571
- async function locateOnNowCell(page, targetName) {
572
- return evaluateWithAbort(page, (target) => {
573
- const prefix = "live-guide-channel-kyber-";
574
- const containers = document.querySelectorAll("[data-testid^=\"" + prefix + "\"]");
575
- for (const el of Array.from(containers)) {
576
- const testid = el.getAttribute("data-testid") ?? "";
577
- const name = testid.slice(prefix.length).trim().replace(/\s+/g, " ").toLowerCase();
578
- if (name === target) {
579
- const row = el.closest("[data-testid=\"live-guide-row\"]");
580
- if (!row) {
581
- return null;
582
- }
583
- const onNow = row.querySelector(".LiveGuideProgram--first");
584
- if (!onNow) {
585
- return null;
586
- }
587
- onNow.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
588
- const rect = onNow.getBoundingClientRect();
589
- if ((rect.width > 0) && (rect.height > 0)) {
590
- return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
591
- }
592
- return null;
593
- }
594
- }
595
- return null;
596
- }, [targetName]);
597
- }
598
- // US broadcast call sign pattern. Local affiliate stations have 3-4 uppercase letter call signs starting with W (east of the Mississippi) or K (west). This
599
- // pattern is used to identify call signs in the guide grid so position-based inference can find local affiliates that sort by their hidden network name.
600
- const CALL_SIGN_PATTERN = /^[WK][A-Z]{2,3}$/i;
601
50
  // Normalizes a channel name for case-insensitive, whitespace-tolerant comparison. Trims leading and trailing whitespace, collapses internal whitespace sequences
602
51
  // (including non-breaking spaces, tabs, and other Unicode whitespace matched by \s) into a single regular space, and lowercases. This handles data-testid values
603
52
  // with trailing spaces (e.g., "WLS "), double spaces, or non-breaking space characters that would otherwise cause exact match failures.
604
- function normalizeChannelName(name) {
53
+ // Exported for use by tuning strategy files (hulu, sling).
54
+ export function normalizeChannelName(name) {
605
55
  return name.trim().replace(/\s+/g, " ").toLowerCase();
606
56
  }
607
57
  /**
608
- * Position-based inference for local affiliates. When binary search returns "missing" (target name sorts between rendered channels but no exact match), this
609
- * function identifies the local affiliate at the target's alphabetical insertion point.
610
- *
611
- * The guide sorts local affiliates by their network name (ABC, CBS, NBC, etc.), but displays call signs (WLS, WBBM, WMAQ) in data-testid. The binary search
612
- * converges to the correct scroll position because the target network name sorts correctly, but the name match fails because the data-testid contains the call
613
- * sign. The affiliate occupies the DOM position where the network name would be if it existed.
614
- *
615
- * Algorithm:
616
- * 1. Filter rendered channels to non-call-sign names (these sort correctly by their displayed name)
617
- * 2. Find where the target would insert alphabetically among the non-call-sign neighbors
618
- * 3. The channel at the DOM position between those two neighbors is the local affiliate
619
- * @param rendered - The rendered channels in DOM order.
620
- * @param targetName - The lowercased target channel name.
621
- * @returns The name of the inferred local affiliate channel, or null if inference fails.
622
- */
623
- function inferLocalAffiliate(rendered, targetName) {
624
- // Build a list of non-call-sign channels with their DOM indices. These channels sort alphabetically by their displayed name and serve as position anchors.
625
- const anchors = [];
626
- for (const ch of rendered) {
627
- if (!CALL_SIGN_PATTERN.test(ch.name)) {
628
- anchors.push(ch);
629
- }
630
- }
631
- // If no non-call-sign channels are rendered, we have no position anchors and cannot infer the affiliate.
632
- if (anchors.length === 0) {
633
- return null;
634
- }
635
- // Find the insertion point: the first anchor whose name sorts after the target.
636
- let insertBeforeIndex = -1;
637
- for (let i = 0; i < anchors.length; i++) {
638
- if (targetName.localeCompare(anchors[i].name) < 0) {
639
- insertBeforeIndex = i;
640
- break;
641
- }
642
- }
643
- // Determine the DOM index range between the two surrounding anchor channels.
644
- let lowerDomIndex;
645
- let upperDomIndex;
646
- if (insertBeforeIndex === 0) {
647
- // Target sorts before all anchors. Look for call signs before the first anchor.
648
- lowerDomIndex = -1;
649
- upperDomIndex = anchors[0].domIndex;
650
- }
651
- else if (insertBeforeIndex === -1) {
652
- // Target sorts after all anchors. Look for call signs after the last anchor.
653
- lowerDomIndex = anchors[anchors.length - 1].domIndex;
654
- upperDomIndex = rendered.length;
58
+ * Logs available channel names from a provider's guide grid when channel selection fails. Produces an actionable log message listing channel names that users can
59
+ * use as `channelSelector` values in user-defined channels. When `presetSuffix` is provided, channels already covered by built-in preset definitions are filtered
60
+ * out so users see only channels that require manual configuration. When omitted (small channel sets like Fox or HBO), all channels are logged unfiltered.
61
+ * @param options - Diagnostic dump configuration.
62
+ * @param options.additionalKnownNames - Extra names to exclude from the filtered list (e.g., CHANNEL_ALTERNATES values for YTTV).
63
+ * @param options.availableChannels - Sorted list of channel names discovered in the guide grid.
64
+ * @param options.channelName - The channelSelector value that failed to match, for the log message.
65
+ * @param options.guideUrl - The URL of the provider's guide page, included in the log message so users know what to set as the channel URL.
66
+ * @param options.presetSuffix - Key suffix to filter preset channels (e.g., "-yttv", "-hulu"). Omit for small unfiltered channel sets.
67
+ * @param options.providerName - Human-readable provider name for the log message (e.g., "YouTube TV", "Hulu").
68
+ */
69
+ export function logAvailableChannels(options) {
70
+ const { additionalKnownNames, availableChannels, channelName, guideUrl, presetSuffix, providerName } = options;
71
+ if (availableChannels.length === 0) {
72
+ return;
73
+ }
74
+ let filteredChannels;
75
+ let countLabel;
76
+ if (presetSuffix) {
77
+ // Collect all channelSelector values from preset channels with this suffix, lowercased for case-insensitive comparison.
78
+ const knownSelectors = Object.entries(CHANNELS)
79
+ .filter(([key]) => key.endsWith(presetSuffix))
80
+ .map(([, ch]) => (ch.channelSelector ?? "").toLowerCase())
81
+ .filter((s) => s.length > 0);
82
+ // Include additional known names (e.g., CHANNEL_ALTERNATES values for YTTV) so those are also filtered out.
83
+ if (additionalKnownNames) {
84
+ for (const name of additionalKnownNames) {
85
+ knownSelectors.push(name.toLowerCase());
86
+ }
87
+ }
88
+ // Filter to channels not matched by any known selector. A channel is "covered" if a preset would find it via exact match (with parenthetical suffix stripped)
89
+ // or prefix+digit match. This mirrors the strategy's own matching tiers so users see only channels that genuinely need manual configuration.
90
+ filteredChannels = availableChannels.filter((name) => {
91
+ const lower = name.toLowerCase();
92
+ const stripped = lower.replace(/ \(.*\)$/, "");
93
+ return !knownSelectors.some((sel) => {
94
+ return (stripped === sel) ||
95
+ (lower.startsWith(sel + " ") && (lower.length > sel.length + 1) && (lower.charCodeAt(sel.length + 1) >= 48) && (lower.charCodeAt(sel.length + 1) <= 57));
96
+ });
97
+ });
98
+ countLabel = "uncovered (" + String(filteredChannels.length) + " of " + String(availableChannels.length) + ")";
655
99
  }
656
100
  else {
657
- // Target sorts between two anchors.
658
- lowerDomIndex = anchors[insertBeforeIndex - 1].domIndex;
659
- upperDomIndex = anchors[insertBeforeIndex].domIndex;
660
- }
661
- // Find call sign channels in the DOM range between the two anchors.
662
- for (const ch of rendered) {
663
- if ((ch.domIndex > lowerDomIndex) && (ch.domIndex < upperDomIndex) && CALL_SIGN_PATTERN.test(ch.name)) {
664
- return ch.name;
665
- }
666
- }
667
- return null;
668
- }
669
- /**
670
- * Guide grid strategy: finds a channel in a virtualized, alphabetically sorted channel grid by scrolling the page to the target row using binary search, then
671
- * clicking the on-now program cell to open the playback overlay. This strategy works for sites like Hulu Live TV where the channel guide is rendered as a
672
- * virtualized list — only ~13 of ~124 rows exist in the DOM at any time, positioned absolutely within a tall spacer div. The virtualizer renders rows based on
673
- * the page scroll position (`document.documentElement.scrollTop`), so we scroll to bring the target channel into the DOM, then interact with it directly.
674
- *
675
- * Three mechanisms handle different channel types:
676
- * 1. Binary search with passive row number caching — primary mechanism for most channels (~800ms first time, ~200ms on cache hit)
677
- * 2. Position-based inference — handles local affiliates when searching by network name (e.g., "ABC" finds WLS at the right sort position)
678
- * 3. Linear scan fallback — safety net for raw call sign searches or any channel the binary search cannot find (~2.4 seconds)
679
- *
680
- * The selection process:
681
- * 1. If listSelector is provided, click the tab/button to reveal the channel list (e.g., a "Channels" tab)
682
- * 2. Wait for the channel grid rows to render in the DOM
683
- * 3. Check the row number cache for a direct-scroll shortcut
684
- * 4. Binary search: scroll to the midpoint row, read rendered channels (caching row numbers), check for exact match or infer local affiliate
685
- * 5. If binary search fails, linear scan from top to bottom as a universal fallback
686
- * 6. Click the on-now program cell (`.LiveGuideProgram--first`) in the target channel's row to open the playback overlay
687
- * 7. If playSelector is provided, wait for and click the play button to start live playback
688
- * @param page - The Puppeteer page object.
689
- * @param channelName - The channel name to match against data-testid attributes (case-insensitive).
690
- * @param channelSelection - The channel selection configuration containing strategy, listSelector, and playSelector.
691
- * @returns Result object with success status and optional failure reason.
692
- */
693
- async function guideGridStrategy(page, channelName, channelSelection) {
694
- const { listSelector, playSelector } = channelSelection;
695
- // Ensure the guide is open and on the correct tab. We wait for the tab button to become VISIBLE (not just present in the DOM) because the guide overlay may exist
696
- // in the DOM structure while still hidden during page initialization or animation. Clicking a hidden button dispatches a DOM event but has no visual effect — the
697
- // guide remains hidden and the virtualizer never populates rows. We use $eval for the click because overlapping elements (spinners, overlays) can intercept
698
- // Puppeteer's coordinate-based mouse events.
699
- if (listSelector) {
700
- try {
701
- await page.waitForSelector(listSelector, { timeout: CONFIG.streaming.videoTimeout, visible: true });
702
- await page.$eval(listSelector, (el) => el.click());
703
- // Brief delay for the tab switch animation and virtualizer initialization.
704
- await delay(300);
705
- }
706
- catch (error) {
707
- LOG.warn("Could not click channel list selector %s: %s.", listSelector, formatError(error));
708
- }
709
- }
710
- // Wait for channel grid rows to become visible. If rows don't appear within a short initial window, retry the tab click once — the first click may have fired
711
- // during a transitional state before the guide was fully interactive, or the guide may have been animating open.
712
- let rowsVisible = false;
713
- for (let guideAttempt = 0; guideAttempt < 2; guideAttempt++) {
714
- try {
715
- const rowTimeout = (guideAttempt === 0) ? 5000 : CONFIG.streaming.videoTimeout;
716
- // eslint-disable-next-line no-await-in-loop
717
- await page.waitForSelector("[data-testid=\"live-guide-row\"]", { timeout: rowTimeout, visible: true });
718
- rowsVisible = true;
719
- break;
720
- }
721
- catch {
722
- // Rows not visible yet. On first failure, retry the tab click in case the guide wasn't fully interactive.
723
- if ((guideAttempt === 0) && listSelector) {
724
- LOG.debug("Guide rows not visible after initial wait. Retrying tab click for %s.", listSelector);
725
- try {
726
- // eslint-disable-next-line no-await-in-loop
727
- await page.$eval(listSelector, (el) => el.click());
728
- // eslint-disable-next-line no-await-in-loop
729
- await delay(500);
730
- }
731
- catch {
732
- // Retry click failed. Fall through to final wait attempt.
733
- }
734
- }
735
- }
736
- }
737
- if (!rowsVisible) {
738
- return { reason: "Channel grid rows did not render.", success: false };
739
- }
740
- // Each row in the virtualized grid is exactly 112px tall. The total number of channels is derived from the spacer div's height.
741
- const ROW_HEIGHT = 112;
742
- // Normalize the channel name to lowercase for case-insensitive matching against data-testid suffixes.
743
- const normalizedName = normalizeChannelName(channelName);
744
- // Read grid metadata by walking up from a rendered row to find the spacer and viewport divs. The spacer div is the direct parent of all absolutely-positioned
745
- // rows, and its height equals totalRows * ROW_HEIGHT. The viewport div is the spacer's parent (overflow: hidden). We calculate gridDocTop as the viewport's
746
- // document-level offset, so that scrolling to gridDocTop + (rowIndex * ROW_HEIGHT) places that row at the top of the browser viewport.
747
- const gridMeta = await page.evaluate((rowHeight) => {
748
- const row = document.querySelector("[data-testid=\"live-guide-row\"]");
749
- if (!row) {
750
- return null;
751
- }
752
- // The spacer div is the parent of all row elements.
753
- const spacer = row.parentElement;
754
- if (!spacer) {
755
- return null;
756
- }
757
- const spacerHeight = spacer.offsetHeight;
758
- if (spacerHeight < rowHeight) {
759
- return null;
760
- }
761
- // The viewport div is the spacer's parent. Its position relative to the document determines our scroll offset.
762
- const viewport = spacer.parentElement;
763
- if (!viewport) {
764
- return null;
765
- }
766
- const gridDocTop = viewport.getBoundingClientRect().top + document.documentElement.scrollTop;
767
- return { gridDocTop, totalRows: Math.round(spacerHeight / rowHeight) };
768
- }, ROW_HEIGHT);
769
- if (!gridMeta) {
770
- return { reason: "Could not locate channel grid spacer element.", success: false };
771
- }
772
- const { gridDocTop, totalRows } = gridMeta;
773
- // Helper: scroll to a specific row index and wait for the virtualizer to render.
774
- const scrollToRow = async (rowIndex) => {
775
- await page.evaluate((scrollTo) => {
776
- document.documentElement.scrollTop = scrollTo;
777
- }, gridDocTop + (rowIndex * ROW_HEIGHT));
778
- await delay(200);
779
- };
780
- // The name of the channel to click. This starts as the normalized target name but may be replaced by a local affiliate call sign via position inference.
781
- let clickTarget = normalizedName;
782
- // Check the row number cache for a direct-scroll shortcut. If we've seen this channel before, we can skip binary search entirely and scroll directly to it.
783
- const cachedRow = guideRowCache.get(normalizedName);
784
- if (cachedRow !== undefined) {
785
- LOG.debug("Guide cache hit for %s at row %s.", channelName, cachedRow);
786
- await scrollToRow(cachedRow);
787
- // Read rendered channels to update the cache and confirm the channel is present.
788
- const rendered = await readRenderedChannels(page);
789
- if (rendered) {
790
- const match = rendered.find((ch) => ch.name === normalizedName);
791
- if (match) {
792
- return await clickOnNowCellAndPlay(page, normalizedName, playSelector, channelName);
793
- }
794
- }
795
- // Cache hit but channel not found at expected position. The guide may have changed. Clear this entry and fall through to binary search.
796
- LOG.debug("Guide cache miss for %s. Falling back to binary search.", channelName);
797
- guideRowCache.delete(normalizedName);
798
- }
799
- // Binary search through the virtualized channel list. On each iteration we scroll to the midpoint of the current range, wait for the virtualizer to render,
800
- // then check if the target channel is among the ~13 rendered rows. If not, we compare the target name alphabetically against the first and last rendered
801
- // channel names to narrow the range. The search converges in ~3-4 iterations because the 13-row render window covers a large fraction of the remaining range.
802
- let low = 0;
803
- let high = totalRows - 1;
804
- const maxIterations = 10;
805
- let found = false;
806
- for (let iteration = 0; iteration < maxIterations; iteration++) {
807
- if (low > high) {
808
- break;
809
- }
810
- const mid = Math.floor((low + high) / 2);
811
- // eslint-disable-next-line no-await-in-loop
812
- await scrollToRow(mid);
813
- // Read all rendered channels, populating the row number cache as a side effect.
814
- // eslint-disable-next-line no-await-in-loop
815
- const rendered = await readRenderedChannels(page);
816
- if (!rendered || (rendered.length === 0)) {
817
- continue;
818
- }
819
- // Check for an exact match first.
820
- const exactMatch = rendered.find((ch) => ch.name === normalizedName);
821
- if (exactMatch) {
822
- found = true;
823
- break;
824
- }
825
- // Determine binary search direction by comparing the target against the first and last rendered non-call-sign channel names. Call sign channels (W*/K*
826
- // local affiliates) are excluded from direction comparison because they sort by hidden network name, not by their displayed call sign — using them for
827
- // localeCompare would send the search the wrong way.
828
- const nonCallSigns = rendered.filter((ch) => !CALL_SIGN_PATTERN.test(ch.name));
829
- if (nonCallSigns.length === 0) {
830
- // All rendered channels are call signs. Cannot determine direction. Move down and hope for better data.
831
- low = mid + 1;
832
- continue;
833
- }
834
- const first = nonCallSigns[0].name;
835
- const last = nonCallSigns[nonCallSigns.length - 1].name;
836
- if (normalizedName.localeCompare(first) < 0) {
837
- // Target sorts before the first visible non-call-sign channel. Scroll up (toward lower row indices).
838
- high = mid - 1;
839
- continue;
840
- }
841
- if (normalizedName.localeCompare(last) > 0) {
842
- // Target sorts after the last visible non-call-sign channel. Scroll down (toward higher row indices).
843
- low = mid + 1;
844
- continue;
845
- }
846
- // The target is alphabetically between the first and last rendered channels but was not found by exact data-testid match. This is the "missing" case — the
847
- // channel may be a local affiliate whose call sign doesn't match the network name we're searching for. Try position-based inference.
848
- const inferred = inferLocalAffiliate(rendered, normalizedName);
849
- if (inferred) {
850
- LOG.info("Inferred local affiliate %s for network name %s.", inferred, channelName);
851
- clickTarget = inferred;
852
- found = true;
853
- // Cache the network name → affiliate's row number so subsequent tunes for the same network name become direct scrolls.
854
- const inferredRow = guideRowCache.get(inferred);
855
- if (inferredRow !== undefined) {
856
- guideRowCache.set(normalizedName, inferredRow);
857
- }
858
- }
859
- break;
860
- }
861
- // If binary search did not find the channel (and position inference didn't identify a local affiliate), fall back to a linear scan through all channels. This
862
- // handles edge cases like raw call sign searches (e.g., "WLS") where localeCompare gives the wrong direction, or channels like "Lakeshore PBS" that sort by
863
- // hidden network name but don't match the W/K call sign pattern.
864
- if (!found) {
865
- LOG.debug("Binary search did not find %s. Starting linear scan fallback.", channelName);
866
- for (let row = 0; row < totalRows; row += 10) {
867
- // eslint-disable-next-line no-await-in-loop
868
- await scrollToRow(row);
869
- // eslint-disable-next-line no-await-in-loop
870
- const rendered = await readRenderedChannels(page);
871
- if (!rendered) {
872
- continue;
873
- }
874
- const match = rendered.find((ch) => ch.name === normalizedName);
875
- if (match) {
876
- found = true;
877
- break;
878
- }
879
- }
880
- }
881
- if (!found) {
882
- return { reason: "Could not find channel " + channelName + " in guide grid.", success: false };
883
- }
884
- // Click the on-now program cell and wait for the play button, with click retries to handle React hydration timing.
885
- return await clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName);
886
- }
887
- /**
888
- * Waits for the play button to appear and clicks it using coordinate-based mouse events. Called by clickOnNowCellAndPlay after each on-now cell click attempt.
889
- * @param page - The Puppeteer page object.
890
- * @param playSelector - The CSS selector for the play button, or undefined if no play button is needed.
891
- * @param timeout - Optional timeout in milliseconds for the play button to appear. Defaults to CONFIG.streaming.videoTimeout.
892
- * @returns Result object with success status and optional failure reason.
893
- */
894
- async function waitForPlayButton(page, playSelector, timeout) {
895
- if (!playSelector) {
896
- return { success: true };
897
- }
898
- try {
899
- await page.waitForSelector(playSelector, { timeout: timeout ?? CONFIG.streaming.videoTimeout, visible: true });
900
- // Wait two animation frames for React to flush pending state updates. The play button may be visible in the DOM before React's concurrent mode has committed
901
- // the channel selection state to the component's event handlers. Without this, clicking immediately can trigger playback of the previously-selected channel
902
- // rather than the one we just chose. The double-rAF pattern synchronizes with the browser's rendering pipeline rather than using a fixed delay.
903
- await page.evaluate(async () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
904
- // Get the play button's coordinates for a real mouse click. Like the on-now cell click, we use page.mouse.click() to generate the full pointer event chain
905
- // rather than a bare DOM .click().
906
- const playTarget = await evaluateWithAbort(page, (selector) => {
907
- const el = document.querySelector(selector);
908
- if (!el) {
909
- return null;
910
- }
911
- el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
912
- const rect = el.getBoundingClientRect();
913
- if ((rect.width > 0) && (rect.height > 0)) {
914
- return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
915
- }
916
- return null;
917
- }, [playSelector]);
918
- if (!playTarget) {
919
- return { reason: "Play selector " + playSelector + " found but has no dimensions.", success: false };
920
- }
921
- await scrollAndClick(page, playTarget);
922
- return { success: true };
101
+ // No preset suffix log all available channels unfiltered. Used for small channel sets (Fox, HBO) where the full list is actionable without filtering.
102
+ filteredChannels = availableChannels;
103
+ countLabel = String(filteredChannels.length);
923
104
  }
924
- catch (error) {
925
- return { reason: "Could not click play selector " + playSelector + ": " + formatError(error) + ".", success: false };
926
- }
927
- }
928
- /**
929
- * Clicks the on-now program cell and waits for the play button, retrying the click if the play button doesn't appear. This handles a timing issue where the
930
- * guide grid's DOM elements render (so binary search finds the channel) before React has fully hydrated the event handlers (so the first mouse click on the
931
- * on-now cell may not trigger the playback overlay). Retrying the click after a brief delay allows hydration to complete.
932
- * @param page - The Puppeteer page object.
933
- * @param clickTarget - The lowercased, trimmed channel name to locate in the guide grid.
934
- * @param playSelector - The CSS selector for the play button, or undefined if no play button is needed.
935
- * @param channelName - The original channel name for logging.
936
- * @returns Result object with success status and optional failure reason.
937
- */
938
- async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName) {
939
- // Maximum number of on-now cell click attempts. The first click may not register if React hasn't finished hydrating the guide's event handlers.
940
- const MAX_CLICK_ATTEMPTS = 3;
941
- // Shorter timeout for the play button on non-final attempts. When a click registers, the play button appears in under 10ms — this timeout only determines how
942
- // quickly we detect a missed click and retry. Keeping it low saves ~2s per failed attempt compared to the previous 3000ms value.
943
- const RETRY_PLAY_TIMEOUT = 1000;
944
- // Delay between click retries. Gives React additional time to finish hydrating event handlers.
945
- const CLICK_RETRY_DELAY = 1500;
946
- for (let attempt = 0; attempt < MAX_CLICK_ATTEMPTS; attempt++) {
947
- // eslint-disable-next-line no-await-in-loop
948
- const onNowTarget = await locateOnNowCell(page, clickTarget);
949
- if (!onNowTarget) {
950
- return { reason: "Found channel " + channelName + " but could not locate on-now program cell.", success: false };
951
- }
952
- // eslint-disable-next-line no-await-in-loop
953
- await scrollAndClick(page, onNowTarget);
954
- // Use a shorter timeout on non-final attempts to enable quick retries. The final attempt uses the full default timeout as a last chance.
955
- const playTimeout = (attempt < MAX_CLICK_ATTEMPTS - 1) ? RETRY_PLAY_TIMEOUT : undefined;
956
- // eslint-disable-next-line no-await-in-loop
957
- const result = await waitForPlayButton(page, playSelector, playTimeout);
958
- if (result.success) {
959
- return result;
960
- }
961
- // Play button didn't appear — the click likely didn't register. Retry after a brief delay.
962
- if (attempt < MAX_CLICK_ATTEMPTS - 1) {
963
- LOG.debug("Play button did not appear for %s. Retrying on-now cell click (attempt %s of %s).", channelName, attempt + 2, MAX_CLICK_ATTEMPTS);
964
- // eslint-disable-next-line no-await-in-loop
965
- await delay(CLICK_RETRY_DELAY);
966
- }
105
+ if (filteredChannels.length === 0) {
106
+ return;
967
107
  }
968
- return { reason: "Play button did not appear after " + MAX_CLICK_ATTEMPTS + " on-now cell click attempts for " + channelName + ".", success: false };
108
+ LOG.warn("Channel \"%s\" not found in %s guide. Create a user-defined channel with one of the names below as the Channel Selector and %s as the URL. " +
109
+ "Available channels (%s): %s.", channelName, providerName, guideUrl, countLabel, filteredChannels.join(", "));
969
110
  }
970
- /* The selectChannel() function is the public API for channel selection. It delegates to the appropriate strategy based on the profile configuration.
971
- */
972
111
  /**
973
112
  * Selects a channel from a multi-channel player UI using the strategy specified in the profile. This is the main entry point for channel selection, called by
974
113
  * tuneToChannel() after page navigation.
@@ -983,57 +122,35 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
983
122
  * @returns Result object with success status and optional failure reason.
984
123
  */
985
124
  export async function selectChannel(page, profile) {
986
- const { channelSelection, channelSelector } = profile;
125
+ const { channelSelection } = profile;
987
126
  // No channel selection needed if strategy is "none" or no channelSelector is specified.
988
- if ((channelSelection.strategy === "none") || !channelSelector) {
127
+ if ((channelSelection.strategy === "none") || !isChannelSelectionProfile(profile)) {
989
128
  return { success: true };
990
129
  }
991
130
  // Poll for the channel slug image to appear and fully load. We check both src match and load completion (img.complete + naturalWidth) to ensure the image is
992
131
  // actually rendered before proceeding. This prevents race conditions where the img element exists with the correct src but the browser hasn't finished fetching
993
- // and rendering it, which can cause layout instability and click failures. We skip this polling for guideGrid (channel list images are hidden behind a tab),
994
- // hboGrid (channelSelector is a channel name, not an image URL slug), and youtubeGrid (same reason as hboGrid).
995
- if ((channelSelection.strategy !== "guideGrid") && (channelSelection.strategy !== "hboGrid") && (channelSelection.strategy !== "youtubeGrid")) {
132
+ // and rendering it, which can cause layout instability and click failures. We skip this polling for foxGrid (channelSelector is a station code, not an image
133
+ // URL slug), guideGrid (channel list images are hidden behind a tab), hboGrid (channelSelector is a channel name, not an image URL slug), slingGrid (same
134
+ // reason as hboGrid), and youtubeGrid (same reason as hboGrid).
135
+ const skipImagePolling = ["foxGrid", "guideGrid", "hboGrid", "slingGrid", "youtubeGrid"];
136
+ if (!skipImagePolling.includes(channelSelection.strategy)) {
996
137
  try {
997
138
  await page.waitForFunction((slug) => {
998
139
  return Array.from(document.querySelectorAll("img")).some((img) => img.src && img.src.includes(slug) && img.complete && (img.naturalWidth > 0));
999
- }, { timeout: CONFIG.playback.channelSelectorDelay }, channelSelector);
140
+ }, { timeout: CONFIG.playback.channelSelectorDelay }, profile.channelSelector);
1000
141
  }
1001
142
  catch {
1002
143
  // Timeout — the image hasn't loaded yet. Proceed anyway and let the strategy evaluate and report not-found naturally.
1003
144
  }
1004
145
  }
1005
- // Dispatch to the appropriate strategy.
1006
- let result;
1007
- switch (channelSelection.strategy) {
1008
- case "guideGrid": {
1009
- result = await guideGridStrategy(page, channelSelector, channelSelection);
1010
- break;
1011
- }
1012
- case "hboGrid": {
1013
- result = await hboGridStrategy(page, channelSelector);
1014
- break;
1015
- }
1016
- case "thumbnailRow": {
1017
- result = await thumbnailRowStrategy(page, channelSelector);
1018
- break;
1019
- }
1020
- case "tileClick": {
1021
- result = await tileClickStrategy(page, channelSelector);
1022
- break;
1023
- }
1024
- case "youtubeGrid": {
1025
- result = await youtubeGridStrategy(page, channelSelector);
1026
- break;
1027
- }
1028
- default: {
1029
- // Unknown strategy - this shouldn't happen if profiles are validated, but handle gracefully.
1030
- LOG.warn("Unknown channel selection strategy: %s.", channelSelection.strategy);
1031
- return { reason: "Unknown channel selection strategy.", success: false };
1032
- }
1033
- }
1034
- if (!result.success) {
1035
- LOG.warn("Failed to select %s from channel guide: %s", channelSelector, result.reason ?? "Unknown reason.");
146
+ // Dispatch to the appropriate strategy via the registry.
147
+ const strategyFn = strategies[channelSelection.strategy];
148
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
149
+ if (!strategyFn) {
150
+ LOG.warn("Unknown channel selection strategy: %s.", channelSelection.strategy);
151
+ return { reason: "Unknown channel selection strategy.", success: false };
1036
152
  }
153
+ const result = await strategyFn(page, profile);
1037
154
  return result;
1038
155
  }
1039
156
  //# sourceMappingURL=channelSelection.js.map