prismcast 1.4.2 → 1.5.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/dist/app.js +18 -1
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +2 -1
- package/dist/browser/cdp.js +44 -23
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +45 -1
- package/dist/browser/channelSelection.js +164 -27
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +0 -4
- package/dist/browser/display.js +0 -7
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +5 -0
- package/dist/browser/index.js +14 -0
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/precaching.d.ts +5 -0
- package/dist/browser/precaching.js +112 -0
- package/dist/browser/precaching.js.map +1 -0
- package/dist/browser/tuning/directv.d.ts +2 -0
- package/dist/browser/tuning/directv.js +723 -0
- package/dist/browser/tuning/directv.js.map +1 -0
- package/dist/browser/tuning/fox.d.ts +2 -2
- package/dist/browser/tuning/fox.js +141 -22
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.d.ts +2 -2
- package/dist/browser/tuning/hbo.js +110 -41
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.d.ts +2 -2
- package/dist/browser/tuning/hulu.js +442 -168
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.d.ts +2 -2
- package/dist/browser/tuning/sling.js +200 -56
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.d.ts +2 -2
- package/dist/browser/tuning/youtubeTv.js +243 -57
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +1 -1
- package/dist/browser/video.js +4 -2
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.js +280 -34
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +78 -0
- package/dist/config/health.js +202 -0
- package/dist/config/health.js.map +1 -0
- package/dist/config/paths.d.ts +10 -0
- package/dist/config/paths.js +14 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/profiles.d.ts +6 -4
- package/dist/config/profiles.js +65 -14
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerPacks.d.ts +46 -0
- package/dist/config/providerPacks.js +200 -0
- package/dist/config/providerPacks.js.map +1 -0
- package/dist/config/providers.d.ts +36 -3
- package/dist/config/providers.js +168 -8
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +1 -12
- package/dist/config/sites.js +38 -5
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +12 -0
- package/dist/config/userChannels.js +81 -14
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +5 -1
- package/dist/config/userConfig.js +58 -6
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +81 -0
- package/dist/config/userProfiles.js +424 -0
- package/dist/config/userProfiles.js.map +1 -0
- package/dist/routes/config/channels/index.d.ts +3 -0
- package/dist/routes/config/channels/index.js +3 -0
- package/dist/routes/config/channels/index.js.map +1 -0
- package/dist/routes/config/channels/routes.d.ts +6 -0
- package/dist/routes/config/channels/routes.js +797 -0
- package/dist/routes/config/channels/routes.js.map +1 -0
- package/dist/routes/config/channels/table.d.ts +46 -0
- package/dist/routes/config/channels/table.js +811 -0
- package/dist/routes/config/channels/table.js.map +1 -0
- package/dist/routes/config/index.d.ts +37 -0
- package/dist/routes/config/index.js +81 -0
- package/dist/routes/config/index.js.map +1 -0
- package/dist/routes/config/providers.d.ts +18 -0
- package/dist/routes/config/providers.js +573 -0
- package/dist/routes/config/providers.js.map +1 -0
- package/dist/routes/config/settings.d.ts +39 -0
- package/dist/routes/config/settings.js +820 -0
- package/dist/routes/config/settings.js.map +1 -0
- package/dist/routes/index.d.ts +3 -2
- package/dist/routes/index.js +7 -4
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/playlist.d.ts +5 -1
- package/dist/routes/playlist.js +32 -7
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/providers.d.ts +6 -0
- package/dist/routes/providers.js +166 -0
- package/dist/routes/providers.js.map +1 -0
- package/dist/routes/root/content.d.ts +32 -0
- package/dist/routes/root/content.js +857 -0
- package/dist/routes/root/content.js.map +1 -0
- package/dist/routes/root/index.js +197 -0
- package/dist/routes/root/index.js.map +1 -0
- package/dist/routes/root/scripts/channels.d.ts +1 -0
- package/dist/routes/root/scripts/channels.js +745 -0
- package/dist/routes/root/scripts/channels.js.map +1 -0
- package/dist/routes/root/scripts/config.d.ts +1 -0
- package/dist/routes/root/scripts/config.js +1662 -0
- package/dist/routes/root/scripts/config.js.map +1 -0
- package/dist/routes/root/scripts/index.d.ts +3 -0
- package/dist/routes/root/scripts/index.js +8 -0
- package/dist/routes/root/scripts/index.js.map +1 -0
- package/dist/routes/root/scripts/status.d.ts +1 -0
- package/dist/routes/root/scripts/status.js +436 -0
- package/dist/routes/root/scripts/status.js.map +1 -0
- package/dist/routes/root/styles.d.ts +5 -0
- package/dist/routes/root/styles.js +447 -0
- package/dist/routes/root/styles.js.map +1 -0
- package/dist/routes/ui.js +8 -3
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.d.ts +5 -3
- package/dist/service/commands.js +117 -33
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +27 -0
- package/dist/service/generators.js +97 -6
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/hls.js +18 -1
- package/dist/streaming/hls.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 -38
- package/dist/streaming/monitor.js +2 -289
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/recovery.d.ts +143 -0
- package/dist/streaming/recovery.js +286 -0
- package/dist/streaming/recovery.js.map +1 -0
- package/dist/streaming/registry.d.ts +1 -1
- package/dist/streaming/setup.d.ts +1 -1
- package/dist/streaming/setup.js +3 -1
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -2
- package/dist/streaming/statusEmitter.js +5 -0
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +68 -0
- package/dist/types/channels.js +2 -0
- package/dist/types/channels.js.map +1 -0
- package/dist/types/config.d.ts +127 -0
- package/dist/types/config.js +2 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/index.d.ts +7 -429
- package/dist/types/index.js +1 -5
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +141 -0
- package/dist/types/profiles.js +2 -0
- package/dist/types/profiles.js.map +1 -0
- package/dist/types/selection.d.ts +101 -0
- package/dist/types/selection.js +6 -0
- package/dist/types/selection.js.map +1 -0
- package/dist/types/shared.d.ts +7 -0
- package/dist/types/shared.js +6 -0
- package/dist/types/shared.js.map +1 -0
- package/dist/types/streaming.d.ts +96 -0
- package/dist/types/streaming.js +2 -0
- package/dist/types/streaming.js.map +1 -0
- package/dist/utils/debugFilter.js +3 -1
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/format.d.ts +7 -0
- package/dist/utils/format.js +22 -0
- package/dist/utils/format.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/sanitize.d.ts +14 -0
- package/dist/utils/sanitize.js +37 -0
- package/dist/utils/sanitize.js.map +1 -0
- package/package.json +5 -3
- package/dist/routes/config.d.ts +0 -76
- package/dist/routes/config.js +0 -2187
- package/dist/routes/config.js.map +0 -1
- package/dist/routes/root.js +0 -3158
- package/dist/routes/root.js.map +0 -1
- /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { LOG, delay, formatError } from "../../utils/index.js";
|
|
2
|
+
import { logAvailableChannels, normalizeChannelName } from "../channelSelection.js";
|
|
3
|
+
import { CONFIG } from "../../config/index.js";
|
|
4
|
+
// Unified channel cache for DirecTV Stream. Maps normalized channel names to their combined discovery and tuning data. Populated by the webpack interceptor's
|
|
5
|
+
// Redux store extraction, emitted as a [DIRECTV-CHANNELS] console signal and parsed by the console listener. Cleared on browser disconnect via clearDirectvCache().
|
|
6
|
+
const directvChannelCache = new Map();
|
|
7
|
+
// Set to true after a complete discovery confirms the channel cache contains the full channel lineup. Individual tunes only populate the cache from the Redux store
|
|
8
|
+
// extraction during that tune's page load, but the store contains the full lineup, so a single successful extraction fully enumerates. Without this flag,
|
|
9
|
+
// getCachedChannels() would return null before the first successful extraction.
|
|
10
|
+
let directvFullyDiscovered = false;
|
|
11
|
+
// Tracks which pages have console listeners registered to avoid duplicate registrations.
|
|
12
|
+
const directvPagesWithListeners = new WeakSet();
|
|
13
|
+
const pendingTunes = new WeakMap();
|
|
14
|
+
// DirecTV guide URL. All tunes navigate here — the webpack interceptor handles channel selection during page load.
|
|
15
|
+
const DIRECTV_GUIDE_URL = "https://stream.directv.com/guide";
|
|
16
|
+
// Maximum time to wait for the webpack interceptor to emit a tune result. This covers the time from page navigation start through webpack chunk interception,
|
|
17
|
+
// React fiber tree traversal, Redux store discovery, channel matching, and playConsumable dispatch. Validated tunes complete in 2-5 seconds; eight seconds
|
|
18
|
+
// provides headroom for slow page loads while failing fast enough that logo click fallback isn't delayed excessively.
|
|
19
|
+
const TUNE_TIMEOUT = 8000;
|
|
20
|
+
// Maximum time to wait for the [DIRECTV-CHANNELS] console signal during discovery. The Redux store extraction polls for the React mount point with a 10-second
|
|
21
|
+
// overall timeout and a 5-second milestone timeout after DOM content appears. This Node-side timeout should exceed the browser-side timeout to avoid race
|
|
22
|
+
// conditions where the browser script succeeds but the console signal hasn't been processed yet.
|
|
23
|
+
const DISCOVERY_TIMEOUT = 15000;
|
|
24
|
+
/**
|
|
25
|
+
* Clears all DirecTV state: the unified channel cache and the fully-discovered flag. Called by clearChannelSelectionCaches() in the coordinator when the browser
|
|
26
|
+
* restarts, since cached state may be stale in a new browser session. The pendingTunes WeakMap is self-cleaning — entries are GC'd when their Page references are
|
|
27
|
+
* released after page close.
|
|
28
|
+
*/
|
|
29
|
+
function clearDirectvCache() {
|
|
30
|
+
directvChannelCache.clear();
|
|
31
|
+
directvFullyDiscovered = false;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Processes the channel lineup JSON emitted by the webpack interceptor's [DIRECTV-CHANNELS] console signal. Parses the JSON array and populates the unified
|
|
35
|
+
* channel cache. Each entry contains channelId, channelName, callSign, and resourceId from the Redux store's channel lineup.
|
|
36
|
+
* @param json - The JSON string emitted by the console signal.
|
|
37
|
+
*/
|
|
38
|
+
function processChannelLineup(json) {
|
|
39
|
+
let channels;
|
|
40
|
+
try {
|
|
41
|
+
channels = JSON.parse(json);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
LOG.debug("tuning:directv", "Failed to parse [DIRECTV-CHANNELS] JSON.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(channels) || (channels.length === 0)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let count = 0;
|
|
51
|
+
for (const ch of channels) {
|
|
52
|
+
if (ch.channelName && ch.channelId && ch.resourceId) {
|
|
53
|
+
const normalized = normalizeChannelName(ch.channelName);
|
|
54
|
+
directvChannelCache.set(normalized, {
|
|
55
|
+
callSign: ch.callSign ?? "",
|
|
56
|
+
channelId: ch.channelId,
|
|
57
|
+
displayName: ch.channelName,
|
|
58
|
+
resourceId: ch.resourceId
|
|
59
|
+
});
|
|
60
|
+
count++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (count > 0) {
|
|
64
|
+
directvFullyDiscovered = true;
|
|
65
|
+
LOG.debug("tuning:directv", "Channel lineup populated: %s channels.", count);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Sets up console listeners on the page to bridge in-page console signals from the evaluateOnNewDocument interceptor to the Node.js process. Handles four signal
|
|
70
|
+
* types: [DIRECTV-CHANNELS] for channel lineup data, [DIRECTV-TUNE-OK] and [DIRECTV-TUNE-FAIL] for tune result signaling, and [DIRECTV-DIAG] for diagnostic
|
|
71
|
+
* logging. Uses a WeakSet to prevent duplicate listener registration on the same page.
|
|
72
|
+
* @param page - The Puppeteer page object.
|
|
73
|
+
*/
|
|
74
|
+
function setupConsoleListeners(page) {
|
|
75
|
+
if (directvPagesWithListeners.has(page)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
directvPagesWithListeners.add(page);
|
|
79
|
+
page.on("console", (msg) => {
|
|
80
|
+
const text = msg.text();
|
|
81
|
+
// Channel lineup data emitted by the webpack interceptor's Redux store extraction.
|
|
82
|
+
if (text.startsWith("[DIRECTV-CHANNELS] ")) {
|
|
83
|
+
processChannelLineup(text.slice("[DIRECTV-CHANNELS] ".length));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Tune success signal — the playConsumable dispatch settled successfully (synchronous action or async thunk resolution).
|
|
87
|
+
if (text.startsWith("[DIRECTV-TUNE-OK]")) {
|
|
88
|
+
pendingTunes.get(page)?.resolve(true);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Tune failure signal — the playConsumable dispatch failed or the target channel was not found in the Redux store.
|
|
92
|
+
if (text.startsWith("[DIRECTV-TUNE-FAIL]")) {
|
|
93
|
+
LOG.debug("tuning:directv", "Tune failure signal: %s.", text);
|
|
94
|
+
pendingTunes.get(page)?.resolve(false);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Diagnostic messages bridged to the debug log for troubleshooting the webpack interceptor.
|
|
98
|
+
if (text.startsWith("[DIRECTV-DIAG]")) {
|
|
99
|
+
LOG.debug("tuning:directv", text);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Installs the evaluateOnNewDocument script that intercepts webpack chunk loading to capture __webpack_require__, extracts the Redux store from the React fiber
|
|
105
|
+
* tree, emits the channel lineup, and optionally dispatches playConsumable to tune to a specific channel. The script runs before any SPA JavaScript and is
|
|
106
|
+
* entirely self-contained (no Node.js references).
|
|
107
|
+
*
|
|
108
|
+
* The interceptor has four phases:
|
|
109
|
+
* 1. Main-frame guard (skip ad iframes)
|
|
110
|
+
* 2. Wrap webpackChunk push to capture __webpack_require__ from natural chunk callbacks (defense-in-depth for future webpack builds)
|
|
111
|
+
* 3. Poll for React fiber root, BFS for Redux store, extract channel lineup, emit [DIRECTV-CHANNELS]
|
|
112
|
+
* 4. If not discoverOnly: capture __webpack_require__ via synthetic chunk push if needed, find playConsumable module, match channel, dispatch tune
|
|
113
|
+
*
|
|
114
|
+
* @param page - The Puppeteer page object.
|
|
115
|
+
* @param channelName - The target channel name (normalized) for tuning. Ignored when discoverOnly is true.
|
|
116
|
+
* @param discoverOnly - When true, skip the tune phase and only extract the channel lineup.
|
|
117
|
+
*/
|
|
118
|
+
async function installDirectTuneInterceptor(page, channelName, discoverOnly) {
|
|
119
|
+
await page.evaluateOnNewDocument((targetName, discoverOnlyFlag) => {
|
|
120
|
+
// Phase 1: Main-frame guard. The evaluateOnNewDocument script runs in every frame, including ad iframes. We only want to intercept webpack chunks in the main
|
|
121
|
+
// frame where the DirecTV SPA loads.
|
|
122
|
+
try {
|
|
123
|
+
if (window.self !== window.top) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// eslint-disable-next-line no-console
|
|
131
|
+
console.log("[DIRECTV-DIAG] Interceptor installed (target=" + targetName + ", discoverOnly=" + String(discoverOnlyFlag) + ").");
|
|
132
|
+
let wreq = null;
|
|
133
|
+
// Phase 2: Wrap webpackChunk push to capture __webpack_require__. This wrapper intercepts natural chunk pushes and captures __webpack_require__ from any chunk
|
|
134
|
+
// that includes an entry point callback (the optional third element). DirecTV's current webpack build uses 2-element chunks without callbacks, so this wrapper
|
|
135
|
+
// serves as defense-in-depth — the primary capture mechanism is the synthetic chunk push in Phase 4. If a future DirecTV deploy changes the chunk format, this
|
|
136
|
+
// wrapper captures wreq early without needing a code change.
|
|
137
|
+
const chunkArrayName = "webpackChunk_directv_web";
|
|
138
|
+
// Ensure the chunk array exists before wrapping.
|
|
139
|
+
const win = window;
|
|
140
|
+
win[chunkArrayName] ??= [];
|
|
141
|
+
const chunkArray = win[chunkArrayName];
|
|
142
|
+
const originalPush = chunkArray.push.bind(chunkArray);
|
|
143
|
+
chunkArray.push = function (...args) {
|
|
144
|
+
// Each chunk is an array: [ [chunkIds], { moduleId: moduleFactory }, executeCallback ]. The third element, when present, is a function that receives
|
|
145
|
+
// __webpack_require__ as its argument. We intercept the first one that has a callback.
|
|
146
|
+
for (const chunk of args) {
|
|
147
|
+
if (!Array.isArray(chunk) || (chunk.length < 3)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const callback = chunk[2];
|
|
151
|
+
if ((typeof callback === "function") && !wreq) {
|
|
152
|
+
// Replace the callback with our wrapper that captures __webpack_require__ before calling the original.
|
|
153
|
+
chunk[2] = (wreqFn) => {
|
|
154
|
+
wreq = wreqFn;
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.log("[DIRECTV-DIAG] Captured __webpack_require__ (" + String(Object.keys(wreqFn.m).length) + " modules).");
|
|
157
|
+
callback(wreqFn);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return originalPush(...args);
|
|
162
|
+
};
|
|
163
|
+
// Phase 3: Poll for React fiber root, extract Redux store and channel lineup. React mounts asynchronously after webpack chunks load, so we poll the DOM for
|
|
164
|
+
// the fiber root at 200ms intervals. Once found, BFS through the fiber tree to locate pendingProps.store (the Redux store), then extract the channel lineup
|
|
165
|
+
// from the store's state.
|
|
166
|
+
const POLL_INTERVAL = 200;
|
|
167
|
+
const MAX_POLL_TIME = 10000;
|
|
168
|
+
const pollStart = Date.now();
|
|
169
|
+
// React property prefixes to search for on candidate mount elements. Covers React 18 createRoot (__reactContainer$), React 17-18 individual fibers
|
|
170
|
+
// (__reactFiber$), and React 16 legacy (__reactInternalInstance$). Defined once and shared across poll iterations.
|
|
171
|
+
const REACT_PREFIXES = ["__reactContainer$", "__reactFiber$", "__reactInternalInstance$"];
|
|
172
|
+
// Finds the first React property key on an element, or undefined if no React properties exist.
|
|
173
|
+
const findReactKey = (el) => Object.keys(el).find((k) => REACT_PREFIXES.some((p) => k.startsWith(p)));
|
|
174
|
+
// One-shot diagnostic flags for the poll loop. Prevent flooding the console when the poll retries repeatedly.
|
|
175
|
+
let mountPointLogged = false;
|
|
176
|
+
let bfsExhaustedLogged = false;
|
|
177
|
+
// Tracks when the page body first has children (DOM content loaded). Used for milestone-based early exit — if the page has rendered content but no React
|
|
178
|
+
// mount point exists after MOUNT_SEARCH_TIMEOUT, the SPA structure is different from what we expect and continuing to poll is pointless.
|
|
179
|
+
let pageContentTime = 0;
|
|
180
|
+
const MOUNT_SEARCH_TIMEOUT = 5000;
|
|
181
|
+
let cachedStore = null;
|
|
182
|
+
// One-shot flag for the "channels not yet populated" diagnostic. The SPA fetches the channel lineup asynchronously after the Redux store is created, so
|
|
183
|
+
// several poll iterations may pass before the data arrives.
|
|
184
|
+
let channelsEmptyLogged = false;
|
|
185
|
+
const pollTimer = setInterval(() => {
|
|
186
|
+
if ((Date.now() - pollStart) > MAX_POLL_TIME) {
|
|
187
|
+
clearInterval(pollTimer);
|
|
188
|
+
// eslint-disable-next-line no-console
|
|
189
|
+
console.log("[DIRECTV-DIAG] Overall poll timeout (" + String(MAX_POLL_TIME) + "ms) — " +
|
|
190
|
+
(cachedStore ? "Redux store found but channel lineup never populated." : "Redux store not found."));
|
|
191
|
+
if (!discoverOnlyFlag) {
|
|
192
|
+
// eslint-disable-next-line no-console
|
|
193
|
+
console.log("[DIRECTV-TUNE-FAIL] Timed out — " +
|
|
194
|
+
(cachedStore ? "channel lineup never populated in Redux store." : "React mount point or Redux store not found."));
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Phase 3a: Find the React mount point and Redux store (skipped once cached). The store is found early (~400ms) but the channel lineup is populated
|
|
199
|
+
// asynchronously by the SPA's API calls. We cache the store reference and continue polling for channel data in Phase 3b below.
|
|
200
|
+
if (!cachedStore) {
|
|
201
|
+
// Find the React mount point. Try the known DirecTV ID first (fast path), then scan body's direct children for any element with React fiber
|
|
202
|
+
// properties. This resilient approach survives element ID changes — if DirecTV renames their root container, the body scan finds it automatically.
|
|
203
|
+
let mountEl = document.getElementById("app-root");
|
|
204
|
+
let reactKey = mountEl ? findReactKey(mountEl) : undefined;
|
|
205
|
+
if (!reactKey) {
|
|
206
|
+
mountEl = null;
|
|
207
|
+
for (const child of Array.from(document.body.children)) {
|
|
208
|
+
const key = findReactKey(child);
|
|
209
|
+
if (key) {
|
|
210
|
+
mountEl = child;
|
|
211
|
+
reactKey = key;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!mountEl || !reactKey) {
|
|
217
|
+
// Track when the page first has DOM content. Once content exists but no React mount is found, start the milestone countdown.
|
|
218
|
+
if (!pageContentTime && (document.body.children.length > 0)) {
|
|
219
|
+
pageContentTime = Date.now();
|
|
220
|
+
}
|
|
221
|
+
if (pageContentTime && ((Date.now() - pageContentTime) > MOUNT_SEARCH_TIMEOUT)) {
|
|
222
|
+
clearInterval(pollTimer);
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.log("[DIRECTV-DIAG] Page has DOM content but no React mount point found after " + String(MOUNT_SEARCH_TIMEOUT) + "ms.");
|
|
225
|
+
if (!discoverOnlyFlag) {
|
|
226
|
+
// eslint-disable-next-line no-console
|
|
227
|
+
console.log("[DIRECTV-TUNE-FAIL] No React mount point found.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Log the discovered mount point once. The tag name and ID help diagnose future element ID changes without requiring code changes.
|
|
233
|
+
if (!mountPointLogged) {
|
|
234
|
+
mountPointLogged = true;
|
|
235
|
+
// eslint-disable-next-line no-console
|
|
236
|
+
console.log("[DIRECTV-DIAG] React mount: <" + mountEl.tagName.toLowerCase() + " id=\"" + (mountEl.id || "") + "\"> via " +
|
|
237
|
+
reactKey.split("$")[0] + "$.");
|
|
238
|
+
}
|
|
239
|
+
// Get the fiber root from the React property. __reactContainer$ returns an internal root object whose .current property is the actual fiber node.
|
|
240
|
+
// __reactFiber$ returns the fiber directly.
|
|
241
|
+
const value = mountEl[reactKey];
|
|
242
|
+
const fiberRoot = (value && (typeof value.current === "object")) ? value.current : value ?? null;
|
|
243
|
+
if (!fiberRoot) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// BFS through the fiber tree to find the Redux store. The store is attached as pendingProps.store on a Provider fiber node.
|
|
247
|
+
const queue = [fiberRoot];
|
|
248
|
+
let store = null;
|
|
249
|
+
let visited = 0;
|
|
250
|
+
const MAX_NODES = 5000;
|
|
251
|
+
while ((queue.length > 0) && (visited < MAX_NODES)) {
|
|
252
|
+
const node = queue.shift();
|
|
253
|
+
if (!node) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
visited++;
|
|
257
|
+
// Check for Redux store on pendingProps. React-Redux v7 attaches directly as pendingProps.store. React-Redux v8 uses React context, placing the
|
|
258
|
+
// store at pendingProps.value.store on the context Provider fiber node.
|
|
259
|
+
const props = node.pendingProps;
|
|
260
|
+
const candidate = (props?.store ?? props?.value?.store);
|
|
261
|
+
if (candidate && (typeof candidate.dispatch === "function") && (typeof candidate.getState === "function")) {
|
|
262
|
+
store = candidate;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
// Traverse fiber tree: child and sibling links.
|
|
266
|
+
if (node.child) {
|
|
267
|
+
queue.push(node.child);
|
|
268
|
+
}
|
|
269
|
+
if (node.sibling) {
|
|
270
|
+
queue.push(node.sibling);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!store) {
|
|
274
|
+
if (!bfsExhaustedLogged) {
|
|
275
|
+
bfsExhaustedLogged = true;
|
|
276
|
+
// eslint-disable-next-line no-console
|
|
277
|
+
console.log("[DIRECTV-DIAG] Fiber root found but BFS visited " + String(visited) + " nodes without finding Redux store. Continuing to poll.");
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// Cache the store reference for subsequent poll iterations. The store object is stable — Redux stores are never recreated.
|
|
282
|
+
cachedStore = store;
|
|
283
|
+
// eslint-disable-next-line no-console
|
|
284
|
+
console.log("[DIRECTV-DIAG] Found Redux store after " + String(Date.now() - pollStart) + "ms (" + String(visited) + " nodes visited).");
|
|
285
|
+
}
|
|
286
|
+
// Phase 3b: Extract the channel lineup from the Redux store state. The store is found early (~400ms after page load) but the SPA fetches channel data
|
|
287
|
+
// asynchronously. We poll getState() each interval until the lineup appears. The validated path is state.channels.channelArrays (a 151-element array).
|
|
288
|
+
// Fallback paths cover plausible restructurings in future app versions.
|
|
289
|
+
const state = cachedStore.getState();
|
|
290
|
+
let channels = [];
|
|
291
|
+
if (state.channels) {
|
|
292
|
+
const channelsState = state.channels;
|
|
293
|
+
// Primary path: channelArrays is the validated location for the channel lineup array.
|
|
294
|
+
if (Array.isArray(channelsState.channelArrays)) {
|
|
295
|
+
channels = channelsState.channelArrays;
|
|
296
|
+
}
|
|
297
|
+
else if (Array.isArray(channelsState.lineup)) {
|
|
298
|
+
channels = channelsState.lineup;
|
|
299
|
+
}
|
|
300
|
+
else if (Array.isArray(channelsState.channels)) {
|
|
301
|
+
channels = channelsState.channels;
|
|
302
|
+
}
|
|
303
|
+
else if (Array.isArray(channelsState.allChannels)) {
|
|
304
|
+
channels = channelsState.allChannels;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Fallback: some app versions may use a dedicated channelLineup reducer.
|
|
308
|
+
if ((channels.length === 0) && state.channelLineup) {
|
|
309
|
+
const lineup = state.channelLineup;
|
|
310
|
+
if (Array.isArray(lineup.channelArrays)) {
|
|
311
|
+
channels = lineup.channelArrays;
|
|
312
|
+
}
|
|
313
|
+
else if (Array.isArray(lineup.channels)) {
|
|
314
|
+
channels = lineup.channels;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (channels.length === 0) {
|
|
318
|
+
// The channel data hasn't arrived yet — the SPA is still fetching from DirecTV's API. Log once and continue polling.
|
|
319
|
+
if (!channelsEmptyLogged) {
|
|
320
|
+
channelsEmptyLogged = true;
|
|
321
|
+
// eslint-disable-next-line no-console
|
|
322
|
+
console.log("[DIRECTV-DIAG] Redux store found but channel lineup not yet populated. Waiting for async data fetch.");
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Channel data found — stop polling and proceed with emission and (optionally) tuning.
|
|
327
|
+
clearInterval(pollTimer);
|
|
328
|
+
// eslint-disable-next-line no-console
|
|
329
|
+
console.log("[DIRECTV-DIAG] Channel lineup populated: " + String(channels.length) + " channels (" + String(Date.now() - pollStart) + "ms total).");
|
|
330
|
+
// Emit the channel lineup as a JSON array via console signal. The Node-side console listener parses this and populates the unified channel cache.
|
|
331
|
+
const lineupData = channels.map((ch) => ({
|
|
332
|
+
callSign: ch.callSign ?? "",
|
|
333
|
+
channelId: ch.ccid ?? "",
|
|
334
|
+
channelName: ch.channelName ?? "",
|
|
335
|
+
resourceId: ch.resourceId ?? ""
|
|
336
|
+
})).filter((ch) => ch.channelName && ch.channelId);
|
|
337
|
+
// eslint-disable-next-line no-console
|
|
338
|
+
console.log("[DIRECTV-CHANNELS] " + JSON.stringify(lineupData));
|
|
339
|
+
// Phase 4: If not discovery-only, find and call playConsumable to tune to the target channel.
|
|
340
|
+
if (discoverOnlyFlag) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// If wreq wasn't captured naturally from chunk callbacks (DirecTV's current webpack build uses 2-element chunks without entry point callbacks), use a
|
|
344
|
+
// synthetic chunk push to capture __webpack_require__. By this point webpack has fully initialized — it replaced the chunk array's push method with its own
|
|
345
|
+
// webpackJsonpCallback handler during bootstrap. Pushing a synthetic chunk with an entry point callback (third element) triggers webpack to call it with
|
|
346
|
+
// __webpack_require__ as the argument, giving us access to all loaded modules. The ref holder pattern isolates the synchronous callback mutation from
|
|
347
|
+
// TypeScript's control flow narrowing — wreq is narrowed to null in this scope, so we capture into a holder and only assign after a null check.
|
|
348
|
+
if (!wreq) {
|
|
349
|
+
const wreqHolder = { ref: null };
|
|
350
|
+
try {
|
|
351
|
+
chunkArray.push([["__prismcast__"], {}, (wreqFn) => {
|
|
352
|
+
wreqHolder.ref = wreqFn;
|
|
353
|
+
}]);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
// eslint-disable-next-line no-console
|
|
357
|
+
console.log("[DIRECTV-DIAG] Synthetic chunk push error: " + String(err));
|
|
358
|
+
}
|
|
359
|
+
if (wreqHolder.ref) {
|
|
360
|
+
wreq = wreqHolder.ref;
|
|
361
|
+
// eslint-disable-next-line no-console
|
|
362
|
+
console.log("[DIRECTV-DIAG] Captured __webpack_require__ via synthetic chunk (" + String(Object.keys(wreq.m).length) + " modules).");
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// eslint-disable-next-line no-console
|
|
366
|
+
console.log("[DIRECTV-DIAG] Synthetic chunk push did not trigger callback — webpack handler may not be in place.");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!wreq) {
|
|
370
|
+
// eslint-disable-next-line no-console
|
|
371
|
+
console.log("[DIRECTV-TUNE-FAIL] __webpack_require__ not captured (natural callback and synthetic chunk push both failed).");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// Normalize the target name for case-insensitive matching. Duplicated inline because evaluateOnNewDocument cannot reference Node.js functions.
|
|
375
|
+
const normalize = (name) => name.trim().replace(/\s+/g, " ").toLowerCase();
|
|
376
|
+
const normalizedTarget = normalize(targetName);
|
|
377
|
+
// Find the target channel in the lineup by matching normalized channel name.
|
|
378
|
+
const targetChannel = channels.find((ch) => normalize(ch.channelName ?? "") === normalizedTarget);
|
|
379
|
+
if (!targetChannel) {
|
|
380
|
+
// eslint-disable-next-line no-console
|
|
381
|
+
console.log("[DIRECTV-TUNE-FAIL] Channel \"" + targetName + "\" not found in lineup (" + String(channels.length) + " channels).");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// eslint-disable-next-line no-console
|
|
385
|
+
console.log("[DIRECTV-DIAG] Target channel found: " + (targetChannel.channelName ?? "") + " (ccid=" + (targetChannel.ccid ?? "") +
|
|
386
|
+
", resourceId=" + (targetChannel.resourceId ?? "") + ").");
|
|
387
|
+
// Find the playConsumable module by searching webpack module factories for the characteristic function signature. The factory source must contain both
|
|
388
|
+
// "playConsumable" and "playAsset" to positively identify the module. Once found, load it via wreq(moduleId) which uses webpack's internal closure-based
|
|
389
|
+
// cache and correctly resolves all closure dependencies. Manual factory execution (wreq.m[id](mod, exports, wreq)) does NOT work — it creates a fresh
|
|
390
|
+
// closure scope where minified dependency variables are uninitialized, causing "a is not a function" at call time.
|
|
391
|
+
let playConsumableFn = null;
|
|
392
|
+
for (const moduleId of Object.keys(wreq.m)) {
|
|
393
|
+
// Webpack 5 may delete factories from wreq.m after loading. Skip non-function entries to avoid TypeError on .toString().
|
|
394
|
+
if (typeof wreq.m[moduleId] !== "function") {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
let moduleSource;
|
|
398
|
+
try {
|
|
399
|
+
moduleSource = wreq.m[moduleId].toString();
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (moduleSource.includes("playConsumable") && moduleSource.includes("playAsset")) {
|
|
405
|
+
// Load the module through webpack's own require function. This resolves all internal dependencies correctly via the closure-based module cache.
|
|
406
|
+
try {
|
|
407
|
+
const moduleExports = wreq(moduleId);
|
|
408
|
+
if (typeof moduleExports.playConsumable === "function") {
|
|
409
|
+
playConsumableFn = moduleExports.playConsumable;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
// Search all exports for a function whose source mentions playConsumable.
|
|
413
|
+
for (const exportKey of Object.keys(moduleExports)) {
|
|
414
|
+
const exportVal = moduleExports[exportKey];
|
|
415
|
+
if ((typeof exportVal === "function") && exportVal.toString().includes("playConsumable")) {
|
|
416
|
+
playConsumableFn = exportVal;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (loadErr) {
|
|
423
|
+
// eslint-disable-next-line no-console
|
|
424
|
+
console.log("[DIRECTV-TUNE-FAIL] Found playConsumable factory (module " + moduleId + ") but failed to load: " + String(loadErr));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (!playConsumableFn) {
|
|
431
|
+
// eslint-disable-next-line no-console
|
|
432
|
+
console.log("[DIRECTV-TUNE-FAIL] playConsumable module not found in webpack modules.");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// eslint-disable-next-line no-console
|
|
436
|
+
console.log("[DIRECTV-DIAG] Dispatching playConsumable for " + (targetChannel.channelName ?? "") + ".");
|
|
437
|
+
// Call playConsumable directly with the validated payload. The function is self-dispatching — it uses the dispatch and getState functions passed in the
|
|
438
|
+
// payload to internally dispatch Redux actions for authorization, CDN token generation, manifest construction, and player initialization. This is NOT a
|
|
439
|
+
// Redux action creator — do not wrap the call in store.dispatch().
|
|
440
|
+
try {
|
|
441
|
+
playConsumableFn({
|
|
442
|
+
consumable: {
|
|
443
|
+
augmentation: { constraints: { isPlayable: true } },
|
|
444
|
+
badges: ["OnNow"],
|
|
445
|
+
consumableType: "LINEAR",
|
|
446
|
+
duration: 3600,
|
|
447
|
+
programChannelId: targetChannel.resourceId ?? ""
|
|
448
|
+
},
|
|
449
|
+
consumableResourceId: targetChannel.resourceId ?? "",
|
|
450
|
+
dispatch: cachedStore.dispatch,
|
|
451
|
+
getState: cachedStore.getState,
|
|
452
|
+
makeFullscreen: true,
|
|
453
|
+
restart: false
|
|
454
|
+
});
|
|
455
|
+
// eslint-disable-next-line no-console
|
|
456
|
+
console.log("[DIRECTV-TUNE-OK]");
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
// eslint-disable-next-line no-console
|
|
460
|
+
console.log("[DIRECTV-TUNE-FAIL] Dispatch error: " + String(err));
|
|
461
|
+
}
|
|
462
|
+
}, POLL_INTERVAL);
|
|
463
|
+
}, channelName, discoverOnly);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Installs the DirecTV webpack interceptor before navigation. Called by the coordinator's resolveDirectUrl before page.goto fires. Always returns null so
|
|
467
|
+
* channel selection runs directvGridStrategy, which awaits the interceptor result and falls back to logo click if it fails. The interceptor is installed
|
|
468
|
+
* regardless of cache state — warm tunes dispatch playConsumable immediately, cold tunes discover the channel via Redux store extraction during page load.
|
|
469
|
+
*
|
|
470
|
+
* @param channelSelector - The channel selector string (e.g., "CNN", "ESPN").
|
|
471
|
+
* @param page - The Puppeteer page for evaluateOnNewDocument installation and console listener setup.
|
|
472
|
+
* @returns Always null — channel selection is never skipped.
|
|
473
|
+
*/
|
|
474
|
+
async function resolveDirectvDirectUrl(channelSelector, page) {
|
|
475
|
+
const normalizedName = normalizeChannelName(channelSelector);
|
|
476
|
+
// Set up console listeners for all tunes. Must be registered before navigation so we capture signals emitted during page load.
|
|
477
|
+
setupConsoleListeners(page);
|
|
478
|
+
// Create a per-page tune state. The console listener resolves this page's entry when [DIRECTV-TUNE-OK] or [DIRECTV-TUNE-FAIL] is emitted. We build the
|
|
479
|
+
// TuneState object first so the resolve function is captured before any await yields control.
|
|
480
|
+
const tuneState = {};
|
|
481
|
+
tuneState.promise = new Promise((resolve) => {
|
|
482
|
+
tuneState.resolve = resolve;
|
|
483
|
+
});
|
|
484
|
+
pendingTunes.set(page, tuneState);
|
|
485
|
+
// Install the webpack interceptor. It runs before any page JavaScript and captures __webpack_require__ from the first webpack chunk push.
|
|
486
|
+
try {
|
|
487
|
+
await installDirectTuneInterceptor(page, channelSelector, false);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
LOG.debug("tuning:directv", "Failed to install interceptor for %s: %s.", channelSelector, formatError(error));
|
|
491
|
+
pendingTunes.delete(page);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
// Log cache state for diagnostics. Both warm and cold paths return null so channel selection always runs directvGridStrategy, which awaits the interceptor
|
|
495
|
+
// result and falls back to logo click if it fails.
|
|
496
|
+
if (directvChannelCache.has(normalizedName)) {
|
|
497
|
+
LOG.debug("tuning:directv", "Warm cache for %s. Interceptor will dispatch playConsumable during page load.", channelSelector);
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
LOG.debug("tuning:directv", "Cold cache for %s. Interceptor will discover and tune during page load.", channelSelector);
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* DirecTV grid strategy: waits for the webpack interceptor to complete the tune via playConsumable dispatch. The interceptor was already installed by
|
|
506
|
+
* resolveDirectvDirectUrl before navigation. This function awaits the tune result promise with a timeout, falling back to the logo click strategy if the
|
|
507
|
+
* interceptor fails or times out.
|
|
508
|
+
* @param page - The Puppeteer page object.
|
|
509
|
+
* @param profile - The resolved site profile with a non-null channelSelector.
|
|
510
|
+
* @returns Result object with success status and optional failure reason.
|
|
511
|
+
*/
|
|
512
|
+
async function directvGridStrategy(page, profile) {
|
|
513
|
+
const channelName = profile.channelSelector;
|
|
514
|
+
// If resolveDirectvDirectUrl set up a tune promise for this page, await it with a timeout. The interceptor fires during page load and emits [DIRECTV-TUNE-OK]
|
|
515
|
+
// or [DIRECTV-TUNE-FAIL] when the playConsumable dispatch completes.
|
|
516
|
+
const tuneState = pendingTunes.get(page);
|
|
517
|
+
if (tuneState) {
|
|
518
|
+
// Race the tune promise against a timeout. The promise maps to discriminated string results so we can distinguish "interceptor reported failure" from
|
|
519
|
+
// "no signal arrived" — critical for debugging whether the interceptor ran at all or stalled silently.
|
|
520
|
+
const result = await Promise.race([
|
|
521
|
+
tuneState.promise.then((v) => v ? "success" : "failure"),
|
|
522
|
+
delay(TUNE_TIMEOUT).then(() => "timeout")
|
|
523
|
+
]);
|
|
524
|
+
// Clean up after resolution or timeout. In the success case the console listener already resolved the promise; in the timeout case this removes the
|
|
525
|
+
// stale entry so a late-arriving console signal is a no-op.
|
|
526
|
+
pendingTunes.delete(page);
|
|
527
|
+
if (result === "success") {
|
|
528
|
+
LOG.debug("tuning:directv", "Interceptor tune succeeded for %s.", channelName);
|
|
529
|
+
return { directTune: true, success: true };
|
|
530
|
+
}
|
|
531
|
+
// Invalidate the cache entry so the next tune rediscovers the channel with fresh IDs from the Redux store rather than retrying with the same stale data.
|
|
532
|
+
directvChannelCache.delete(normalizeChannelName(channelName));
|
|
533
|
+
if (result === "failure") {
|
|
534
|
+
LOG.debug("tuning:directv", "Interceptor reported tune failure for %s. Invalidated cache entry. Falling back to logo click.", channelName);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
LOG.debug("tuning:directv", "Timed out waiting for interceptor signal for %s (%sms). Invalidated cache entry. Falling back to logo click.", channelName, TUNE_TIMEOUT);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// Fallback: use the logo click strategy. The interceptor either failed or was not set up (should not happen in normal flow).
|
|
541
|
+
return await directvLogoClickFallback(page, channelName);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Logo click fallback for DirecTV Stream. The guide's logo column is NOT virtualized — all ~152 channel logos are always in the DOM with
|
|
545
|
+
* aria-label="view {channelName}" attributes. This function:
|
|
546
|
+
* 1. Finds the logo element by aria-label
|
|
547
|
+
* 2. Scrolls it into view and clicks it (DOM click, not coordinate-based — an invisible overlay blocks coordinate clicks)
|
|
548
|
+
* 3. Waits for the mini-guide play button to appear
|
|
549
|
+
* 4. Clicks the inner Pressable to start playback
|
|
550
|
+
*
|
|
551
|
+
* @param page - The Puppeteer page object.
|
|
552
|
+
* @param channelName - The channel selector value (display name as shown in the guide).
|
|
553
|
+
* @returns Result object with success status and optional failure reason.
|
|
554
|
+
*/
|
|
555
|
+
async function directvLogoClickFallback(page, channelName) {
|
|
556
|
+
LOG.debug("tuning:directv", "Logo click fallback for %s.", channelName);
|
|
557
|
+
// Wait for the guide grid to render. The logo column loads progressively as React renders the channel list.
|
|
558
|
+
try {
|
|
559
|
+
await page.waitForSelector("[aria-label^=\"view \"]", { timeout: CONFIG.streaming.videoTimeout, visible: true });
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return { reason: "DirecTV guide grid did not load (no channel logos found).", success: false };
|
|
563
|
+
}
|
|
564
|
+
// Find and scroll to the target channel's logo. We use DOM element.click() rather than coordinate-based page.mouse.click because DirecTV has an invisible
|
|
565
|
+
// overlay (React Native for Web Pressable) that intercepts coordinate-based clicks. DOM click dispatches directly to the element, bypassing coordinate
|
|
566
|
+
// hit-testing. The find-and-scroll step tags the element with a data attribute so the subsequent click step can retrieve it without duplicating the search.
|
|
567
|
+
const found = await page.evaluate((name) => {
|
|
568
|
+
// Try exact match first, then case-insensitive fallback.
|
|
569
|
+
let logo = document.querySelector("[aria-label=\"view " + name + "\"]");
|
|
570
|
+
if (!logo) {
|
|
571
|
+
const allLogos = document.querySelectorAll("[aria-label^=\"view \"]");
|
|
572
|
+
const lowerName = name.toLowerCase();
|
|
573
|
+
for (const el of Array.from(allLogos)) {
|
|
574
|
+
const label = (el.getAttribute("aria-label") ?? "").slice("view ".length).toLowerCase();
|
|
575
|
+
if (label === lowerName) {
|
|
576
|
+
logo = el;
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (!logo) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
// Tag the element so the click step can find it without repeating the search.
|
|
585
|
+
logo.setAttribute("data-prismcast-target", "1");
|
|
586
|
+
// Scroll the logo into view so the mini-guide overlay appears at a visible position.
|
|
587
|
+
logo.scrollIntoView({ behavior: "instant", block: "center" });
|
|
588
|
+
return true;
|
|
589
|
+
}, channelName);
|
|
590
|
+
if (!found) {
|
|
591
|
+
// Log available channels from the logo column to help users identify the correct channelSelector value.
|
|
592
|
+
const availableChannels = await page.evaluate(() => {
|
|
593
|
+
const logos = document.querySelectorAll("[aria-label^=\"view \"]");
|
|
594
|
+
return Array.from(logos).map((el) => (el.getAttribute("aria-label") ?? "").slice("view ".length)).filter((name) => name.length > 0).sort();
|
|
595
|
+
});
|
|
596
|
+
logAvailableChannels({
|
|
597
|
+
availableChannels,
|
|
598
|
+
channelName,
|
|
599
|
+
guideUrl: DIRECTV_GUIDE_URL,
|
|
600
|
+
presetSuffix: "-directv",
|
|
601
|
+
providerName: "DirecTV Stream"
|
|
602
|
+
});
|
|
603
|
+
return { reason: "Could not find channel " + channelName + " in DirecTV Stream guide.", success: false };
|
|
604
|
+
}
|
|
605
|
+
// Brief settle delay after scrolling for animations and lazy content to finish.
|
|
606
|
+
await delay(300);
|
|
607
|
+
// Click the previously-tagged logo element via DOM click.
|
|
608
|
+
const logoClicked = await page.evaluate(() => {
|
|
609
|
+
const logo = document.querySelector("[data-prismcast-target=\"1\"]");
|
|
610
|
+
if (!logo) {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
logo.removeAttribute("data-prismcast-target");
|
|
614
|
+
logo.click();
|
|
615
|
+
return true;
|
|
616
|
+
});
|
|
617
|
+
if (!logoClicked) {
|
|
618
|
+
LOG.debug("tuning:directv", "Tagged logo element not found after scroll delay for %s.", channelName);
|
|
619
|
+
return { reason: "DirecTV logo element disappeared after scrolling for " + channelName + ".", success: false };
|
|
620
|
+
}
|
|
621
|
+
// Wait for the mini-guide overlay to appear with the "on now" play button. DirecTV shows a mini-guide strip when a logo is clicked, with the currently-airing
|
|
622
|
+
// program tile that starts playback when clicked.
|
|
623
|
+
try {
|
|
624
|
+
await page.waitForSelector("[aria-label^=\"on now,\"]", { timeout: CONFIG.streaming.videoTimeout, visible: true });
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return { reason: "DirecTV mini-guide play button did not appear for " + channelName + ".", success: false };
|
|
628
|
+
}
|
|
629
|
+
// Brief settle delay for the play button's React state to bind.
|
|
630
|
+
await delay(200);
|
|
631
|
+
// Click the inner Pressable element inside the on-now tile. The outer element has the aria-label but the clickable Pressable is the first focusable child.
|
|
632
|
+
const clicked = await page.evaluate(() => {
|
|
633
|
+
const onNow = document.querySelector("[aria-label^=\"on now,\"]");
|
|
634
|
+
if (!onNow) {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
// Find the inner clickable element. DirecTV uses tabindex="0" on Pressable components.
|
|
638
|
+
const pressable = onNow.querySelector("[tabindex=\"0\"]") ?? onNow;
|
|
639
|
+
pressable.click();
|
|
640
|
+
return true;
|
|
641
|
+
});
|
|
642
|
+
if (!clicked) {
|
|
643
|
+
return { reason: "Failed to click DirecTV play button for " + channelName + ".", success: false };
|
|
644
|
+
}
|
|
645
|
+
LOG.info("DirecTV logo click fallback succeeded for %s.", channelName);
|
|
646
|
+
return { success: true };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Converts the unified channel cache to a sorted DiscoveredChannel array.
|
|
650
|
+
* @returns Sorted array of discovered channels.
|
|
651
|
+
*/
|
|
652
|
+
function buildDirectvDiscoveredChannels() {
|
|
653
|
+
const channels = [];
|
|
654
|
+
for (const entry of directvChannelCache.values()) {
|
|
655
|
+
channels.push({ channelSelector: entry.displayName, name: entry.displayName });
|
|
656
|
+
}
|
|
657
|
+
channels.sort((a, b) => a.name.localeCompare(b.name));
|
|
658
|
+
return channels;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Discovers all channels from DirecTV Stream by installing the webpack interceptor with discoverOnly=true, navigating to the guide page, and waiting for the
|
|
662
|
+
* Redux store extraction to emit the channel lineup. The Redux store contains the complete channel lineup, so a single page load fully enumerates all available
|
|
663
|
+
* channels. Requires handlesOwnNavigation on the provider module because the interceptor must be installed before navigation.
|
|
664
|
+
*
|
|
665
|
+
* Side effect: populates the module-level directvChannelCache, warming the tuning cache for subsequent channel tunes.
|
|
666
|
+
* @param page - The Puppeteer page object (fresh page, not yet navigated).
|
|
667
|
+
* @returns Array of discovered channels.
|
|
668
|
+
*/
|
|
669
|
+
async function discoverDirectvChannels(page) {
|
|
670
|
+
// Return from the unified cache if a prior discovery or tune has fully enumerated the lineup.
|
|
671
|
+
if (directvFullyDiscovered && (directvChannelCache.size > 0)) {
|
|
672
|
+
return buildDirectvDiscoveredChannels();
|
|
673
|
+
}
|
|
674
|
+
// Set up console listeners before navigation so we capture the [DIRECTV-CHANNELS] signal.
|
|
675
|
+
setupConsoleListeners(page);
|
|
676
|
+
// Install the interceptor in discover-only mode — skip the playConsumable tune phase.
|
|
677
|
+
try {
|
|
678
|
+
await installDirectTuneInterceptor(page, "", true);
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
LOG.warn("Failed to install DirecTV discovery interceptor: %s.", formatError(error));
|
|
682
|
+
return [];
|
|
683
|
+
}
|
|
684
|
+
await page.goto(DIRECTV_GUIDE_URL, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "load" });
|
|
685
|
+
// Wait for the channel lineup to be emitted by the interceptor. The Redux store extraction polls at 200ms intervals in the browser context.
|
|
686
|
+
const discoveryStart = Date.now();
|
|
687
|
+
while (!directvFullyDiscovered && ((Date.now() - discoveryStart) < DISCOVERY_TIMEOUT)) {
|
|
688
|
+
// eslint-disable-next-line no-await-in-loop
|
|
689
|
+
await delay(500);
|
|
690
|
+
}
|
|
691
|
+
if (!directvFullyDiscovered || (directvChannelCache.size === 0)) {
|
|
692
|
+
LOG.debug("tuning:directv", "Discovery timed out or returned empty results after %sms.", Date.now() - discoveryStart);
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
695
|
+
LOG.debug("tuning:directv", "Discovery completed: %s channels in %sms.", directvChannelCache.size, Date.now() - discoveryStart);
|
|
696
|
+
return buildDirectvDiscoveredChannels();
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Returns cached discovered channels if the provider has fully enumerated its lineup, or null if no enumeration has occurred. The Redux store extraction
|
|
700
|
+
* produces the complete lineup on a single page load, so the fully-discovered flag is reliable.
|
|
701
|
+
* @returns Sorted array of discovered channels or null.
|
|
702
|
+
*/
|
|
703
|
+
function getDirectvCachedChannels() {
|
|
704
|
+
if (!directvFullyDiscovered || (directvChannelCache.size === 0)) {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
return buildDirectvDiscoveredChannels();
|
|
708
|
+
}
|
|
709
|
+
export const directvProvider = {
|
|
710
|
+
discoverChannels: discoverDirectvChannels,
|
|
711
|
+
getCachedChannels: getDirectvCachedChannels,
|
|
712
|
+
guideUrl: DIRECTV_GUIDE_URL,
|
|
713
|
+
handlesOwnNavigation: true,
|
|
714
|
+
label: "DirecTV Stream",
|
|
715
|
+
slug: "directv",
|
|
716
|
+
strategy: {
|
|
717
|
+
clearCache: clearDirectvCache,
|
|
718
|
+
execute: directvGridStrategy,
|
|
719
|
+
resolveDirectUrl: resolveDirectvDirectUrl
|
|
720
|
+
},
|
|
721
|
+
strategyName: "directvGrid"
|
|
722
|
+
};
|
|
723
|
+
//# sourceMappingURL=directv.js.map
|