prismcast 1.2.0 → 1.3.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.
- package/README.md +26 -1
- package/dist/app.js +29 -2
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +14 -4
- package/dist/browser/channelSelection.js +48 -704
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/index.d.ts +18 -4
- package/dist/browser/index.js +205 -39
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/tuning/fox.d.ts +19 -0
- package/dist/browser/tuning/fox.js +61 -0
- package/dist/browser/tuning/fox.js.map +1 -0
- package/dist/browser/tuning/hbo.d.ts +22 -0
- package/dist/browser/tuning/hbo.js +182 -0
- package/dist/browser/tuning/hbo.js.map +1 -0
- package/dist/browser/tuning/hulu.d.ts +30 -0
- package/dist/browser/tuning/hulu.js +514 -0
- package/dist/browser/tuning/hulu.js.map +1 -0
- package/dist/browser/tuning/sling.d.ts +22 -0
- package/dist/browser/tuning/sling.js +287 -0
- package/dist/browser/tuning/sling.js.map +1 -0
- package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
- package/dist/browser/tuning/thumbnailRow.js +107 -0
- package/dist/browser/tuning/thumbnailRow.js.map +1 -0
- package/dist/browser/tuning/tileClick.d.ts +18 -0
- package/dist/browser/tuning/tileClick.js +102 -0
- package/dist/browser/tuning/tileClick.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.d.ts +17 -0
- package/dist/browser/tuning/youtubeTv.js +99 -0
- package/dist/browser/tuning/youtubeTv.js.map +1 -0
- package/dist/browser/video.d.ts +2 -2
- package/dist/browser/video.js +18 -8
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.js +131 -12
- package/dist/channels/index.js.map +1 -1
- package/dist/config/profiles.d.ts +5 -5
- package/dist/config/profiles.js +35 -11
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providers.d.ts +48 -4
- package/dist/config/providers.js +182 -22
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +10 -0
- package/dist/config/sites.js +96 -17
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.js +21 -3
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -1
- package/dist/config/userConfig.js +12 -4
- package/dist/config/userConfig.js.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/routes/auth.js +6 -0
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/config.d.ts +5 -0
- package/dist/routes/config.js +273 -35
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/debug.d.ts +6 -0
- package/dist/routes/debug.js +274 -0
- package/dist/routes/debug.js.map +1 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +3 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +17 -5
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/root.js +507 -128
- package/dist/routes/root.js.map +1 -1
- package/dist/routes/theme.js +7 -0
- package/dist/routes/theme.js.map +1 -1
- package/dist/service/commands.js +8 -8
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.js +22 -16
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +7 -0
- package/dist/streaming/fmp4Segmenter.js +124 -15
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +74 -6
- package/dist/streaming/hls.js +212 -130
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsSegments.js +11 -4
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +1 -1
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -1
- package/dist/streaming/monitor.js +202 -53
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +29 -1
- package/dist/streaming/mp4Parser.js +243 -18
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +5 -2
- package/dist/streaming/mpegts.js +122 -29
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/registry.d.ts +1 -1
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +13 -1
- package/dist/streaming/setup.js +136 -36
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +9 -9
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.js +5 -6
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/index.d.ts +16 -3
- package/dist/types/index.js +5 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/debugFilter.d.ts +38 -0
- package/dist/utils/debugFilter.js +157 -0
- package/dist/utils/debugFilter.js.map +1 -0
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +5 -0
- package/dist/utils/ffmpeg.js +13 -2
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/fileLogger.d.ts +2 -1
- package/dist/utils/fileLogger.js +4 -2
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.d.ts +1 -0
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/logger.d.ts +9 -8
- package/dist/utils/logger.js +52 -28
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/retry.d.ts +1 -1
- package/dist/utils/retry.js +3 -3
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/version.js +4 -9
- package/dist/utils/version.js.map +1 -1
- package/package.json +6 -6
|
@@ -1,703 +1,58 @@
|
|
|
1
|
-
|
|
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
|
-
* - thumbnailRow: Find channel by matching image URL slug, click adjacent show entry on the same row (USA Network)
|
|
10
|
-
* - tileClick: Find channel tile by matching image URL slug, click tile, then click play button on modal (Disney+ live)
|
|
1
|
+
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
11
2
|
*
|
|
12
|
-
*
|
|
13
|
-
* delegates to the appropriate strategy based on the profile configuration.
|
|
3
|
+
* channelSelection.ts: Channel selection coordinator for multi-channel streaming sites.
|
|
14
4
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 { CONFIG } from "../config/index.js";
|
|
11
|
+
import { foxGridStrategy } from "./tuning/fox.js";
|
|
12
|
+
import { thumbnailRowStrategy } from "./tuning/thumbnailRow.js";
|
|
13
|
+
import { tileClickStrategy } from "./tuning/tileClick.js";
|
|
14
|
+
import { youtubeGridStrategy } from "./tuning/youtubeTv.js";
|
|
15
|
+
// Strategy dispatch registry. Maps strategy names from ChannelSelectionStrategy to their implementation functions.
|
|
16
|
+
const strategies = {
|
|
17
|
+
foxGrid: foxGridStrategy,
|
|
18
|
+
guideGrid: guideGridStrategy,
|
|
19
|
+
hboGrid: hboGridStrategy,
|
|
20
|
+
slingGrid: slingGridStrategy,
|
|
21
|
+
thumbnailRow: thumbnailRowStrategy,
|
|
22
|
+
tileClick: tileClickStrategy,
|
|
23
|
+
youtubeGrid: youtubeGridStrategy
|
|
24
|
+
};
|
|
18
25
|
/**
|
|
19
|
-
* Clears
|
|
20
|
-
*
|
|
26
|
+
* Clears all channel selection caches. Called by handleBrowserDisconnect() in browser/index.ts when the browser restarts, since cached state (guide row positions,
|
|
27
|
+
* discovered page URLs) may be stale in a new browser session.
|
|
21
28
|
*/
|
|
22
|
-
export function
|
|
23
|
-
|
|
29
|
+
export function clearChannelSelectionCaches() {
|
|
30
|
+
clearHboCache();
|
|
31
|
+
clearHuluCache();
|
|
32
|
+
clearSlingCache();
|
|
24
33
|
}
|
|
25
|
-
/* These utilities are shared across channel selection strategies. They handle common operations like finding elements, scrolling, and clicking.
|
|
26
|
-
*/
|
|
27
34
|
/**
|
|
28
35
|
* 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.
|
|
29
36
|
* Callers are responsible for scrolling the target element into view (typically via scrollIntoView inside a page.evaluate call) before invoking this function.
|
|
37
|
+
* Exported for use by tuning strategy files (thumbnailRow, tileClick, hulu).
|
|
30
38
|
* @param page - The Puppeteer page object.
|
|
31
39
|
* @param target - The x/y coordinates to click.
|
|
32
40
|
* @returns True if the click was executed.
|
|
33
41
|
*/
|
|
34
|
-
async function scrollAndClick(page, target) {
|
|
42
|
+
export async function scrollAndClick(page, target) {
|
|
35
43
|
// Brief delay after scrolling for any animations or lazy-loaded content to settle.
|
|
36
44
|
await delay(200);
|
|
37
45
|
// Click the target coordinates to switch to the channel.
|
|
38
46
|
await page.mouse.click(target.x, target.y);
|
|
39
47
|
return true;
|
|
40
48
|
}
|
|
41
|
-
/* Each strategy implements a different approach to finding and selecting channels. Strategies are self-contained functions that can be tested independently.
|
|
42
|
-
*/
|
|
43
|
-
/**
|
|
44
|
-
* 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
|
|
45
|
-
* works for sites like USA Network where channels are displayed as rows with a thumbnail on the left and program entries to the right.
|
|
46
|
-
*
|
|
47
|
-
* The selection process:
|
|
48
|
-
* 1. Search all images on the page for one whose src URL contains the channel slug
|
|
49
|
-
* 2. Verify the image has dimensions (is rendered and visible)
|
|
50
|
-
* 3. Walk up the DOM to find a container wide enough to hold both thumbnail and guide entries
|
|
51
|
-
* 4. Search for clickable elements (links, buttons, cards) to the right of the thumbnail on the same row
|
|
52
|
-
* 5. Fall back to divs with cursor:pointer if no semantic clickables found
|
|
53
|
-
* 6. Click the found element to switch to the channel
|
|
54
|
-
* @param page - The Puppeteer page object.
|
|
55
|
-
* @param channelSlug - The literal string to match in thumbnail image URLs.
|
|
56
|
-
* @returns Result object with success status and optional failure reason.
|
|
57
|
-
*/
|
|
58
|
-
async function thumbnailRowStrategy(page, channelSlug) {
|
|
59
|
-
// Find clickable element by evaluating DOM. The logic walks through the page looking for channel thumbnail images, then finds clickable show entries on the
|
|
60
|
-
// same row.
|
|
61
|
-
const clickTarget = await evaluateWithAbort(page, (slug) => {
|
|
62
|
-
const images = document.querySelectorAll("img");
|
|
63
|
-
for (const img of Array.from(images)) {
|
|
64
|
-
// Channel thumbnails have URLs containing the channel slug pattern. Match against the src URL.
|
|
65
|
-
if (img.src && img.src.includes(slug)) {
|
|
66
|
-
const imgRect = img.getBoundingClientRect();
|
|
67
|
-
// Verify the image has dimensions (is actually rendered and visible).
|
|
68
|
-
if ((imgRect.width > 0) && (imgRect.height > 0)) {
|
|
69
|
-
// 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.
|
|
70
|
-
let rowContainer = img.parentElement;
|
|
71
|
-
while (rowContainer && (rowContainer !== document.body)) {
|
|
72
|
-
const containerRect = rowContainer.getBoundingClientRect();
|
|
73
|
-
// Look for a container significantly wider than the thumbnail (indicating it contains more than just the image). The factor of 2 is a heuristic
|
|
74
|
-
// that works for typical channel guide layouts.
|
|
75
|
-
if (containerRect.width > (imgRect.width * 2)) {
|
|
76
|
-
// This container is wide enough to contain guide entries. Search for clickable elements (show cards) to the right of the thumbnail.
|
|
77
|
-
const clickables = rowContainer.querySelectorAll("a, button, [role=\"button\"], [onclick], [class*=\"card\"], [class*=\"program\"], [class*=\"show\"], [class*=\"episode\"]");
|
|
78
|
-
const imgCenterY = imgRect.y + (imgRect.height / 2);
|
|
79
|
-
for (const clickable of Array.from(clickables)) {
|
|
80
|
-
const clickRect = clickable.getBoundingClientRect();
|
|
81
|
-
const clickCenterY = clickRect.y + (clickRect.height / 2);
|
|
82
|
-
// The guide entry must meet these criteria:
|
|
83
|
-
// - To the right of the thumbnail (with small tolerance for overlapping borders)
|
|
84
|
-
// - Has dimensions (is visible)
|
|
85
|
-
// - On the same row (vertical center within thumbnail height)
|
|
86
|
-
const isRightOfThumbnail = clickRect.x > (imgRect.x + imgRect.width - 10);
|
|
87
|
-
const hasDimensions = (clickRect.width > 0) && (clickRect.height > 0);
|
|
88
|
-
const isSameRow = Math.abs(clickCenterY - imgCenterY) < imgRect.height;
|
|
89
|
-
if (isRightOfThumbnail && hasDimensions && isSameRow) {
|
|
90
|
-
// Found a suitable click target. Scroll it into view and return its center coordinates.
|
|
91
|
-
clickable.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
92
|
-
const newRect = clickable.getBoundingClientRect();
|
|
93
|
-
return { x: newRect.x + (newRect.width / 2), y: newRect.y + (newRect.height / 2) };
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
// Fallback: if no semantically clickable elements found, look for divs with cursor: pointer styling. These are often custom-styled click
|
|
97
|
-
// handlers.
|
|
98
|
-
const allDivs = rowContainer.querySelectorAll("div");
|
|
99
|
-
for (const div of Array.from(allDivs)) {
|
|
100
|
-
const divRect = div.getBoundingClientRect();
|
|
101
|
-
const divCenterY = divRect.y + (divRect.height / 2);
|
|
102
|
-
const style = window.getComputedStyle(div);
|
|
103
|
-
const isRightOfThumbnail = divRect.x > (imgRect.x + imgRect.width - 10);
|
|
104
|
-
const hasDimensions = (divRect.width > 20) && (divRect.height > 20);
|
|
105
|
-
const isClickable = style.cursor === "pointer";
|
|
106
|
-
const isSameRow = Math.abs(divCenterY - imgCenterY) < imgRect.height;
|
|
107
|
-
if (isRightOfThumbnail && hasDimensions && isClickable && isSameRow) {
|
|
108
|
-
div.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
109
|
-
const newRect = div.getBoundingClientRect();
|
|
110
|
-
return { x: newRect.x + (newRect.width / 2), y: newRect.y + (newRect.height / 2) };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
rowContainer = rowContainer.parentElement;
|
|
115
|
-
}
|
|
116
|
-
// 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
|
|
117
|
-
// expectations.
|
|
118
|
-
img.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
119
|
-
const newImgRect = img.getBoundingClientRect();
|
|
120
|
-
return { x: newImgRect.x + newImgRect.width + 50, y: newImgRect.y + (newImgRect.height / 2) };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
// Channel thumbnail not found in any images.
|
|
125
|
-
return null;
|
|
126
|
-
}, [channelSlug]);
|
|
127
|
-
if (clickTarget) {
|
|
128
|
-
await scrollAndClick(page, clickTarget);
|
|
129
|
-
// 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
|
|
130
|
-
// early exit. If no video exists yet or readyState never drops (channel already selected), the timeout expires harmlessly and waitForVideoReady() handles the
|
|
131
|
-
// rest.
|
|
132
|
-
try {
|
|
133
|
-
await page.waitForFunction(() => {
|
|
134
|
-
const v = document.querySelector("video");
|
|
135
|
-
return !v || (v.readyState < 3);
|
|
136
|
-
}, { timeout: CONFIG.playback.channelSwitchDelay });
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
// Timeout — readyState never dropped. Proceed normally.
|
|
140
|
-
}
|
|
141
|
-
return { success: true };
|
|
142
|
-
}
|
|
143
|
-
return { reason: "Channel thumbnail not found in page images.", success: false };
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* 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
|
|
147
|
-
* 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
|
|
148
|
-
* play button to start the live stream.
|
|
149
|
-
*
|
|
150
|
-
* The selection process:
|
|
151
|
-
* 1. Search all images on the page for one whose src URL contains the channel slug
|
|
152
|
-
* 2. Walk up the DOM to find the nearest clickable ancestor (the tile container)
|
|
153
|
-
* 3. Scroll the tile into view and click it
|
|
154
|
-
* 4. Wait for the play button to appear on the resulting modal
|
|
155
|
-
* 5. Click the play button to start live playback
|
|
156
|
-
* @param page - The Puppeteer page object.
|
|
157
|
-
* @param channelSlug - The literal string to match in tile image URLs.
|
|
158
|
-
* @returns Result object with success status and optional failure reason.
|
|
159
|
-
*/
|
|
160
|
-
async function tileClickStrategy(page, channelSlug) {
|
|
161
|
-
// 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
|
|
162
|
-
// 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
|
|
163
|
-
// clickable ancestor that represents the entire tile.
|
|
164
|
-
const tileTarget = await evaluateWithAbort(page, (slug) => {
|
|
165
|
-
const images = document.querySelectorAll("img");
|
|
166
|
-
for (const img of Array.from(images)) {
|
|
167
|
-
if (img.src && img.src.includes(slug)) {
|
|
168
|
-
const imgRect = img.getBoundingClientRect();
|
|
169
|
-
// Verify the image has dimensions (is actually rendered and visible). This matches the pattern in thumbnailRowStrategy and provides defense-in-depth if the
|
|
170
|
-
// wait phase timed out before the image fully loaded.
|
|
171
|
-
if ((imgRect.width > 0) && (imgRect.height > 0)) {
|
|
172
|
-
// Walk up the DOM to find the nearest clickable ancestor wrapping the tile. Check for semantic clickable elements (<a>, <button>, role="button") and
|
|
173
|
-
// elements with explicit click handlers first. Track cursor:pointer elements as a fallback for sites using custom click handlers without semantic markup.
|
|
174
|
-
let ancestor = img.parentElement;
|
|
175
|
-
let pointerFallback = null;
|
|
176
|
-
while (ancestor && (ancestor !== document.body)) {
|
|
177
|
-
const tag = ancestor.tagName;
|
|
178
|
-
// Semantic clickable elements are the most reliable indicators of an interactive tile container.
|
|
179
|
-
if ((tag === "A") || (tag === "BUTTON") || (ancestor.getAttribute("role") === "button") || ancestor.hasAttribute("onclick")) {
|
|
180
|
-
ancestor.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
181
|
-
const rect = ancestor.getBoundingClientRect();
|
|
182
|
-
if ((rect.width > 0) && (rect.height > 0)) {
|
|
183
|
-
return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
// Track the nearest cursor:pointer ancestor with reasonable dimensions as a fallback.
|
|
187
|
-
if (!pointerFallback) {
|
|
188
|
-
const rect = ancestor.getBoundingClientRect();
|
|
189
|
-
if ((rect.width > 20) && (rect.height > 20) && (window.getComputedStyle(ancestor).cursor === "pointer")) {
|
|
190
|
-
pointerFallback = ancestor;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
ancestor = ancestor.parentElement;
|
|
194
|
-
}
|
|
195
|
-
// Fallback: use cursor:pointer ancestor if no semantic clickable was found above.
|
|
196
|
-
if (pointerFallback) {
|
|
197
|
-
pointerFallback.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
198
|
-
const rect = pointerFallback.getBoundingClientRect();
|
|
199
|
-
if ((rect.width > 0) && (rect.height > 0)) {
|
|
200
|
-
return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return null;
|
|
207
|
-
}, [channelSlug]);
|
|
208
|
-
if (!tileTarget) {
|
|
209
|
-
return { reason: "Channel tile not found in page images.", success: false };
|
|
210
|
-
}
|
|
211
|
-
// Click the channel tile to open the entity modal.
|
|
212
|
-
await scrollAndClick(page, tileTarget);
|
|
213
|
-
// 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
|
|
214
|
-
// tile, the site performs a SPA navigation that renders a modal with playback options.
|
|
215
|
-
const playButtonSelector = "[data-testid=\"live-modal-watch-live-action-button\"]";
|
|
216
|
-
try {
|
|
217
|
-
await page.waitForSelector(playButtonSelector, { timeout: CONFIG.streaming.videoTimeout });
|
|
218
|
-
}
|
|
219
|
-
catch {
|
|
220
|
-
return { reason: "Play button did not appear after clicking channel tile.", success: false };
|
|
221
|
-
}
|
|
222
|
-
// Get the play button coordinates for clicking.
|
|
223
|
-
const playTarget = await evaluateWithAbort(page, (selector) => {
|
|
224
|
-
const button = document.querySelector(selector);
|
|
225
|
-
if (!button) {
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
|
-
button.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
229
|
-
const rect = button.getBoundingClientRect();
|
|
230
|
-
if ((rect.width > 0) && (rect.height > 0)) {
|
|
231
|
-
return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
|
|
232
|
-
}
|
|
233
|
-
return null;
|
|
234
|
-
}, [playButtonSelector]);
|
|
235
|
-
if (!playTarget) {
|
|
236
|
-
return { reason: "Play button found but has no dimensions.", success: false };
|
|
237
|
-
}
|
|
238
|
-
// Click the play button to start live playback.
|
|
239
|
-
await scrollAndClick(page, playTarget);
|
|
240
|
-
return { success: true };
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* 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
|
|
244
|
-
* row number cache as a side effect.
|
|
245
|
-
* @param page - The Puppeteer page object.
|
|
246
|
-
* @returns Array of rendered channels in DOM order, or null if no channels are rendered.
|
|
247
|
-
*/
|
|
248
|
-
async function readRenderedChannels(page) {
|
|
249
|
-
const channels = await page.evaluate(() => {
|
|
250
|
-
const containers = document.querySelectorAll("[data-testid^=\"live-guide-channel-kyber-\"]");
|
|
251
|
-
if (containers.length === 0) {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
const prefix = "live-guide-channel-kyber-";
|
|
255
|
-
const results = [];
|
|
256
|
-
for (const el of Array.from(containers)) {
|
|
257
|
-
const testid = el.getAttribute("data-testid") ?? "";
|
|
258
|
-
const name = testid.slice(prefix.length).trim().replace(/\s+/g, " ").toLowerCase();
|
|
259
|
-
// Extract row number from sr-only text. Format: "{Name} Details, row {N} of {Total}. ..."
|
|
260
|
-
let rowNumber = -1;
|
|
261
|
-
const btn = el.querySelector("[data-testid=\"live-guide-channel-button\"]");
|
|
262
|
-
if (btn) {
|
|
263
|
-
const srOnly = btn.querySelector(".sr-only, [class*=\"sr-only\"]");
|
|
264
|
-
if (srOnly) {
|
|
265
|
-
const match = srOnly.textContent.match(/row (\d+) of/);
|
|
266
|
-
if (match) {
|
|
267
|
-
// Row numbers in sr-only text are 1-based. Convert to 0-based for scroll offset calculation.
|
|
268
|
-
rowNumber = parseInt(match[1], 10) - 1;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
results.push({ name, rowNumber });
|
|
273
|
-
}
|
|
274
|
-
return results;
|
|
275
|
-
});
|
|
276
|
-
if (!channels) {
|
|
277
|
-
return null;
|
|
278
|
-
}
|
|
279
|
-
// Assign DOM indices and populate the row number cache.
|
|
280
|
-
const rendered = [];
|
|
281
|
-
for (let i = 0; i < channels.length; i++) {
|
|
282
|
-
const ch = channels[i];
|
|
283
|
-
rendered.push({ domIndex: i, name: ch.name, rowNumber: ch.rowNumber });
|
|
284
|
-
// Cache the row number for future direct-scroll lookups.
|
|
285
|
-
if (ch.rowNumber >= 0) {
|
|
286
|
-
guideRowCache.set(ch.name, ch.rowNumber);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return rendered;
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* 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
|
|
293
|
-
* for a subsequent page.mouse.click(). We return coordinates rather than clicking inside the evaluate because page.mouse.click() generates the full pointer event
|
|
294
|
-
* chain (pointerdown → mousedown → pointerup → mouseup → click) that React's event delegation requires, whereas a bare DOM .click() dispatches only a synthetic
|
|
295
|
-
* click event that may not be processed reliably in a Puppeteer automation context.
|
|
296
|
-
* @param page - The Puppeteer page object.
|
|
297
|
-
* @param targetName - The lowercased, trimmed channel name to match against data-testid.
|
|
298
|
-
* @returns Center coordinates of the on-now cell, or null if not found.
|
|
299
|
-
*/
|
|
300
|
-
async function locateOnNowCell(page, targetName) {
|
|
301
|
-
return evaluateWithAbort(page, (target) => {
|
|
302
|
-
const prefix = "live-guide-channel-kyber-";
|
|
303
|
-
const containers = document.querySelectorAll("[data-testid^=\"" + prefix + "\"]");
|
|
304
|
-
for (const el of Array.from(containers)) {
|
|
305
|
-
const testid = el.getAttribute("data-testid") ?? "";
|
|
306
|
-
const name = testid.slice(prefix.length).trim().replace(/\s+/g, " ").toLowerCase();
|
|
307
|
-
if (name === target) {
|
|
308
|
-
const row = el.closest("[data-testid=\"live-guide-row\"]");
|
|
309
|
-
if (!row) {
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
const onNow = row.querySelector(".LiveGuideProgram--first");
|
|
313
|
-
if (!onNow) {
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
onNow.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
317
|
-
const rect = onNow.getBoundingClientRect();
|
|
318
|
-
if ((rect.width > 0) && (rect.height > 0)) {
|
|
319
|
-
return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
|
|
320
|
-
}
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return null;
|
|
325
|
-
}, [targetName]);
|
|
326
|
-
}
|
|
327
|
-
// 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
|
|
328
|
-
// 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.
|
|
329
|
-
const CALL_SIGN_PATTERN = /^[WK][A-Z]{2,3}$/i;
|
|
330
49
|
// Normalizes a channel name for case-insensitive, whitespace-tolerant comparison. Trims leading and trailing whitespace, collapses internal whitespace sequences
|
|
331
50
|
// (including non-breaking spaces, tabs, and other Unicode whitespace matched by \s) into a single regular space, and lowercases. This handles data-testid values
|
|
332
51
|
// with trailing spaces (e.g., "WLS "), double spaces, or non-breaking space characters that would otherwise cause exact match failures.
|
|
333
|
-
|
|
52
|
+
// Exported for use by tuning strategy files (hulu, sling).
|
|
53
|
+
export function normalizeChannelName(name) {
|
|
334
54
|
return name.trim().replace(/\s+/g, " ").toLowerCase();
|
|
335
55
|
}
|
|
336
|
-
/**
|
|
337
|
-
* Position-based inference for local affiliates. When binary search returns "missing" (target name sorts between rendered channels but no exact match), this
|
|
338
|
-
* function identifies the local affiliate at the target's alphabetical insertion point.
|
|
339
|
-
*
|
|
340
|
-
* 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
|
|
341
|
-
* 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
|
|
342
|
-
* sign. The affiliate occupies the DOM position where the network name would be if it existed.
|
|
343
|
-
*
|
|
344
|
-
* Algorithm:
|
|
345
|
-
* 1. Filter rendered channels to non-call-sign names (these sort correctly by their displayed name)
|
|
346
|
-
* 2. Find where the target would insert alphabetically among the non-call-sign neighbors
|
|
347
|
-
* 3. The channel at the DOM position between those two neighbors is the local affiliate
|
|
348
|
-
* @param rendered - The rendered channels in DOM order.
|
|
349
|
-
* @param targetName - The lowercased target channel name.
|
|
350
|
-
* @returns The name of the inferred local affiliate channel, or null if inference fails.
|
|
351
|
-
*/
|
|
352
|
-
function inferLocalAffiliate(rendered, targetName) {
|
|
353
|
-
// 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.
|
|
354
|
-
const anchors = [];
|
|
355
|
-
for (const ch of rendered) {
|
|
356
|
-
if (!CALL_SIGN_PATTERN.test(ch.name)) {
|
|
357
|
-
anchors.push(ch);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
// If no non-call-sign channels are rendered, we have no position anchors and cannot infer the affiliate.
|
|
361
|
-
if (anchors.length === 0) {
|
|
362
|
-
return null;
|
|
363
|
-
}
|
|
364
|
-
// Find the insertion point: the first anchor whose name sorts after the target.
|
|
365
|
-
let insertBeforeIndex = -1;
|
|
366
|
-
for (let i = 0; i < anchors.length; i++) {
|
|
367
|
-
if (targetName.localeCompare(anchors[i].name) < 0) {
|
|
368
|
-
insertBeforeIndex = i;
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
// Determine the DOM index range between the two surrounding anchor channels.
|
|
373
|
-
let lowerDomIndex;
|
|
374
|
-
let upperDomIndex;
|
|
375
|
-
if (insertBeforeIndex === 0) {
|
|
376
|
-
// Target sorts before all anchors. Look for call signs before the first anchor.
|
|
377
|
-
lowerDomIndex = -1;
|
|
378
|
-
upperDomIndex = anchors[0].domIndex;
|
|
379
|
-
}
|
|
380
|
-
else if (insertBeforeIndex === -1) {
|
|
381
|
-
// Target sorts after all anchors. Look for call signs after the last anchor.
|
|
382
|
-
lowerDomIndex = anchors[anchors.length - 1].domIndex;
|
|
383
|
-
upperDomIndex = rendered.length;
|
|
384
|
-
}
|
|
385
|
-
else {
|
|
386
|
-
// Target sorts between two anchors.
|
|
387
|
-
lowerDomIndex = anchors[insertBeforeIndex - 1].domIndex;
|
|
388
|
-
upperDomIndex = anchors[insertBeforeIndex].domIndex;
|
|
389
|
-
}
|
|
390
|
-
// Find call sign channels in the DOM range between the two anchors.
|
|
391
|
-
for (const ch of rendered) {
|
|
392
|
-
if ((ch.domIndex > lowerDomIndex) && (ch.domIndex < upperDomIndex) && CALL_SIGN_PATTERN.test(ch.name)) {
|
|
393
|
-
return ch.name;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
return null;
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* 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
|
|
400
|
-
* 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
|
|
401
|
-
* 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
|
|
402
|
-
* the page scroll position (`document.documentElement.scrollTop`), so we scroll to bring the target channel into the DOM, then interact with it directly.
|
|
403
|
-
*
|
|
404
|
-
* Three mechanisms handle different channel types:
|
|
405
|
-
* 1. Binary search with passive row number caching — primary mechanism for most channels (~800ms first time, ~200ms on cache hit)
|
|
406
|
-
* 2. Position-based inference — handles local affiliates when searching by network name (e.g., "ABC" finds WLS at the right sort position)
|
|
407
|
-
* 3. Linear scan fallback — safety net for raw call sign searches or any channel the binary search cannot find (~2.4 seconds)
|
|
408
|
-
*
|
|
409
|
-
* The selection process:
|
|
410
|
-
* 1. If listSelector is provided, click the tab/button to reveal the channel list (e.g., a "Channels" tab)
|
|
411
|
-
* 2. Wait for the channel grid rows to render in the DOM
|
|
412
|
-
* 3. Check the row number cache for a direct-scroll shortcut
|
|
413
|
-
* 4. Binary search: scroll to the midpoint row, read rendered channels (caching row numbers), check for exact match or infer local affiliate
|
|
414
|
-
* 5. If binary search fails, linear scan from top to bottom as a universal fallback
|
|
415
|
-
* 6. Click the on-now program cell (`.LiveGuideProgram--first`) in the target channel's row to open the playback overlay
|
|
416
|
-
* 7. If playSelector is provided, wait for and click the play button to start live playback
|
|
417
|
-
* @param page - The Puppeteer page object.
|
|
418
|
-
* @param channelName - The channel name to match against data-testid attributes (case-insensitive).
|
|
419
|
-
* @param channelSelection - The channel selection configuration containing strategy, listSelector, and playSelector.
|
|
420
|
-
* @returns Result object with success status and optional failure reason.
|
|
421
|
-
*/
|
|
422
|
-
async function guideGridStrategy(page, channelName, channelSelection) {
|
|
423
|
-
const { listSelector, playSelector } = channelSelection;
|
|
424
|
-
// 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
|
|
425
|
-
// 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
|
|
426
|
-
// guide remains hidden and the virtualizer never populates rows. We use $eval for the click because overlapping elements (spinners, overlays) can intercept
|
|
427
|
-
// Puppeteer's coordinate-based mouse events.
|
|
428
|
-
if (listSelector) {
|
|
429
|
-
try {
|
|
430
|
-
await page.waitForSelector(listSelector, { timeout: CONFIG.streaming.videoTimeout, visible: true });
|
|
431
|
-
await page.$eval(listSelector, (el) => el.click());
|
|
432
|
-
// Brief delay for the tab switch animation and virtualizer initialization.
|
|
433
|
-
await delay(300);
|
|
434
|
-
}
|
|
435
|
-
catch (error) {
|
|
436
|
-
LOG.warn("Could not click channel list selector %s: %s.", listSelector, formatError(error));
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
// 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
|
|
440
|
-
// during a transitional state before the guide was fully interactive, or the guide may have been animating open.
|
|
441
|
-
let rowsVisible = false;
|
|
442
|
-
for (let guideAttempt = 0; guideAttempt < 2; guideAttempt++) {
|
|
443
|
-
try {
|
|
444
|
-
const rowTimeout = (guideAttempt === 0) ? 5000 : CONFIG.streaming.videoTimeout;
|
|
445
|
-
// eslint-disable-next-line no-await-in-loop
|
|
446
|
-
await page.waitForSelector("[data-testid=\"live-guide-row\"]", { timeout: rowTimeout, visible: true });
|
|
447
|
-
rowsVisible = true;
|
|
448
|
-
break;
|
|
449
|
-
}
|
|
450
|
-
catch {
|
|
451
|
-
// Rows not visible yet. On first failure, retry the tab click in case the guide wasn't fully interactive.
|
|
452
|
-
if ((guideAttempt === 0) && listSelector) {
|
|
453
|
-
LOG.debug("Guide rows not visible after initial wait. Retrying tab click for %s.", listSelector);
|
|
454
|
-
try {
|
|
455
|
-
// eslint-disable-next-line no-await-in-loop
|
|
456
|
-
await page.$eval(listSelector, (el) => el.click());
|
|
457
|
-
// eslint-disable-next-line no-await-in-loop
|
|
458
|
-
await delay(500);
|
|
459
|
-
}
|
|
460
|
-
catch {
|
|
461
|
-
// Retry click failed. Fall through to final wait attempt.
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
if (!rowsVisible) {
|
|
467
|
-
return { reason: "Channel grid rows did not render.", success: false };
|
|
468
|
-
}
|
|
469
|
-
// Each row in the virtualized grid is exactly 112px tall. The total number of channels is derived from the spacer div's height.
|
|
470
|
-
const ROW_HEIGHT = 112;
|
|
471
|
-
// Normalize the channel name to lowercase for case-insensitive matching against data-testid suffixes.
|
|
472
|
-
const normalizedName = normalizeChannelName(channelName);
|
|
473
|
-
// 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
|
|
474
|
-
// 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
|
|
475
|
-
// document-level offset, so that scrolling to gridDocTop + (rowIndex * ROW_HEIGHT) places that row at the top of the browser viewport.
|
|
476
|
-
const gridMeta = await page.evaluate((rowHeight) => {
|
|
477
|
-
const row = document.querySelector("[data-testid=\"live-guide-row\"]");
|
|
478
|
-
if (!row) {
|
|
479
|
-
return null;
|
|
480
|
-
}
|
|
481
|
-
// The spacer div is the parent of all row elements.
|
|
482
|
-
const spacer = row.parentElement;
|
|
483
|
-
if (!spacer) {
|
|
484
|
-
return null;
|
|
485
|
-
}
|
|
486
|
-
const spacerHeight = spacer.offsetHeight;
|
|
487
|
-
if (spacerHeight < rowHeight) {
|
|
488
|
-
return null;
|
|
489
|
-
}
|
|
490
|
-
// The viewport div is the spacer's parent. Its position relative to the document determines our scroll offset.
|
|
491
|
-
const viewport = spacer.parentElement;
|
|
492
|
-
if (!viewport) {
|
|
493
|
-
return null;
|
|
494
|
-
}
|
|
495
|
-
const gridDocTop = viewport.getBoundingClientRect().top + document.documentElement.scrollTop;
|
|
496
|
-
return { gridDocTop, totalRows: Math.round(spacerHeight / rowHeight) };
|
|
497
|
-
}, ROW_HEIGHT);
|
|
498
|
-
if (!gridMeta) {
|
|
499
|
-
return { reason: "Could not locate channel grid spacer element.", success: false };
|
|
500
|
-
}
|
|
501
|
-
const { gridDocTop, totalRows } = gridMeta;
|
|
502
|
-
// Helper: scroll to a specific row index and wait for the virtualizer to render.
|
|
503
|
-
const scrollToRow = async (rowIndex) => {
|
|
504
|
-
await page.evaluate((scrollTo) => {
|
|
505
|
-
document.documentElement.scrollTop = scrollTo;
|
|
506
|
-
}, gridDocTop + (rowIndex * ROW_HEIGHT));
|
|
507
|
-
await delay(200);
|
|
508
|
-
};
|
|
509
|
-
// 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.
|
|
510
|
-
let clickTarget = normalizedName;
|
|
511
|
-
// 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.
|
|
512
|
-
const cachedRow = guideRowCache.get(normalizedName);
|
|
513
|
-
if (cachedRow !== undefined) {
|
|
514
|
-
LOG.debug("Guide cache hit for %s at row %s.", channelName, cachedRow);
|
|
515
|
-
await scrollToRow(cachedRow);
|
|
516
|
-
// Read rendered channels to update the cache and confirm the channel is present.
|
|
517
|
-
const rendered = await readRenderedChannels(page);
|
|
518
|
-
if (rendered) {
|
|
519
|
-
const match = rendered.find((ch) => ch.name === normalizedName);
|
|
520
|
-
if (match) {
|
|
521
|
-
return await clickOnNowCellAndPlay(page, normalizedName, playSelector, channelName);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
// Cache hit but channel not found at expected position. The guide may have changed. Clear this entry and fall through to binary search.
|
|
525
|
-
LOG.debug("Guide cache miss for %s. Falling back to binary search.", channelName);
|
|
526
|
-
guideRowCache.delete(normalizedName);
|
|
527
|
-
}
|
|
528
|
-
// 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,
|
|
529
|
-
// 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
|
|
530
|
-
// 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.
|
|
531
|
-
let low = 0;
|
|
532
|
-
let high = totalRows - 1;
|
|
533
|
-
const maxIterations = 10;
|
|
534
|
-
let found = false;
|
|
535
|
-
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
536
|
-
if (low > high) {
|
|
537
|
-
break;
|
|
538
|
-
}
|
|
539
|
-
const mid = Math.floor((low + high) / 2);
|
|
540
|
-
// eslint-disable-next-line no-await-in-loop
|
|
541
|
-
await scrollToRow(mid);
|
|
542
|
-
// Read all rendered channels, populating the row number cache as a side effect.
|
|
543
|
-
// eslint-disable-next-line no-await-in-loop
|
|
544
|
-
const rendered = await readRenderedChannels(page);
|
|
545
|
-
if (!rendered || (rendered.length === 0)) {
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
// Check for an exact match first.
|
|
549
|
-
const exactMatch = rendered.find((ch) => ch.name === normalizedName);
|
|
550
|
-
if (exactMatch) {
|
|
551
|
-
found = true;
|
|
552
|
-
break;
|
|
553
|
-
}
|
|
554
|
-
// Determine binary search direction by comparing the target against the first and last rendered non-call-sign channel names. Call sign channels (W*/K*
|
|
555
|
-
// local affiliates) are excluded from direction comparison because they sort by hidden network name, not by their displayed call sign — using them for
|
|
556
|
-
// localeCompare would send the search the wrong way.
|
|
557
|
-
const nonCallSigns = rendered.filter((ch) => !CALL_SIGN_PATTERN.test(ch.name));
|
|
558
|
-
if (nonCallSigns.length === 0) {
|
|
559
|
-
// All rendered channels are call signs. Cannot determine direction. Move down and hope for better data.
|
|
560
|
-
low = mid + 1;
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
const first = nonCallSigns[0].name;
|
|
564
|
-
const last = nonCallSigns[nonCallSigns.length - 1].name;
|
|
565
|
-
if (normalizedName.localeCompare(first) < 0) {
|
|
566
|
-
// Target sorts before the first visible non-call-sign channel. Scroll up (toward lower row indices).
|
|
567
|
-
high = mid - 1;
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
|
-
if (normalizedName.localeCompare(last) > 0) {
|
|
571
|
-
// Target sorts after the last visible non-call-sign channel. Scroll down (toward higher row indices).
|
|
572
|
-
low = mid + 1;
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
// 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
|
|
576
|
-
// channel may be a local affiliate whose call sign doesn't match the network name we're searching for. Try position-based inference.
|
|
577
|
-
const inferred = inferLocalAffiliate(rendered, normalizedName);
|
|
578
|
-
if (inferred) {
|
|
579
|
-
LOG.info("Inferred local affiliate %s for network name %s.", inferred, channelName);
|
|
580
|
-
clickTarget = inferred;
|
|
581
|
-
found = true;
|
|
582
|
-
// Cache the network name → affiliate's row number so subsequent tunes for the same network name become direct scrolls.
|
|
583
|
-
const inferredRow = guideRowCache.get(inferred);
|
|
584
|
-
if (inferredRow !== undefined) {
|
|
585
|
-
guideRowCache.set(normalizedName, inferredRow);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
break;
|
|
589
|
-
}
|
|
590
|
-
// 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
|
|
591
|
-
// 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
|
|
592
|
-
// hidden network name but don't match the W/K call sign pattern.
|
|
593
|
-
if (!found) {
|
|
594
|
-
LOG.debug("Binary search did not find %s. Starting linear scan fallback.", channelName);
|
|
595
|
-
for (let row = 0; row < totalRows; row += 10) {
|
|
596
|
-
// eslint-disable-next-line no-await-in-loop
|
|
597
|
-
await scrollToRow(row);
|
|
598
|
-
// eslint-disable-next-line no-await-in-loop
|
|
599
|
-
const rendered = await readRenderedChannels(page);
|
|
600
|
-
if (!rendered) {
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
const match = rendered.find((ch) => ch.name === normalizedName);
|
|
604
|
-
if (match) {
|
|
605
|
-
found = true;
|
|
606
|
-
break;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
if (!found) {
|
|
611
|
-
return { reason: "Could not find channel " + channelName + " in guide grid.", success: false };
|
|
612
|
-
}
|
|
613
|
-
// Click the on-now program cell and wait for the play button, with click retries to handle React hydration timing.
|
|
614
|
-
return await clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName);
|
|
615
|
-
}
|
|
616
|
-
/**
|
|
617
|
-
* 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.
|
|
618
|
-
* @param page - The Puppeteer page object.
|
|
619
|
-
* @param playSelector - The CSS selector for the play button, or undefined if no play button is needed.
|
|
620
|
-
* @param timeout - Optional timeout in milliseconds for the play button to appear. Defaults to CONFIG.streaming.videoTimeout.
|
|
621
|
-
* @returns Result object with success status and optional failure reason.
|
|
622
|
-
*/
|
|
623
|
-
async function waitForPlayButton(page, playSelector, timeout) {
|
|
624
|
-
if (!playSelector) {
|
|
625
|
-
return { success: true };
|
|
626
|
-
}
|
|
627
|
-
try {
|
|
628
|
-
await page.waitForSelector(playSelector, { timeout: timeout ?? CONFIG.streaming.videoTimeout, visible: true });
|
|
629
|
-
// 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
|
|
630
|
-
// the channel selection state to the component's event handlers. Without this, clicking immediately can trigger playback of the previously-selected channel
|
|
631
|
-
// rather than the one we just chose. The double-rAF pattern synchronizes with the browser's rendering pipeline rather than using a fixed delay.
|
|
632
|
-
await page.evaluate(async () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
633
|
-
// 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
|
|
634
|
-
// rather than a bare DOM .click().
|
|
635
|
-
const playTarget = await evaluateWithAbort(page, (selector) => {
|
|
636
|
-
const el = document.querySelector(selector);
|
|
637
|
-
if (!el) {
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
|
641
|
-
const rect = el.getBoundingClientRect();
|
|
642
|
-
if ((rect.width > 0) && (rect.height > 0)) {
|
|
643
|
-
return { x: rect.x + (rect.width / 2), y: rect.y + (rect.height / 2) };
|
|
644
|
-
}
|
|
645
|
-
return null;
|
|
646
|
-
}, [playSelector]);
|
|
647
|
-
if (!playTarget) {
|
|
648
|
-
return { reason: "Play selector " + playSelector + " found but has no dimensions.", success: false };
|
|
649
|
-
}
|
|
650
|
-
await scrollAndClick(page, playTarget);
|
|
651
|
-
return { success: true };
|
|
652
|
-
}
|
|
653
|
-
catch (error) {
|
|
654
|
-
return { reason: "Could not click play selector " + playSelector + ": " + formatError(error) + ".", success: false };
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* 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
|
|
659
|
-
* 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
|
|
660
|
-
* on-now cell may not trigger the playback overlay). Retrying the click after a brief delay allows hydration to complete.
|
|
661
|
-
* @param page - The Puppeteer page object.
|
|
662
|
-
* @param clickTarget - The lowercased, trimmed channel name to locate in the guide grid.
|
|
663
|
-
* @param playSelector - The CSS selector for the play button, or undefined if no play button is needed.
|
|
664
|
-
* @param channelName - The original channel name for logging.
|
|
665
|
-
* @returns Result object with success status and optional failure reason.
|
|
666
|
-
*/
|
|
667
|
-
async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName) {
|
|
668
|
-
// 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.
|
|
669
|
-
const MAX_CLICK_ATTEMPTS = 3;
|
|
670
|
-
// Shorter timeout for the play button on non-final attempts. Long enough for the overlay to appear if the click registered, but short enough to retry quickly
|
|
671
|
-
// rather than waiting the full videoTimeout on each attempt.
|
|
672
|
-
const RETRY_PLAY_TIMEOUT = 3000;
|
|
673
|
-
// Delay between click retries. Gives React additional time to finish hydrating event handlers.
|
|
674
|
-
const CLICK_RETRY_DELAY = 1500;
|
|
675
|
-
for (let attempt = 0; attempt < MAX_CLICK_ATTEMPTS; attempt++) {
|
|
676
|
-
// eslint-disable-next-line no-await-in-loop
|
|
677
|
-
const onNowTarget = await locateOnNowCell(page, clickTarget);
|
|
678
|
-
if (!onNowTarget) {
|
|
679
|
-
return { reason: "Found channel " + channelName + " but could not locate on-now program cell.", success: false };
|
|
680
|
-
}
|
|
681
|
-
// eslint-disable-next-line no-await-in-loop
|
|
682
|
-
await scrollAndClick(page, onNowTarget);
|
|
683
|
-
// Use a shorter timeout on non-final attempts to enable quick retries. The final attempt uses the full default timeout as a last chance.
|
|
684
|
-
const playTimeout = (attempt < MAX_CLICK_ATTEMPTS - 1) ? RETRY_PLAY_TIMEOUT : undefined;
|
|
685
|
-
// eslint-disable-next-line no-await-in-loop
|
|
686
|
-
const result = await waitForPlayButton(page, playSelector, playTimeout);
|
|
687
|
-
if (result.success) {
|
|
688
|
-
return result;
|
|
689
|
-
}
|
|
690
|
-
// Play button didn't appear — the click likely didn't register. Retry after a brief delay.
|
|
691
|
-
if (attempt < MAX_CLICK_ATTEMPTS - 1) {
|
|
692
|
-
LOG.debug("Play button did not appear for %s. Retrying on-now cell click (attempt %s of %s).", channelName, attempt + 2, MAX_CLICK_ATTEMPTS);
|
|
693
|
-
// eslint-disable-next-line no-await-in-loop
|
|
694
|
-
await delay(CLICK_RETRY_DELAY);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
return { reason: "Play button did not appear after " + MAX_CLICK_ATTEMPTS + " on-now cell click attempts for " + channelName + ".", success: false };
|
|
698
|
-
}
|
|
699
|
-
/* The selectChannel() function is the public API for channel selection. It delegates to the appropriate strategy based on the profile configuration.
|
|
700
|
-
*/
|
|
701
56
|
/**
|
|
702
57
|
* 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
|
|
703
58
|
* tuneToChannel() after page navigation.
|
|
@@ -712,48 +67,37 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
|
|
|
712
67
|
* @returns Result object with success status and optional failure reason.
|
|
713
68
|
*/
|
|
714
69
|
export async function selectChannel(page, profile) {
|
|
715
|
-
const { channelSelection
|
|
70
|
+
const { channelSelection } = profile;
|
|
716
71
|
// No channel selection needed if strategy is "none" or no channelSelector is specified.
|
|
717
|
-
if ((channelSelection.strategy === "none") || !
|
|
72
|
+
if ((channelSelection.strategy === "none") || !isChannelSelectionProfile(profile)) {
|
|
718
73
|
return { success: true };
|
|
719
74
|
}
|
|
720
75
|
// 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
|
|
721
76
|
// actually rendered before proceeding. This prevents race conditions where the img element exists with the correct src but the browser hasn't finished fetching
|
|
722
|
-
// and rendering it, which can cause layout instability and click failures. We skip this polling for
|
|
723
|
-
//
|
|
724
|
-
|
|
77
|
+
// 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
|
|
78
|
+
// URL slug), guideGrid (channel list images are hidden behind a tab), hboGrid (channelSelector is a channel name, not an image URL slug), slingGrid (same
|
|
79
|
+
// reason as hboGrid), and youtubeGrid (same reason as hboGrid).
|
|
80
|
+
const skipImagePolling = ["foxGrid", "guideGrid", "hboGrid", "slingGrid", "youtubeGrid"];
|
|
81
|
+
if (!skipImagePolling.includes(channelSelection.strategy)) {
|
|
725
82
|
try {
|
|
726
83
|
await page.waitForFunction((slug) => {
|
|
727
84
|
return Array.from(document.querySelectorAll("img")).some((img) => img.src && img.src.includes(slug) && img.complete && (img.naturalWidth > 0));
|
|
728
|
-
}, { timeout: CONFIG.playback.channelSelectorDelay }, channelSelector);
|
|
85
|
+
}, { timeout: CONFIG.playback.channelSelectorDelay }, profile.channelSelector);
|
|
729
86
|
}
|
|
730
87
|
catch {
|
|
731
88
|
// Timeout — the image hasn't loaded yet. Proceed anyway and let the strategy evaluate and report not-found naturally.
|
|
732
89
|
}
|
|
733
90
|
}
|
|
734
|
-
// Dispatch to the appropriate strategy.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
741
|
-
case "thumbnailRow": {
|
|
742
|
-
result = await thumbnailRowStrategy(page, channelSelector);
|
|
743
|
-
break;
|
|
744
|
-
}
|
|
745
|
-
case "tileClick": {
|
|
746
|
-
result = await tileClickStrategy(page, channelSelector);
|
|
747
|
-
break;
|
|
748
|
-
}
|
|
749
|
-
default: {
|
|
750
|
-
// Unknown strategy - this shouldn't happen if profiles are validated, but handle gracefully.
|
|
751
|
-
LOG.warn("Unknown channel selection strategy: %s.", channelSelection.strategy);
|
|
752
|
-
return { reason: "Unknown channel selection strategy.", success: false };
|
|
753
|
-
}
|
|
91
|
+
// Dispatch to the appropriate strategy via the registry.
|
|
92
|
+
const strategyFn = strategies[channelSelection.strategy];
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
94
|
+
if (!strategyFn) {
|
|
95
|
+
LOG.warn("Unknown channel selection strategy: %s.", channelSelection.strategy);
|
|
96
|
+
return { reason: "Unknown channel selection strategy.", success: false };
|
|
754
97
|
}
|
|
98
|
+
const result = await strategyFn(page, profile);
|
|
755
99
|
if (!result.success) {
|
|
756
|
-
LOG.warn("Failed to select %s from channel guide: %s", channelSelector, result.reason ?? "Unknown reason.");
|
|
100
|
+
LOG.warn("Failed to select %s from channel guide: %s", profile.channelSelector, result.reason ?? "Unknown reason.");
|
|
757
101
|
}
|
|
758
102
|
return result;
|
|
759
103
|
}
|