prismcast 1.4.1 → 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 +537 -211
- 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 +47 -6
- package/dist/config/providers.js +211 -17
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +1 -12
- package/dist/config/sites.js +40 -7
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +41 -19
- package/dist/config/userChannels.js +152 -35
- 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/theme.js +7 -1
- package/dist/routes/theme.js.map +1 -1
- 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/showInfo.js +12 -14
- package/dist/streaming/showInfo.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 -406
- 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 +6 -4
- package/dist/routes/config.d.ts +0 -72
- package/dist/routes/config.js +0 -1987
- package/dist/routes/config.js.map +0 -1
- package/dist/routes/root.js +0 -2959
- package/dist/routes/root.js.map +0 -1
- /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
|
@@ -1,34 +1,132 @@
|
|
|
1
1
|
import { LOG, delay, evaluateWithAbort, formatError } from "../../utils/index.js";
|
|
2
2
|
import { logAvailableChannels, normalizeChannelName, scrollAndClick } from "../channelSelection.js";
|
|
3
3
|
import { CONFIG } from "../../config/index.js";
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
const
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
4
|
+
// Unified channel cache for Hulu. Maps normalized channel names to their combined entry. Aliases (e.g., "abc" → same entry as "wls") share object references for
|
|
5
|
+
// automatic propagation of fresh programs and EAB data. Cleared on browser disconnect via clearHuluCache().
|
|
6
|
+
const huluChannelCache = new Map();
|
|
7
|
+
// Transient staging map for listing API response data. Maps channel UUIDs to their program schedules. Populated from listing API responses (which arrive before
|
|
8
|
+
// details responses due to the interceptor's listingCapturedPromise hold). Read by populateHuluChannelCache when the details response arrives, joining UUID-keyed
|
|
9
|
+
// programs with name-keyed entries. Also used to propagate fresh programs to existing cache entries when a new listing response arrives.
|
|
10
|
+
const huluListingStaging = new Map();
|
|
11
|
+
// Tracks whether a full discovery walk (with affiliate position inference) has completed. When true, buildHuluDiscoveredChannels can return comprehensive results
|
|
12
|
+
// including proper affiliate labeling. When false, getCachedChannels returns null to force a fresh discovery walk.
|
|
13
|
+
let huluFullyDiscovered = false;
|
|
14
14
|
// Tracks pages with details API response listeners to avoid duplicate registration. Mirrors the pagesWithListeners pattern in sling.ts.
|
|
15
15
|
const huluPagesWithListeners = new WeakSet();
|
|
16
16
|
// The Hulu live page URL. The evaluateOnNewDocument fetch interceptor swaps channel_id and content_eab_id in the playlist API request, making the app play the
|
|
17
|
-
// target channel through its own pipeline without guide grid interaction. On warm cache, UUID and EAB are injected at install time. On cold cache
|
|
18
|
-
//
|
|
17
|
+
// target channel through its own pipeline without guide grid interaction. On warm cache, UUID and EAB are injected at install time. On cold cache, the guide
|
|
18
|
+
// grid's Channels tab click triggers full API expansion, and the interceptor captures UUID+EAB from those expanded responses.
|
|
19
19
|
const HULU_LIVE_URL = "https://www.hulu.com/live";
|
|
20
20
|
/**
|
|
21
|
-
* Clears all Hulu caches:
|
|
21
|
+
* Clears all Hulu caches: the unified channel cache, listing staging map, and discovery flag. Called by clearChannelSelectionCaches() in the coordinator when the
|
|
22
22
|
* browser restarts, since cached state may be stale in a new browser session.
|
|
23
23
|
*/
|
|
24
24
|
function clearHuluCache() {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
huluChannelCache.clear();
|
|
26
|
+
huluFullyDiscovered = false;
|
|
27
|
+
huluListingStaging.clear();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the actual cache key for a normalized channel name, with a fuzzy fallback that strips all non-alphanumeric characters to handle formatting mismatches
|
|
31
|
+
* between channelSelector values and Hulu's API naming (e.g., "C-SPAN3" vs "C-SPAN 3", "HBO2" vs "HBO 2"). Used by findHuluChannelEntry (lookup) and
|
|
32
|
+
* invalidateHuluDirectUrl (deletion) to ensure both paths handle the same set of name variants.
|
|
33
|
+
* @param normalizedName - The normalized (lowercased, whitespace-collapsed) channel name to resolve.
|
|
34
|
+
* @returns The actual cache key, or null if no match found.
|
|
35
|
+
*/
|
|
36
|
+
function resolveHuluCacheKey(normalizedName) {
|
|
37
|
+
if (huluChannelCache.has(normalizedName)) {
|
|
38
|
+
return normalizedName;
|
|
39
|
+
}
|
|
40
|
+
// Fuzzy fallback: strip all non-alphanumeric characters and compare. Handles formatting differences in spaces, hyphens, ampersands, and other punctuation
|
|
41
|
+
// between channelSelector definitions and Hulu's Details API channel_info.name values.
|
|
42
|
+
const stripped = normalizedName.replace(/[^a-z0-9]/g, "");
|
|
43
|
+
for (const key of huluChannelCache.keys()) {
|
|
44
|
+
if (key.replace(/[^a-z0-9]/g, "") === stripped) {
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Looks up a channel in the unified cache by normalized name, with fuzzy fallback via resolveHuluCacheKey. Only used for lookups keyed by channelSelector —
|
|
52
|
+
* internal cache operations that use API-derived names should use huluChannelCache.get() directly.
|
|
53
|
+
* @param normalizedName - The normalized (lowercased, whitespace-collapsed) channel name to look up.
|
|
54
|
+
* @returns The matching cache entry, or null if no match found.
|
|
55
|
+
*/
|
|
56
|
+
function findHuluChannelEntry(normalizedName) {
|
|
57
|
+
const key = resolveHuluCacheKey(normalizedName);
|
|
58
|
+
return key ? huluChannelCache.get(key) ?? null : null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Populates the unified channel cache from a details API response. For each item with channel_info, creates or updates the entry keyed by normalized name,
|
|
62
|
+
* joining the details data (name, UUID) with programs from the listing staging map. Updates existing entries in-place to preserve alias references and
|
|
63
|
+
* supplementary fields (rowNumber, affiliate) that may have been set by guide grid operations.
|
|
64
|
+
* @param items - Array of details response items containing channel_info with name and id.
|
|
65
|
+
*/
|
|
66
|
+
function populateHuluChannelCache(items) {
|
|
67
|
+
const channelsSeen = new Set();
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
const info = item.channel_info;
|
|
70
|
+
if (info?.name && info.id) {
|
|
71
|
+
const normalized = normalizeChannelName(info.name);
|
|
72
|
+
const existing = huluChannelCache.get(normalized);
|
|
73
|
+
const programs = huluListingStaging.get(info.id) ?? existing?.programs;
|
|
74
|
+
if (existing) {
|
|
75
|
+
// Update in-place to preserve alias references and supplementary fields (rowNumber, affiliate).
|
|
76
|
+
existing.displayName = info.name;
|
|
77
|
+
existing.uuid = info.id;
|
|
78
|
+
if (programs) {
|
|
79
|
+
existing.programs = programs;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
huluChannelCache.set(normalized, { displayName: info.name, programs, uuid: info.id });
|
|
84
|
+
}
|
|
85
|
+
channelsSeen.add(info.name);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
LOG.debug("tuning:hulu", "Details API: %s items, %s unique channels. Channel cache size: %s.", items.length, channelsSeen.size, huluChannelCache.size);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Finds the currently-airing EAB from a program schedule array. Searches the programs for one whose airing window brackets the current time. Returns null if the
|
|
92
|
+
* array is empty or no program is currently airing (stale data or program boundary gap).
|
|
93
|
+
* @param programs - Array of programs with EAB IDs and airing times.
|
|
94
|
+
* @returns The currently-airing EAB string, or null if no match.
|
|
95
|
+
*/
|
|
96
|
+
function findCurrentEabFromPrograms(programs) {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
for (const program of programs) {
|
|
99
|
+
if ((now >= new Date(program.airingStart).getTime()) && (now < new Date(program.airingEnd).getTime())) {
|
|
100
|
+
return program.eab;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Derives a DiscoveredChannel array from the unified channel cache, deduplicating alias entries via Set reference equality. Affiliates produce entries with the
|
|
107
|
+
* network name as channelSelector; non-affiliates use their display name. Used by getCachedChannels and discoverHuluChannels when returning from warm cache.
|
|
108
|
+
* @returns Sorted array of discovered channels.
|
|
109
|
+
*/
|
|
110
|
+
function buildHuluDiscoveredChannels() {
|
|
111
|
+
const channels = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const entry of huluChannelCache.values()) {
|
|
114
|
+
if (seen.has(entry)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
seen.add(entry);
|
|
118
|
+
const result = { channelSelector: entry.affiliate ?? entry.displayName, name: entry.displayName };
|
|
119
|
+
if (entry.affiliate) {
|
|
120
|
+
result.affiliate = entry.affiliate;
|
|
121
|
+
}
|
|
122
|
+
channels.push(result);
|
|
123
|
+
}
|
|
124
|
+
channels.sort((a, b) => a.name.localeCompare(b.name));
|
|
125
|
+
return channels;
|
|
28
126
|
}
|
|
29
127
|
/**
|
|
30
128
|
* 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
|
|
31
|
-
*
|
|
129
|
+
* unified channel cache with rowNumber and displayName as a side effect.
|
|
32
130
|
* @param page - The Puppeteer page object.
|
|
33
131
|
* @returns Array of rendered channels in DOM order, or null if no channels are rendered.
|
|
34
132
|
*/
|
|
@@ -43,38 +141,104 @@ async function readRenderedChannels(page) {
|
|
|
43
141
|
for (const el of Array.from(containers)) {
|
|
44
142
|
const testid = el.getAttribute("data-testid") ?? "";
|
|
45
143
|
const name = testid.slice(prefix.length).trim().replace(/\s+/g, " ").toLowerCase();
|
|
46
|
-
// Extract row number from sr-only text. Format: "{Name} Details, row {N} of {Total}. ..."
|
|
144
|
+
// Extract the original-cased display name and row number from sr-only text. Format: "{Name} Details, row {N} of {Total}. ..." The data-testid attribute
|
|
145
|
+
// is lowercased by Hulu's app, so the sr-only text is the only source of original casing (e.g., "CNN" vs "cnn", "A&E" vs "a&e").
|
|
146
|
+
let displayName = name;
|
|
47
147
|
let rowNumber = -1;
|
|
48
148
|
const btn = el.querySelector("[data-testid=\"live-guide-channel-button\"]");
|
|
49
149
|
if (btn) {
|
|
50
150
|
const srOnly = btn.querySelector(".sr-only, [class*=\"sr-only\"]");
|
|
51
151
|
if (srOnly) {
|
|
52
|
-
|
|
53
|
-
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
153
|
+
const text = srOnly.textContent ?? "";
|
|
154
|
+
const nameMatch = /^(.+?) Details, row/.exec(text);
|
|
155
|
+
if (nameMatch) {
|
|
156
|
+
displayName = nameMatch[1].trim();
|
|
157
|
+
}
|
|
158
|
+
const rowMatch = /row (\d+) of/.exec(text);
|
|
159
|
+
if (rowMatch) {
|
|
54
160
|
// Row numbers in sr-only text are 1-based. Convert to 0-based for scroll offset calculation.
|
|
55
|
-
rowNumber = parseInt(
|
|
161
|
+
rowNumber = parseInt(rowMatch[1], 10) - 1;
|
|
56
162
|
}
|
|
57
163
|
}
|
|
58
164
|
}
|
|
59
|
-
results.push({ name, rowNumber });
|
|
165
|
+
results.push({ displayName, name, rowNumber });
|
|
60
166
|
}
|
|
61
167
|
return results;
|
|
62
168
|
});
|
|
63
169
|
if (!channels) {
|
|
64
170
|
return null;
|
|
65
171
|
}
|
|
66
|
-
// Assign DOM indices and populate the row
|
|
172
|
+
// Assign DOM indices and populate the unified channel cache with row numbers and display names.
|
|
67
173
|
const rendered = [];
|
|
68
174
|
for (let i = 0; i < channels.length; i++) {
|
|
69
175
|
const ch = channels[i];
|
|
70
|
-
rendered.push({ domIndex: i, name: ch.name, rowNumber: ch.rowNumber });
|
|
71
|
-
// Cache the row number for future direct-scroll lookups.
|
|
176
|
+
rendered.push({ displayName: ch.displayName, domIndex: i, name: ch.name, rowNumber: ch.rowNumber });
|
|
177
|
+
// Cache the row number and display name for future direct-scroll lookups.
|
|
72
178
|
if (ch.rowNumber >= 0) {
|
|
73
|
-
|
|
179
|
+
const existing = huluChannelCache.get(ch.name);
|
|
180
|
+
if (existing) {
|
|
181
|
+
existing.rowNumber = ch.rowNumber;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
huluChannelCache.set(ch.name, { displayName: ch.displayName, rowNumber: ch.rowNumber });
|
|
185
|
+
}
|
|
74
186
|
}
|
|
75
187
|
}
|
|
76
188
|
return rendered;
|
|
77
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Reads grid metadata from the Hulu live guide by walking up from a rendered row element to find the spacer and viewport divs. Measures the actual row height
|
|
192
|
+
* from the first rendered row's bounding rect rather than assuming a hardcoded pixel value. The spacer div is the direct parent of all absolutely-positioned
|
|
193
|
+
* rows, and its height equals totalRows * rowHeight. The viewport div is the spacer's parent (overflow: hidden). We calculate gridDocTop as the viewport's
|
|
194
|
+
* document-level offset, so that scrolling to gridDocTop + (rowIndex * rowHeight) places that row at the top of the browser viewport. Shared by both
|
|
195
|
+
* guideGridStrategy and discoverHuluChannels.
|
|
196
|
+
* @param page - The Puppeteer page object.
|
|
197
|
+
* @returns Grid metadata or null if the grid structure is not found.
|
|
198
|
+
*/
|
|
199
|
+
async function readGridMeta(page) {
|
|
200
|
+
return await page.evaluate(() => {
|
|
201
|
+
const row = document.querySelector("[data-testid=\"live-guide-row\"]");
|
|
202
|
+
if (!row) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
// Measure the actual row height from the rendered element rather than assuming a hardcoded value.
|
|
206
|
+
const rowHeight = row.getBoundingClientRect().height;
|
|
207
|
+
if (rowHeight <= 0) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
// The spacer div is the parent of all row elements.
|
|
211
|
+
const spacer = row.parentElement;
|
|
212
|
+
if (!spacer) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const spacerHeight = spacer.offsetHeight;
|
|
216
|
+
if (spacerHeight < rowHeight) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
// The viewport div is the spacer's parent. Its position relative to the document determines our scroll offset.
|
|
220
|
+
const viewport = spacer.parentElement;
|
|
221
|
+
if (!viewport) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const gridDocTop = viewport.getBoundingClientRect().top + document.documentElement.scrollTop;
|
|
225
|
+
return { gridDocTop, rowHeight, totalRows: Math.round(spacerHeight / rowHeight) };
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Scrolls the Hulu live guide to the specified row index and waits for the virtualizer to render. The scroll target is calculated from the grid's document-level
|
|
230
|
+
* offset and the dynamically measured row height. Shared by guideGridStrategy (binary search) and discoverHuluChannels (linear scan).
|
|
231
|
+
* @param page - The Puppeteer page object.
|
|
232
|
+
* @param gridDocTop - The grid viewport's document-level top offset (from readGridMeta).
|
|
233
|
+
* @param rowHeight - The measured row height in pixels (from readGridMeta).
|
|
234
|
+
* @param rowIndex - The zero-based row index to scroll to.
|
|
235
|
+
*/
|
|
236
|
+
async function scrollToGuideRow(page, gridDocTop, rowHeight, rowIndex) {
|
|
237
|
+
await page.evaluate((scrollTo) => {
|
|
238
|
+
document.documentElement.scrollTop = scrollTo;
|
|
239
|
+
}, gridDocTop + (rowIndex * rowHeight));
|
|
240
|
+
await delay(200);
|
|
241
|
+
}
|
|
78
242
|
/**
|
|
79
243
|
* 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
|
|
80
244
|
* for a subsequent page.mouse.click(). We return coordinates rather than clicking inside the evaluate because page.mouse.click() generates the full pointer event
|
|
@@ -315,26 +479,60 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
|
|
|
315
479
|
return { reason: "Play button did not appear after " + String(MAX_CLICK_ATTEMPTS) + " on-now cell click attempts for " + channelName + ".", success: false };
|
|
316
480
|
}
|
|
317
481
|
/**
|
|
318
|
-
* Attempts
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
482
|
+
* Attempts a fast-path tune by either injecting UUID+EAB from the unified cache into the in-page interceptor, or detecting that the interceptor has already
|
|
483
|
+
* self-resolved from in-page API data. Two resolution mechanisms handle different channel types:
|
|
484
|
+
*
|
|
485
|
+
* 1. Server-side injection: finds the currently-airing EAB from the entry's program schedule and calls __prismcastResolveDirectTune to inject both values into the
|
|
486
|
+
* held playlist request. Primary mechanism for local affiliates whose call signs don't match the channelSelector network name — the interceptor can't
|
|
487
|
+
* self-resolve by name for these, so external injection is the only path.
|
|
488
|
+
* 2. Self-resolution detection: queries __prismcastIsDirectTuneResolved to check if the interceptor already captured UUID+EAB from the expanded
|
|
489
|
+
* Details+Listing API responses and resolved the playlist autonomously. Primary mechanism for non-affiliate channels on cold cache, where the interceptor's
|
|
490
|
+
* in-page name matching succeeds before the guide grid completes binary search.
|
|
491
|
+
*
|
|
492
|
+
* On success, dismisses the guide overlay so the video player is visible for capture.
|
|
322
493
|
* @param page - The Puppeteer page object.
|
|
323
|
-
* @param
|
|
324
|
-
* @param currentEab - The currently-airing EAB to inject into the interceptor.
|
|
494
|
+
* @param entry - The unified cache entry for the target channel, or null if not yet available.
|
|
325
495
|
* @param channelName - The original channel name for logging.
|
|
326
|
-
* @returns True if the
|
|
496
|
+
* @returns True if the tune was resolved (via injection or self-resolution), false otherwise.
|
|
327
497
|
*/
|
|
328
|
-
async function tryFastPathTune(page,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
498
|
+
async function tryFastPathTune(page, entry, channelName) {
|
|
499
|
+
let resolved = false;
|
|
500
|
+
let resolveDetail = "";
|
|
501
|
+
// Phase 1: If UUID and programs are available from the unified cache, attempt to inject the UUID along with the current EAB into the in-page interceptor. This
|
|
502
|
+
// is the primary mechanism for local affiliates (where the interceptor can't self-resolve by name) and a secondary mechanism for exact-match channels.
|
|
503
|
+
if (entry?.uuid && entry.programs) {
|
|
504
|
+
const currentEab = findCurrentEabFromPrograms(entry.programs);
|
|
505
|
+
if (currentEab) {
|
|
506
|
+
const injected = await page.evaluate((u, e) => {
|
|
507
|
+
const resolver = window.__prismcastResolveDirectTune;
|
|
508
|
+
if (typeof resolver === "function") {
|
|
509
|
+
return resolver(u, e);
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
}, entry.uuid, currentEab);
|
|
513
|
+
if (injected) {
|
|
514
|
+
resolved = true;
|
|
515
|
+
resolveDetail = "uuid=" + entry.uuid;
|
|
516
|
+
}
|
|
333
517
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
518
|
+
}
|
|
519
|
+
// Phase 2: If injection didn't succeed, check if the interceptor self-resolved from in-page API data. This happens on cold cache for non-affiliate channels
|
|
520
|
+
// whose names match the Details API response (e.g., "CNN International", "ESPN"). The interceptor captures UUID+EAB from the expanded Details+Listing
|
|
521
|
+
// responses triggered by the Channels tab click and resolves the held playlist autonomously, making the guide grid click redundant.
|
|
522
|
+
if (!resolved) {
|
|
523
|
+
const selfResolved = await page.evaluate(() => {
|
|
524
|
+
const checker = window.__prismcastIsDirectTuneResolved;
|
|
525
|
+
if (typeof checker === "function") {
|
|
526
|
+
return checker();
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
});
|
|
530
|
+
if (selfResolved) {
|
|
531
|
+
resolved = true;
|
|
532
|
+
resolveDetail = "interceptor self-resolved from API data";
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (!resolved) {
|
|
338
536
|
return false;
|
|
339
537
|
}
|
|
340
538
|
// Dismiss the guide overlay so the video player is visible for capture. The guide was opened for binary search and is still covering the player.
|
|
@@ -345,13 +543,13 @@ async function tryFastPathTune(page, channelUuid, currentEab, channelName) {
|
|
|
345
543
|
catch (error) {
|
|
346
544
|
LOG.debug("tuning:hulu", "Could not dismiss guide after fast-path tune: %s.", formatError(error));
|
|
347
545
|
}
|
|
348
|
-
LOG.debug("tuning:hulu", "Direct tune via fetch interception for
|
|
546
|
+
LOG.debug("tuning:hulu", "Direct tune via fetch interception for %s (%s).", channelName, resolveDetail);
|
|
349
547
|
return true;
|
|
350
548
|
}
|
|
351
549
|
/**
|
|
352
|
-
* Releases the held playlist request in the in-page fetch interceptor, reverting to the click-based flow. Called when
|
|
353
|
-
*
|
|
354
|
-
* click follow the affiliate capture path ([HULU-CACHE]).
|
|
550
|
+
* Releases the held playlist request in the in-page fetch interceptor, reverting to the click-based flow. Called when tryFastPathTune returns false (neither
|
|
551
|
+
* server-side injection nor interceptor self-resolution succeeded). Sets holdActive to false in the interceptor so subsequent playlist requests from the play
|
|
552
|
+
* button click follow the affiliate capture path ([HULU-CACHE]).
|
|
355
553
|
* @param page - The Puppeteer page object.
|
|
356
554
|
*/
|
|
357
555
|
async function releaseHeldPlaylist(page) {
|
|
@@ -376,7 +574,7 @@ async function releaseHeldPlaylist(page) {
|
|
|
376
574
|
* The selection process:
|
|
377
575
|
* 1. If listSelector is provided, click the tab/button to reveal the channel list (e.g., a "Channels" tab)
|
|
378
576
|
* 2. Wait for the channel grid rows to render in the DOM
|
|
379
|
-
* 3. Check the
|
|
577
|
+
* 3. Check the unified cache for a row number direct-scroll shortcut
|
|
380
578
|
* 4. Binary search: scroll to the midpoint row, read rendered channels (caching row numbers), check for exact match or infer local affiliate
|
|
381
579
|
* 5. If binary search fails, linear scan from top to bottom as a universal fallback
|
|
382
580
|
* 6. Click the on-now program cell (`.LiveGuideProgram--first`) in the target channel's row to open the playback overlay
|
|
@@ -433,53 +631,21 @@ async function guideGridStrategy(page, profile) {
|
|
|
433
631
|
if (!rowsVisible) {
|
|
434
632
|
return { reason: "Channel grid rows did not render.", success: false };
|
|
435
633
|
}
|
|
436
|
-
// Each row in the virtualized grid is exactly 112px tall. The total number of channels is derived from the spacer div's height.
|
|
437
|
-
const ROW_HEIGHT = 112;
|
|
438
634
|
// Normalize the channel name to lowercase for case-insensitive matching against data-testid suffixes.
|
|
439
635
|
const normalizedName = normalizeChannelName(channelName);
|
|
440
|
-
|
|
441
|
-
// 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
|
|
442
|
-
// document-level offset, so that scrolling to gridDocTop + (rowIndex * ROW_HEIGHT) places that row at the top of the browser viewport.
|
|
443
|
-
const gridMeta = await page.evaluate((rowHeight) => {
|
|
444
|
-
const row = document.querySelector("[data-testid=\"live-guide-row\"]");
|
|
445
|
-
if (!row) {
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
// The spacer div is the parent of all row elements.
|
|
449
|
-
const spacer = row.parentElement;
|
|
450
|
-
if (!spacer) {
|
|
451
|
-
return null;
|
|
452
|
-
}
|
|
453
|
-
const spacerHeight = spacer.offsetHeight;
|
|
454
|
-
if (spacerHeight < rowHeight) {
|
|
455
|
-
return null;
|
|
456
|
-
}
|
|
457
|
-
// The viewport div is the spacer's parent. Its position relative to the document determines our scroll offset.
|
|
458
|
-
const viewport = spacer.parentElement;
|
|
459
|
-
if (!viewport) {
|
|
460
|
-
return null;
|
|
461
|
-
}
|
|
462
|
-
const gridDocTop = viewport.getBoundingClientRect().top + document.documentElement.scrollTop;
|
|
463
|
-
return { gridDocTop, totalRows: Math.round(spacerHeight / rowHeight) };
|
|
464
|
-
}, ROW_HEIGHT);
|
|
636
|
+
const gridMeta = await readGridMeta(page);
|
|
465
637
|
if (!gridMeta) {
|
|
466
638
|
return { reason: "Could not locate channel grid spacer element.", success: false };
|
|
467
639
|
}
|
|
468
|
-
const { gridDocTop, totalRows } = gridMeta;
|
|
469
|
-
// Helper: scroll to a specific row index and wait for the virtualizer to render.
|
|
470
|
-
const scrollToRow = async (rowIndex) => {
|
|
471
|
-
await page.evaluate((scrollTo) => {
|
|
472
|
-
document.documentElement.scrollTop = scrollTo;
|
|
473
|
-
}, gridDocTop + (rowIndex * ROW_HEIGHT));
|
|
474
|
-
await delay(200);
|
|
475
|
-
};
|
|
640
|
+
const { gridDocTop, rowHeight, totalRows } = gridMeta;
|
|
476
641
|
// 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.
|
|
477
642
|
let clickTarget = normalizedName;
|
|
478
|
-
// Check the
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
643
|
+
// Check the unified cache for a row number direct-scroll shortcut. If we've seen this channel before, we can skip binary search entirely and scroll directly
|
|
644
|
+
// to it. Uses fuzzy lookup to handle channelSelector/API name formatting mismatches.
|
|
645
|
+
const cachedEntry = findHuluChannelEntry(normalizedName);
|
|
646
|
+
if (cachedEntry?.rowNumber !== undefined) {
|
|
647
|
+
LOG.debug("tuning:hulu", "Guide cache hit for %s at row %s.", channelName, cachedEntry.rowNumber);
|
|
648
|
+
await scrollToGuideRow(page, gridDocTop, rowHeight, cachedEntry.rowNumber);
|
|
483
649
|
// Read rendered channels to update the cache and confirm the channel is present.
|
|
484
650
|
const rendered = await readRenderedChannels(page);
|
|
485
651
|
if (rendered) {
|
|
@@ -488,9 +654,9 @@ async function guideGridStrategy(page, profile) {
|
|
|
488
654
|
return await clickOnNowCellAndPlay(page, normalizedName, playSelector, channelName);
|
|
489
655
|
}
|
|
490
656
|
}
|
|
491
|
-
// Cache hit but channel not found at expected position. The guide may have changed. Clear
|
|
657
|
+
// Cache hit but channel not found at expected position. The guide may have changed. Clear the row number and fall through to binary search.
|
|
492
658
|
LOG.debug("tuning:hulu", "Guide cache miss for %s. Falling back to binary search.", channelName);
|
|
493
|
-
|
|
659
|
+
cachedEntry.rowNumber = undefined;
|
|
494
660
|
}
|
|
495
661
|
// 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,
|
|
496
662
|
// 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
|
|
@@ -505,8 +671,8 @@ async function guideGridStrategy(page, profile) {
|
|
|
505
671
|
}
|
|
506
672
|
const mid = Math.floor((low + high) / 2);
|
|
507
673
|
// eslint-disable-next-line no-await-in-loop
|
|
508
|
-
await
|
|
509
|
-
// Read all rendered channels, populating the row
|
|
674
|
+
await scrollToGuideRow(page, gridDocTop, rowHeight, mid);
|
|
675
|
+
// Read all rendered channels, populating the unified cache with row numbers as a side effect.
|
|
510
676
|
// eslint-disable-next-line no-await-in-loop
|
|
511
677
|
const rendered = await readRenderedChannels(page);
|
|
512
678
|
if (!rendered || (rendered.length === 0)) {
|
|
@@ -546,28 +712,17 @@ async function guideGridStrategy(page, profile) {
|
|
|
546
712
|
LOG.debug("tuning:hulu", "Inferred local affiliate %s for network name %s.", inferred, channelName);
|
|
547
713
|
clickTarget = inferred;
|
|
548
714
|
found = true;
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
if (inferredRow !== undefined) {
|
|
552
|
-
guideRowCache.set(normalizedName, inferredRow);
|
|
553
|
-
}
|
|
554
|
-
// Cross-reference the UUID cache so the network name can resolve to a warm-cache direct tune on subsequent requests. The details API returns the local
|
|
555
|
-
// call sign as channel_info.name, so the UUID cache is keyed by call sign. The user's channelSelector uses the network name (e.g., "ABC"). Without this
|
|
715
|
+
// Cross-reference the unified cache so the network name resolves to a warm-cache direct tune on subsequent requests. The details API returns the local
|
|
716
|
+
// call sign as channel_info.name, so the cache is keyed by call sign. The user's channelSelector uses the network name (e.g., "ABC"). Without this
|
|
556
717
|
// cross-reference, local affiliates would always fall through to cold-cache guide grid tunes because the names never match.
|
|
557
|
-
const
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
LOG.debug("tuning:hulu", "Cross-referenced
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
if (currentEab) {
|
|
566
|
-
// eslint-disable-next-line no-await-in-loop
|
|
567
|
-
const fastPathSuccess = await tryFastPathTune(page, inferredUuid, currentEab, channelName);
|
|
568
|
-
if (fastPathSuccess) {
|
|
569
|
-
return { success: true };
|
|
570
|
-
}
|
|
718
|
+
const inferredEntry = huluChannelCache.get(inferred);
|
|
719
|
+
if (inferredEntry) {
|
|
720
|
+
huluChannelCache.set(normalizedName, inferredEntry);
|
|
721
|
+
LOG.debug("tuning:hulu", "Cross-referenced cache: %s -> %s (from inferred affiliate %s).", channelName, inferredEntry.uuid ?? "no-uuid", inferred);
|
|
722
|
+
// eslint-disable-next-line no-await-in-loop
|
|
723
|
+
const fastPathSuccess = await tryFastPathTune(page, inferredEntry, channelName);
|
|
724
|
+
if (fastPathSuccess) {
|
|
725
|
+
return { success: true };
|
|
571
726
|
}
|
|
572
727
|
}
|
|
573
728
|
}
|
|
@@ -580,7 +735,7 @@ async function guideGridStrategy(page, profile) {
|
|
|
580
735
|
LOG.debug("tuning:hulu", "Binary search did not find %s. Starting linear scan fallback.", channelName);
|
|
581
736
|
for (let row = 0; row < totalRows; row += 10) {
|
|
582
737
|
// eslint-disable-next-line no-await-in-loop
|
|
583
|
-
await
|
|
738
|
+
await scrollToGuideRow(page, gridDocTop, rowHeight, row);
|
|
584
739
|
// eslint-disable-next-line no-await-in-loop
|
|
585
740
|
const rendered = await readRenderedChannels(page);
|
|
586
741
|
if (!rendered) {
|
|
@@ -594,9 +749,9 @@ async function guideGridStrategy(page, profile) {
|
|
|
594
749
|
}
|
|
595
750
|
}
|
|
596
751
|
if (!found) {
|
|
597
|
-
// Log available channels from the
|
|
598
|
-
// during binary search and linear scan, so it contains most or all channels even though the virtualized grid only renders ~13 at a time.
|
|
599
|
-
const availableChannels = Array.from(
|
|
752
|
+
// Log available channels from the unified cache to help users identify the correct channelSelector value. The cache accumulates all channel names
|
|
753
|
+
// encountered during binary search and linear scan, so it contains most or all channels even though the virtualized grid only renders ~13 at a time.
|
|
754
|
+
const availableChannels = Array.from(huluChannelCache.keys()).sort();
|
|
600
755
|
if (availableChannels.length > 0) {
|
|
601
756
|
logAvailableChannels({
|
|
602
757
|
availableChannels,
|
|
@@ -608,8 +763,17 @@ async function guideGridStrategy(page, profile) {
|
|
|
608
763
|
}
|
|
609
764
|
return { reason: "Could not find channel " + channelName + " in guide grid.", success: false };
|
|
610
765
|
}
|
|
611
|
-
//
|
|
612
|
-
//
|
|
766
|
+
// Fast path: attempt direct tune via unified cache injection or interceptor self-resolution detection. The Channels tab click (before binary search)
|
|
767
|
+
// triggers full Details+Listing API responses. For non-affiliates, the in-page interceptor may have already self-resolved from those responses (capturing
|
|
768
|
+
// UUID+EAB and swapping the playlist autonomously). For channels where the interceptor hasn't self-resolved, the unified cache provides UUID+EAB for
|
|
769
|
+
// injection. Either path avoids the redundant on-now cell click. Note: for affiliates, the inference block above may have already called tryFastPathTune with
|
|
770
|
+
// the same entry — that call is redundant here but harmless (~10ms), and avoiding it with a flag would create a fragile coupling to the inference block.
|
|
771
|
+
const fastPathSuccess = await tryFastPathTune(page, findHuluChannelEntry(normalizedName), channelName);
|
|
772
|
+
if (fastPathSuccess) {
|
|
773
|
+
return { success: true };
|
|
774
|
+
}
|
|
775
|
+
// Release the held playlist before falling through to the click path. Neither tryFastPathTune nor the inference block calls releaseHeldPlaylist — they either
|
|
776
|
+
// inject successfully (resolving the held playlist directly) or return false without side effects. The release is a no-op if the hold was already resolved.
|
|
613
777
|
await releaseHeldPlaylist(page);
|
|
614
778
|
// Click the on-now program cell and wait for the play button, with click retries to handle React hydration timing.
|
|
615
779
|
return await clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName);
|
|
@@ -638,11 +802,11 @@ async function guideGridWithRetry(page, profile) {
|
|
|
638
802
|
return result;
|
|
639
803
|
}
|
|
640
804
|
/**
|
|
641
|
-
* Sets up server-side response interception on the page to capture channel
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* in-page
|
|
645
|
-
*
|
|
805
|
+
* Sets up server-side response interception on the page to capture channel data from Hulu's guide APIs. As the live page loads, Hulu fetches program details from
|
|
806
|
+
* guide.hulu.com/guide/details in batches and program schedules from guide.hulu.com/guide/listing. We intercept both responses to populate the unified channel
|
|
807
|
+
* cache: listing data goes into the staging map (UUID → programs), then details data triggers populateHuluChannelCache which joins name → UUID with staged
|
|
808
|
+
* programs. Also bridges in-page console signals (HULU-DIAG, HULU-CACHE, HULU-FAIL) to the Node.js LOG. Uses a WeakSet to prevent duplicate listener
|
|
809
|
+
* registration.
|
|
646
810
|
* @param page - The Puppeteer page object.
|
|
647
811
|
*/
|
|
648
812
|
function setupDetailsResponseInterception(page) {
|
|
@@ -658,45 +822,56 @@ function setupDetailsResponseInterception(page) {
|
|
|
658
822
|
LOG.debug("tuning:hulu", text);
|
|
659
823
|
}
|
|
660
824
|
// Affiliate UUID capture: the in-page interceptor emits "[HULU-CACHE] targetName=channelUuid" when it observes a playlist passthrough for an affiliate guide
|
|
661
|
-
// grid tune.
|
|
825
|
+
// grid tune. Find the existing entry by UUID and create an alias under the channelSelector key so subsequent tunes resolve via warm direct tuning.
|
|
662
826
|
if (text.startsWith("[HULU-CACHE] ")) {
|
|
663
827
|
const payload = text.slice("[HULU-CACHE] ".length);
|
|
664
828
|
const eqIdx = payload.indexOf("=");
|
|
665
829
|
if (eqIdx > 0) {
|
|
666
830
|
const name = payload.slice(0, eqIdx);
|
|
667
831
|
const channelUuid = payload.slice(eqIdx + 1);
|
|
668
|
-
|
|
669
|
-
|
|
832
|
+
// Find the existing entry by UUID to create an alias (shared object reference). If no entry exists yet, create a new one with programs from staging.
|
|
833
|
+
let existingEntry = null;
|
|
834
|
+
for (const e of huluChannelCache.values()) {
|
|
835
|
+
if (e.uuid === channelUuid) {
|
|
836
|
+
existingEntry = e;
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (existingEntry) {
|
|
841
|
+
huluChannelCache.set(name, existingEntry);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
huluChannelCache.set(name, { displayName: name, programs: huluListingStaging.get(channelUuid), uuid: channelUuid });
|
|
845
|
+
}
|
|
846
|
+
LOG.debug("tuning:hulu", "Cached affiliate UUID from playlist: %s -> %s. Channel cache size: %s.", name, channelUuid, huluChannelCache.size);
|
|
670
847
|
}
|
|
671
848
|
}
|
|
849
|
+
// Direct tune failure: the interceptor's hold period expired without resolving UUID+EAB for the target channel. The 503 response prevents Hulu from playing
|
|
850
|
+
// the wrong channel. The guide grid binary search runs as the fallback. Since all cold tunes go through the guide grid (triggering full API expansion), this
|
|
851
|
+
// path should rarely be reached — it provides defense-in-depth against silent false positives.
|
|
852
|
+
if (text.startsWith("[HULU-FAIL]")) {
|
|
853
|
+
LOG.warn(text);
|
|
854
|
+
}
|
|
672
855
|
});
|
|
673
856
|
page.on("response", (response) => {
|
|
674
857
|
const url = response.url();
|
|
675
858
|
if (response.status() !== 200) {
|
|
676
859
|
return;
|
|
677
860
|
}
|
|
678
|
-
// Details API:
|
|
861
|
+
// Details API: populate the unified channel cache by joining name → UUID from this response with programs from the listing staging map.
|
|
679
862
|
if (url.includes("guide.hulu.com/guide/details")) {
|
|
680
863
|
void response.json().then((data) => {
|
|
681
864
|
if (!Array.isArray(data.items)) {
|
|
682
865
|
return;
|
|
683
866
|
}
|
|
684
|
-
|
|
685
|
-
for (const item of data.items) {
|
|
686
|
-
const info = item.channel_info;
|
|
687
|
-
if (info?.name && info.id) {
|
|
688
|
-
huluUuidCache.set(normalizeChannelName(info.name), info.id);
|
|
689
|
-
channelsSeen.add(info.name);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
LOG.debug("tuning:hulu", "Details API: %s items, %s unique channels. UUID cache size: %s.", data.items.length, channelsSeen.size, huluUuidCache.size);
|
|
867
|
+
populateHuluChannelCache(data.items);
|
|
693
868
|
}).catch(() => {
|
|
694
869
|
// CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
|
|
695
870
|
});
|
|
696
871
|
return;
|
|
697
872
|
}
|
|
698
|
-
// Listing API: capture program schedules (EABs with airing times)
|
|
699
|
-
//
|
|
873
|
+
// Listing API: capture program schedules (EABs with airing times) into the staging map, then propagate fresh programs to existing unified cache entries. The
|
|
874
|
+
// app fires listing requests on every page load covering all ~130 channels with ~8 hours of programs.
|
|
700
875
|
if (url.includes("guide.hulu.com/guide/listing")) {
|
|
701
876
|
void response.json().then((data) => {
|
|
702
877
|
if (!Array.isArray(data.channels)) {
|
|
@@ -705,93 +880,89 @@ function setupDetailsResponseInterception(page) {
|
|
|
705
880
|
let programCount = 0;
|
|
706
881
|
for (const channel of data.channels) {
|
|
707
882
|
if (channel.id && Array.isArray(channel.programs) && (channel.programs.length > 0)) {
|
|
708
|
-
|
|
883
|
+
huluListingStaging.set(channel.id, channel.programs);
|
|
709
884
|
programCount += channel.programs.length;
|
|
710
885
|
}
|
|
711
886
|
}
|
|
712
|
-
|
|
887
|
+
// Propagate fresh programs to existing unified cache entries. Aliases share object references, so updating one entry automatically propagates to all
|
|
888
|
+
// aliases for the same channel.
|
|
889
|
+
for (const entry of huluChannelCache.values()) {
|
|
890
|
+
if (entry.uuid) {
|
|
891
|
+
const freshPrograms = huluListingStaging.get(entry.uuid);
|
|
892
|
+
if (freshPrograms) {
|
|
893
|
+
entry.programs = freshPrograms;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
LOG.debug("tuning:hulu", "Listing API: %s channels, %s programs. Staging size: %s.", data.channels.length, programCount, huluListingStaging.size);
|
|
713
898
|
}).catch(() => {
|
|
714
899
|
// CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
|
|
715
900
|
});
|
|
716
901
|
}
|
|
717
902
|
});
|
|
718
903
|
}
|
|
719
|
-
/**
|
|
720
|
-
* Finds the currently-airing EAB for a channel from the EAB cache. Searches the cached program schedule for the given channel UUID and returns the EAB of the
|
|
721
|
-
* program whose airing window brackets the current time. Returns null if the channel has no cached programs or if no program is currently airing (stale cache
|
|
722
|
-
* or program boundary gap).
|
|
723
|
-
* @param channelUuid - The channel UUID to look up in the EAB cache.
|
|
724
|
-
* @returns The currently-airing EAB string, or null if no match.
|
|
725
|
-
*/
|
|
726
|
-
function findCurrentEab(channelUuid) {
|
|
727
|
-
const programs = huluEabCache.get(channelUuid);
|
|
728
|
-
if (!programs) {
|
|
729
|
-
return null;
|
|
730
|
-
}
|
|
731
|
-
const now = Date.now();
|
|
732
|
-
for (const program of programs) {
|
|
733
|
-
if ((now >= new Date(program.airingStart).getTime()) && (now < new Date(program.airingEnd).getTime())) {
|
|
734
|
-
return program.eab;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
return null;
|
|
738
|
-
}
|
|
739
904
|
/**
|
|
740
905
|
* Resolves a direct URL for Hulu channel tuning and installs a fetch interceptor that handles both warm and cold tunes. On warm cache (UUID and EAB known from
|
|
741
|
-
* previous API responses), the interceptor has both values at install time and swaps the first playlist request immediately. On cold cache,
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
* details API uses call signs that don't match the channelSelector, so the guide grid handles them via position-based inference.
|
|
906
|
+
* previous API responses), the interceptor has both values at install time and swaps the first playlist request immediately. On cold cache (no UUID), returns
|
|
907
|
+
* null so the guide grid runs — the Channels tab click triggers full Details+Listing API expansion for all ~123 channels, and the interceptor captures UUID+EAB
|
|
908
|
+
* from those expanded responses to resolve the held playlist. Without the Channels tab click, the initial page load only provides data for ~10 visible channels.
|
|
909
|
+
* On all tunes, the interceptor also expands listing and details API requests to populate the full cache for future warm tunes.
|
|
746
910
|
* @param channelSelector - The channel selector string (e.g., "Fox", "CNN", "ESPN").
|
|
747
911
|
* @param page - The Puppeteer page for evaluateOnNewDocument installation and response interception setup.
|
|
748
|
-
* @returns The Hulu live URL for direct tuning, or null
|
|
912
|
+
* @returns The Hulu live URL for direct tuning, or null on cold cache (no UUID) or interceptor installation failure.
|
|
749
913
|
*/
|
|
750
914
|
async function resolveHuluDirectUrl(channelSelector, page) {
|
|
751
915
|
const normalizedName = normalizeChannelName(channelSelector);
|
|
752
|
-
const
|
|
753
|
-
|
|
916
|
+
const cachedEntry = findHuluChannelEntry(normalizedName);
|
|
917
|
+
const cachedUuid = cachedEntry?.uuid ?? null;
|
|
918
|
+
// Set up server-side response listeners to populate the unified channel cache. Must be set up before navigation so we capture details and listing API responses
|
|
754
919
|
// during both the guide grid flow (cold cache) and the intercepted page load (warm cache).
|
|
755
920
|
setupDetailsResponseInterception(page);
|
|
756
|
-
// Look up the currently-airing EAB for the target channel (if UUID
|
|
757
|
-
// time and swaps immediately. On cold cache
|
|
758
|
-
//
|
|
759
|
-
const cachedEab =
|
|
921
|
+
// Look up the currently-airing EAB for the target channel (if UUID and programs are known). On warm cache (both UUID and EAB available), the interceptor has
|
|
922
|
+
// both at install time and swaps immediately. On cold cache (no UUID), we return null below so the guide grid runs — the Channels tab click triggers full API
|
|
923
|
+
// expansion.
|
|
924
|
+
const cachedEab = (cachedEntry?.programs) ? findCurrentEabFromPrograms(cachedEntry.programs) : null;
|
|
760
925
|
const isWarmCache = Boolean(cachedUuid && cachedEab);
|
|
761
926
|
if (isWarmCache) {
|
|
762
927
|
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: warm cache for %s (uuid=%s, eab=%s).", channelSelector, cachedUuid, cachedEab);
|
|
763
928
|
}
|
|
764
929
|
else if (!cachedUuid) {
|
|
765
|
-
// On cold cache,
|
|
766
|
-
//
|
|
930
|
+
// On cold cache, all channels fall through to the guide grid — the Channels tab click triggers full Details+Listing API expansion for all ~123 channels.
|
|
931
|
+
// The affiliate/non-affiliate distinction is diagnostic only (both return null). Affiliates use position-based inference; non-affiliates use exact match.
|
|
767
932
|
if (NETWORK_NAMES_WITH_AFFILIATES.has(normalizedName)) {
|
|
768
933
|
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s (local affiliate). Falling through to guide grid.", channelSelector);
|
|
769
934
|
}
|
|
770
935
|
else {
|
|
771
|
-
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s.
|
|
936
|
+
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s. Falling through to guide grid for full API expansion.", channelSelector);
|
|
772
937
|
}
|
|
773
938
|
}
|
|
774
939
|
else {
|
|
775
940
|
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: UUID cached for %s but no current EAB. Attempting direct tune via API interception.", channelSelector);
|
|
776
941
|
}
|
|
777
|
-
// Collect all unique UUIDs from the cache for
|
|
778
|
-
// tunes after the first, this expands
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
// first, this supplements the in-page listing-derived EABs. Empty on the very first cold tune — the interceptor builds EABs dynamically from the listing API
|
|
782
|
-
// response instead.
|
|
942
|
+
// Collect all unique UUIDs and current EABs from the unified cache for API request expansion. On warm tunes, this keeps EAB schedules fresh for all known
|
|
943
|
+
// channels. On cold tunes after the first, this expands requests beyond the mini-guide's ~10 channels. Empty on the very first cold tune.
|
|
944
|
+
const seenEntries = new Set();
|
|
945
|
+
const allCachedUuids = [];
|
|
783
946
|
const allCurrentEabs = [];
|
|
784
|
-
for (const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
947
|
+
for (const entry of huluChannelCache.values()) {
|
|
948
|
+
if (!entry.uuid || seenEntries.has(entry)) {
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
seenEntries.add(entry);
|
|
952
|
+
allCachedUuids.push(entry.uuid);
|
|
953
|
+
if (entry.programs) {
|
|
954
|
+
const currentEab = findCurrentEabFromPrograms(entry.programs);
|
|
955
|
+
if (currentEab) {
|
|
956
|
+
allCurrentEabs.push(currentEab);
|
|
957
|
+
}
|
|
788
958
|
}
|
|
789
959
|
}
|
|
790
960
|
/* Install the fetch interceptor before navigation on both warm and cold tunes. On warm tunes, it swaps channel_id and content_eab_id in playlist requests
|
|
791
|
-
* immediately. On cold
|
|
792
|
-
* tunes, it captures listing API responses to build an in-page EAB map and expands subsequent
|
|
793
|
-
* on a single page load. The script runs via evaluateOnNewDocument — it executes before
|
|
794
|
-
* fetch reference captures the interceptor. Each stream gets its own page via
|
|
961
|
+
* immediately. On cold tunes, the guide grid's Channels tab click triggers full API expansion, and the interceptor holds the playlist request until both
|
|
962
|
+
* UUID and EAB are captured from those responses. On all tunes, it captures listing API responses to build an in-page EAB map and expands subsequent
|
|
963
|
+
* details API requests, populating the UUID cache to ~123 channels on a single page load. The script runs via evaluateOnNewDocument — it executes before
|
|
964
|
+
* any page JavaScript, patching window.fetch so Hulu's module-scoped fetch reference captures the interceptor. Each stream gets its own page via
|
|
965
|
+
* createPageWithCapture(), so there's no persistence concern.
|
|
795
966
|
*/
|
|
796
967
|
try {
|
|
797
968
|
// Always hold the playlist request. On warm cache, the interceptor swaps immediately. On cold cache (including affiliates), the guide grid strategy will either
|
|
@@ -832,18 +1003,23 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
832
1003
|
const directTunePromise = (uuid && eab) ?
|
|
833
1004
|
Promise.resolve() :
|
|
834
1005
|
new Promise((resolve) => { directTuneResolve = resolve; });
|
|
1006
|
+
// Tracks whether the direct tune has been successfully resolved (UUID+EAB available and playlist swap in progress). Set by tryResolveDirectTune when both
|
|
1007
|
+
// values are captured from API responses or injected externally. Warm cache starts resolved. Used by __prismcastIsDirectTuneResolved to let the guide grid
|
|
1008
|
+
// strategy detect that the interceptor already handled the tune autonomously, avoiding a redundant on-now cell click.
|
|
1009
|
+
let directTuneResolved = Boolean(uuid && eab);
|
|
835
1010
|
// Checks whether both UUID and EAB are now known and resolves directTunePromise if so. Called after each successful capture from listing or details API
|
|
836
1011
|
// responses. Order-independent — handles both "listing first, details second" and "details first, listing second" sequences.
|
|
837
1012
|
function tryResolveDirectTune() {
|
|
838
1013
|
if (uuid && eab && directTuneResolve) {
|
|
1014
|
+
directTuneResolved = true;
|
|
839
1015
|
directTuneResolve();
|
|
840
1016
|
directTuneResolve = null;
|
|
841
1017
|
}
|
|
842
1018
|
}
|
|
843
|
-
// Injection endpoint for the guide grid strategy's fast-path
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
1019
|
+
// Injection endpoint for the guide grid strategy's fast-path tune. After binary search identifies the target channel and the unified cache provides the
|
|
1020
|
+
// UUID and EAB, the strategy calls this function via page.evaluate to feed both values into the interceptor. The held playlist request then resumes with the
|
|
1021
|
+
// swapped channel_id and content_eab_id. Returns true if the injection was accepted (directTunePromise not yet resolved), false if the Promise already
|
|
1022
|
+
// resolved (self-resolution from API data, 8s timeout, or a previous injection).
|
|
847
1023
|
window.__prismcastResolveDirectTune = (u, e) => {
|
|
848
1024
|
if (!directTuneResolve) {
|
|
849
1025
|
return false;
|
|
@@ -853,7 +1029,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
853
1029
|
tryResolveDirectTune();
|
|
854
1030
|
return true;
|
|
855
1031
|
};
|
|
856
|
-
// Release endpoint for the guide grid strategy's click-path fallback. When the fast-path injection can't proceed (UUID or EAB not in
|
|
1032
|
+
// Release endpoint for the guide grid strategy's click-path fallback. When the fast-path injection can't proceed (UUID or EAB not in unified cache),
|
|
857
1033
|
// the strategy calls this to unblock the held playlist request and revert to the click-based flow. Sets holdActive to false so the playlist handler follows
|
|
858
1034
|
// the affiliate capture path ([HULU-CACHE]) on subsequent requests from the play button click.
|
|
859
1035
|
window.__prismcastReleasePlaylist = () => {
|
|
@@ -863,6 +1039,10 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
863
1039
|
directTuneResolve = null;
|
|
864
1040
|
}
|
|
865
1041
|
};
|
|
1042
|
+
// Query endpoint for the guide grid strategy to check if the interceptor has already resolved the direct tune. Returns true if the playlist was swapped
|
|
1043
|
+
// (self-resolution from API data, external injection, or warm cache). The guide grid checks this after finding the target channel — if the interceptor
|
|
1044
|
+
// already handled the tune, the on-now cell click is redundant and can be skipped.
|
|
1045
|
+
window.__prismcastIsDirectTuneResolved = () => directTuneResolved;
|
|
866
1046
|
// In-page EAB map built dynamically from listing API responses. On the first cold tune, cachedEabs is empty (no pre-computed data from Node.js), so this
|
|
867
1047
|
// map is the sole source of expansion data for details API requests. Populated asynchronously when the first listing response arrives.
|
|
868
1048
|
const capturedCurrentEabs = new Map();
|
|
@@ -875,7 +1055,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
875
1055
|
listingCapturedResolve = resolve;
|
|
876
1056
|
});
|
|
877
1057
|
// Fire-and-forget: parses a listing API response to build the in-page EAB map. For each channel, finds the currently-airing program by comparing airing
|
|
878
|
-
// times against the current time, mirroring the server-side
|
|
1058
|
+
// times against the current time, mirroring the server-side findCurrentEabFromPrograms() logic. Called on all listing return paths (expanded and passthrough).
|
|
879
1059
|
function captureListingData(response) {
|
|
880
1060
|
try {
|
|
881
1061
|
void response.clone().json().then((data) => {
|
|
@@ -1062,8 +1242,9 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
1062
1242
|
if (url.includes("play.hulu.com") && url.includes("playlist")) {
|
|
1063
1243
|
// Wait for UUID and EAB to be available. On warm cache, directTunePromise resolved immediately at creation time. On cold cache with holdActive
|
|
1064
1244
|
// enabled, this holds the playlist request until either: (1) the guide grid strategy injects UUID+EAB via __prismcastResolveDirectTune (fast path), or
|
|
1065
|
-
// (2) the in-page listing/details parsers capture both values
|
|
1066
|
-
// is false (set by __prismcastReleasePlaylist after fast-path failure), the playlist passes
|
|
1245
|
+
// (2) the in-page listing/details parsers capture both values from the guide grid's Channels tab expansion, or (3) the 8-second timeout expires and the
|
|
1246
|
+
// interceptor returns a 503 (safety net). When holdActive is false (set by __prismcastReleasePlaylist after fast-path failure), the playlist passes
|
|
1247
|
+
// through with affiliate UUID capture for the click fallback.
|
|
1067
1248
|
if (holdActive) {
|
|
1068
1249
|
await Promise.race([directTunePromise, new Promise((resolve) => { setTimeout(resolve, 8000); })]);
|
|
1069
1250
|
}
|
|
@@ -1093,8 +1274,8 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
1093
1274
|
return originalFetch(input, init);
|
|
1094
1275
|
}
|
|
1095
1276
|
// eslint-disable-next-line no-console
|
|
1096
|
-
console.log("[HULU-
|
|
1097
|
-
return
|
|
1277
|
+
console.log("[HULU-FAIL] Direct tune failed for " + targetName + ": uuid/eab not resolved within hold period.");
|
|
1278
|
+
return new Response("", { status: 503 });
|
|
1098
1279
|
}
|
|
1099
1280
|
const bodyText = await getBodyText(input, init);
|
|
1100
1281
|
if (bodyText) {
|
|
@@ -1124,27 +1305,172 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
1124
1305
|
LOG.debug("tuning:hulu", "Failed to install Hulu fetch interceptor: %s.", formatError(error));
|
|
1125
1306
|
return null;
|
|
1126
1307
|
}
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
if (!cachedUuid
|
|
1308
|
+
// On cold cache (no UUID), fall through to the guide grid. The initial page load only provides Details API data for ~10 visible channels — the guide grid's
|
|
1309
|
+
// Channels tab click triggers full expansion for all ~123 channels, and the interceptor captures UUID+EAB from those expanded responses to resolve the held
|
|
1310
|
+
// playlist. Affiliates additionally need position-based inference because their channel_info.name uses call signs rather than network names.
|
|
1311
|
+
if (!cachedUuid) {
|
|
1131
1312
|
return null;
|
|
1132
1313
|
}
|
|
1133
1314
|
LOG.debug("tuning:hulu", "Fetch interceptor installed. Returning URL: %s.", HULU_LIVE_URL);
|
|
1134
1315
|
return HULU_LIVE_URL;
|
|
1135
1316
|
}
|
|
1136
1317
|
/**
|
|
1137
|
-
* Invalidates the cached
|
|
1138
|
-
*
|
|
1318
|
+
* Invalidates the cached entry for the given channel selector. Called when a cached direct URL fails to produce a working stream, so the next tune attempts the
|
|
1319
|
+
* cold cache path (details API extraction) or falls back to the guide grid. Uses resolveHuluCacheKey for fuzzy name matching so invalidation works even when the
|
|
1320
|
+
* channelSelector format doesn't exactly match the API-derived key. Deletes the specific key without affecting entries that share the same object reference
|
|
1321
|
+
* via aliasing.
|
|
1139
1322
|
* @param channelSelector - The channel selector string to invalidate.
|
|
1140
1323
|
*/
|
|
1141
1324
|
function invalidateHuluDirectUrl(channelSelector) {
|
|
1142
|
-
|
|
1325
|
+
const key = resolveHuluCacheKey(normalizeChannelName(channelSelector));
|
|
1326
|
+
if (key) {
|
|
1327
|
+
huluChannelCache.delete(key);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Discovers all channels from Hulu Live TV by clicking the Channels tab to trigger full API expansion and performing a complete linear scan through the
|
|
1332
|
+
* virtualized guide grid. The route has already navigated to the Hulu live page. Detects local affiliates using the same CALL_SIGN_PATTERN and position-based
|
|
1333
|
+
* inference logic as the tuning strategy. Affiliates get the network name as their selector; non-affiliates get their display name. Enriches unified cache
|
|
1334
|
+
* entries with affiliate metadata for subsequent getCachedChannels derivation.
|
|
1335
|
+
* @param page - The Puppeteer page object, already on the Hulu live page (navigated by the route handler).
|
|
1336
|
+
* @returns Array of discovered channels with affiliate detection and selector mapping.
|
|
1337
|
+
*/
|
|
1338
|
+
async function discoverHuluChannels(page) {
|
|
1339
|
+
// Return from the unified cache if a full discovery walk (with affiliate inference) has already completed.
|
|
1340
|
+
if (huluFullyDiscovered && (huluChannelCache.size > 0)) {
|
|
1341
|
+
return buildHuluDiscoveredChannels();
|
|
1342
|
+
}
|
|
1343
|
+
// Set up response interception BEFORE navigation so we capture the initial details and listing API responses during page load. These responses populate the
|
|
1344
|
+
// unified channel cache with UUID, programs, and display names for all ~130 channels — warming the tuning cache as a side effect of discovery. The same
|
|
1345
|
+
// setupDetailsResponseInterception function used by the tuning path ensures a single code path for all API response processing.
|
|
1346
|
+
setupDetailsResponseInterception(page);
|
|
1347
|
+
try {
|
|
1348
|
+
await page.goto(HULU_LIVE_URL, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "networkidle2" });
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
return [];
|
|
1352
|
+
}
|
|
1353
|
+
// Click the Channels tab to reveal the channel list and trigger full API expansion. Matches the tuning path's retry logic — if guide rows don't appear after
|
|
1354
|
+
// the first tab click, retry once with a longer delay in case the first click fired during a transitional state before the guide was fully interactive.
|
|
1355
|
+
const listSelector = "#CHANNELS";
|
|
1356
|
+
try {
|
|
1357
|
+
await page.waitForSelector(listSelector, { timeout: CONFIG.streaming.videoTimeout, visible: true });
|
|
1358
|
+
await page.$eval(listSelector, (el) => { el.click(); });
|
|
1359
|
+
await delay(300);
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
return [];
|
|
1363
|
+
}
|
|
1364
|
+
let rowsVisible = false;
|
|
1365
|
+
for (let guideAttempt = 0; guideAttempt < 2; guideAttempt++) {
|
|
1366
|
+
try {
|
|
1367
|
+
const rowTimeout = (guideAttempt === 0) ? 5000 : CONFIG.streaming.videoTimeout;
|
|
1368
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1369
|
+
await page.waitForSelector("[data-testid=\"live-guide-row\"]", { timeout: rowTimeout, visible: true });
|
|
1370
|
+
rowsVisible = true;
|
|
1371
|
+
break;
|
|
1372
|
+
}
|
|
1373
|
+
catch {
|
|
1374
|
+
// Rows not visible yet. On first failure, retry the tab click in case the guide wasn't fully interactive.
|
|
1375
|
+
if (guideAttempt === 0) {
|
|
1376
|
+
try {
|
|
1377
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1378
|
+
await page.$eval(listSelector, (el) => { el.click(); });
|
|
1379
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1380
|
+
await delay(500);
|
|
1381
|
+
}
|
|
1382
|
+
catch {
|
|
1383
|
+
// Retry click failed. Fall through to final wait attempt.
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (!rowsVisible) {
|
|
1389
|
+
return [];
|
|
1390
|
+
}
|
|
1391
|
+
// Read grid metadata to determine total rows and scroll offset. Reuses the shared readGridMeta helper.
|
|
1392
|
+
const gridMeta = await readGridMeta(page);
|
|
1393
|
+
if (!gridMeta) {
|
|
1394
|
+
return [];
|
|
1395
|
+
}
|
|
1396
|
+
const { gridDocTop, rowHeight, totalRows } = gridMeta;
|
|
1397
|
+
// Linear scan through the entire guide grid to collect all channels. Step by 10 rows (~the virtualizer render window) to cover the full list.
|
|
1398
|
+
const allChannels = [];
|
|
1399
|
+
const seenNames = new Set();
|
|
1400
|
+
for (let row = 0; row < totalRows; row += 10) {
|
|
1401
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1402
|
+
await scrollToGuideRow(page, gridDocTop, rowHeight, row);
|
|
1403
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1404
|
+
const rendered = await readRenderedChannels(page);
|
|
1405
|
+
if (!rendered) {
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
for (const ch of rendered) {
|
|
1409
|
+
if (!seenNames.has(ch.name)) {
|
|
1410
|
+
seenNames.add(ch.name);
|
|
1411
|
+
allChannels.push(ch);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// Reassign sequential domIndex values across the full accumulated list so that inferLocalAffiliate's position-based logic works correctly. The original
|
|
1416
|
+
// domIndex values are from individual scroll windows (0-12) and are not meaningful across the full channel list.
|
|
1417
|
+
const indexedChannels = allChannels.map((ch, i) => ({ ...ch, domIndex: i }));
|
|
1418
|
+
// Build a callSign → networkName map by reusing inferLocalAffiliate for each broadcast network. This is the same position-based inference the tuning strategy
|
|
1419
|
+
// uses during binary search — a call sign channel occupies the alphabetical position where its network name would sort.
|
|
1420
|
+
const affiliateMap = new Map();
|
|
1421
|
+
for (const network of NETWORK_NAMES_WITH_AFFILIATES) {
|
|
1422
|
+
const callSign = inferLocalAffiliate(indexedChannels, network);
|
|
1423
|
+
if (callSign) {
|
|
1424
|
+
affiliateMap.set(callSign, network.toUpperCase());
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// Enrich unified cache entries with affiliate metadata so buildHuluDiscoveredChannels can derive proper affiliate labeling on subsequent getCachedChannels calls.
|
|
1428
|
+
for (const [callSign, network] of affiliateMap) {
|
|
1429
|
+
const entry = huluChannelCache.get(callSign);
|
|
1430
|
+
if (entry) {
|
|
1431
|
+
entry.affiliate = network;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
// Build discovery results from the walk data. The unified cache may have incomplete entries on cold start (no API data), so we build from the walk results
|
|
1435
|
+
// directly to ensure all channels are included.
|
|
1436
|
+
const discovered = indexedChannels.map((ch) => {
|
|
1437
|
+
const network = affiliateMap.get(ch.name);
|
|
1438
|
+
if (network) {
|
|
1439
|
+
return { affiliate: network, channelSelector: network, name: ch.displayName };
|
|
1440
|
+
}
|
|
1441
|
+
return { channelSelector: ch.displayName, name: ch.displayName };
|
|
1442
|
+
});
|
|
1443
|
+
// Do not cache empty results — leave the flag false so subsequent calls retry the full walk. Empty results can indicate no Hulu + Live TV subscription.
|
|
1444
|
+
if (discovered.length > 0) {
|
|
1445
|
+
discovered.sort((a, b) => a.name.localeCompare(b.name));
|
|
1446
|
+
huluFullyDiscovered = true;
|
|
1447
|
+
}
|
|
1448
|
+
return discovered;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Returns cached discovered channels from the unified channel cache, or null if a full discovery walk (with affiliate position inference) has not yet completed.
|
|
1452
|
+
* Derives the result on the fly from unified cache entries, deduplicating aliases via Set reference equality.
|
|
1453
|
+
* @returns Sorted array of discovered channels or null.
|
|
1454
|
+
*/
|
|
1455
|
+
function getHuluCachedChannels() {
|
|
1456
|
+
if (!huluFullyDiscovered || (huluChannelCache.size === 0)) {
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
1459
|
+
return buildHuluDiscoveredChannels();
|
|
1143
1460
|
}
|
|
1144
|
-
export const
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1461
|
+
export const huluProvider = {
|
|
1462
|
+
discoverChannels: discoverHuluChannels,
|
|
1463
|
+
getCachedChannels: getHuluCachedChannels,
|
|
1464
|
+
guideUrl: "https://www.hulu.com/live",
|
|
1465
|
+
handlesOwnNavigation: true,
|
|
1466
|
+
label: "Hulu",
|
|
1467
|
+
slug: "hulu",
|
|
1468
|
+
strategy: {
|
|
1469
|
+
clearCache: clearHuluCache,
|
|
1470
|
+
execute: guideGridWithRetry,
|
|
1471
|
+
invalidateDirectUrl: invalidateHuluDirectUrl,
|
|
1472
|
+
resolveDirectUrl: resolveHuluDirectUrl
|
|
1473
|
+
},
|
|
1474
|
+
strategyName: "guideGrid"
|
|
1149
1475
|
};
|
|
1150
1476
|
//# sourceMappingURL=hulu.js.map
|