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
|
@@ -1,16 +1,16 @@
|
|
|
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
|
|
@@ -18,17 +18,115 @@ const huluPagesWithListeners = new WeakSet();
|
|
|
18
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,10 +479,10 @@ 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 a fast-path tune by either injecting UUID+EAB from
|
|
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
|
|
319
483
|
* self-resolved from in-page API data. Two resolution mechanisms handle different channel types:
|
|
320
484
|
*
|
|
321
|
-
* 1. Server-side injection:
|
|
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
|
|
322
486
|
* held playlist request. Primary mechanism for local affiliates whose call signs don't match the channelSelector network name — the interceptor can't
|
|
323
487
|
* self-resolve by name for these, so external injection is the only path.
|
|
324
488
|
* 2. Self-resolution detection: queries __prismcastIsDirectTuneResolved to check if the interceptor already captured UUID+EAB from the expanded
|
|
@@ -327,17 +491,17 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
|
|
|
327
491
|
*
|
|
328
492
|
* On success, dismisses the guide overlay so the video player is visible for capture.
|
|
329
493
|
* @param page - The Puppeteer page object.
|
|
330
|
-
* @param
|
|
494
|
+
* @param entry - The unified cache entry for the target channel, or null if not yet available.
|
|
331
495
|
* @param channelName - The original channel name for logging.
|
|
332
496
|
* @returns True if the tune was resolved (via injection or self-resolution), false otherwise.
|
|
333
497
|
*/
|
|
334
|
-
async function tryFastPathTune(page,
|
|
498
|
+
async function tryFastPathTune(page, entry, channelName) {
|
|
335
499
|
let resolved = false;
|
|
336
500
|
let resolveDetail = "";
|
|
337
|
-
// Phase 1: If UUID
|
|
338
|
-
// mechanism for local affiliates (where the interceptor can't self-resolve by name) and a secondary mechanism for exact-match channels.
|
|
339
|
-
if (
|
|
340
|
-
const currentEab =
|
|
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);
|
|
341
505
|
if (currentEab) {
|
|
342
506
|
const injected = await page.evaluate((u, e) => {
|
|
343
507
|
const resolver = window.__prismcastResolveDirectTune;
|
|
@@ -345,10 +509,10 @@ async function tryFastPathTune(page, channelUuid, channelName) {
|
|
|
345
509
|
return resolver(u, e);
|
|
346
510
|
}
|
|
347
511
|
return false;
|
|
348
|
-
},
|
|
512
|
+
}, entry.uuid, currentEab);
|
|
349
513
|
if (injected) {
|
|
350
514
|
resolved = true;
|
|
351
|
-
resolveDetail = "uuid=" +
|
|
515
|
+
resolveDetail = "uuid=" + entry.uuid;
|
|
352
516
|
}
|
|
353
517
|
}
|
|
354
518
|
}
|
|
@@ -410,7 +574,7 @@ async function releaseHeldPlaylist(page) {
|
|
|
410
574
|
* The selection process:
|
|
411
575
|
* 1. If listSelector is provided, click the tab/button to reveal the channel list (e.g., a "Channels" tab)
|
|
412
576
|
* 2. Wait for the channel grid rows to render in the DOM
|
|
413
|
-
* 3. Check the
|
|
577
|
+
* 3. Check the unified cache for a row number direct-scroll shortcut
|
|
414
578
|
* 4. Binary search: scroll to the midpoint row, read rendered channels (caching row numbers), check for exact match or infer local affiliate
|
|
415
579
|
* 5. If binary search fails, linear scan from top to bottom as a universal fallback
|
|
416
580
|
* 6. Click the on-now program cell (`.LiveGuideProgram--first`) in the target channel's row to open the playback overlay
|
|
@@ -467,53 +631,21 @@ async function guideGridStrategy(page, profile) {
|
|
|
467
631
|
if (!rowsVisible) {
|
|
468
632
|
return { reason: "Channel grid rows did not render.", success: false };
|
|
469
633
|
}
|
|
470
|
-
// Each row in the virtualized grid is exactly 112px tall. The total number of channels is derived from the spacer div's height.
|
|
471
|
-
const ROW_HEIGHT = 112;
|
|
472
634
|
// Normalize the channel name to lowercase for case-insensitive matching against data-testid suffixes.
|
|
473
635
|
const normalizedName = normalizeChannelName(channelName);
|
|
474
|
-
|
|
475
|
-
// 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
|
|
476
|
-
// document-level offset, so that scrolling to gridDocTop + (rowIndex * ROW_HEIGHT) places that row at the top of the browser viewport.
|
|
477
|
-
const gridMeta = await page.evaluate((rowHeight) => {
|
|
478
|
-
const row = document.querySelector("[data-testid=\"live-guide-row\"]");
|
|
479
|
-
if (!row) {
|
|
480
|
-
return null;
|
|
481
|
-
}
|
|
482
|
-
// The spacer div is the parent of all row elements.
|
|
483
|
-
const spacer = row.parentElement;
|
|
484
|
-
if (!spacer) {
|
|
485
|
-
return null;
|
|
486
|
-
}
|
|
487
|
-
const spacerHeight = spacer.offsetHeight;
|
|
488
|
-
if (spacerHeight < rowHeight) {
|
|
489
|
-
return null;
|
|
490
|
-
}
|
|
491
|
-
// The viewport div is the spacer's parent. Its position relative to the document determines our scroll offset.
|
|
492
|
-
const viewport = spacer.parentElement;
|
|
493
|
-
if (!viewport) {
|
|
494
|
-
return null;
|
|
495
|
-
}
|
|
496
|
-
const gridDocTop = viewport.getBoundingClientRect().top + document.documentElement.scrollTop;
|
|
497
|
-
return { gridDocTop, totalRows: Math.round(spacerHeight / rowHeight) };
|
|
498
|
-
}, ROW_HEIGHT);
|
|
636
|
+
const gridMeta = await readGridMeta(page);
|
|
499
637
|
if (!gridMeta) {
|
|
500
638
|
return { reason: "Could not locate channel grid spacer element.", success: false };
|
|
501
639
|
}
|
|
502
|
-
const { gridDocTop, totalRows } = gridMeta;
|
|
503
|
-
// Helper: scroll to a specific row index and wait for the virtualizer to render.
|
|
504
|
-
const scrollToRow = async (rowIndex) => {
|
|
505
|
-
await page.evaluate((scrollTo) => {
|
|
506
|
-
document.documentElement.scrollTop = scrollTo;
|
|
507
|
-
}, gridDocTop + (rowIndex * ROW_HEIGHT));
|
|
508
|
-
await delay(200);
|
|
509
|
-
};
|
|
640
|
+
const { gridDocTop, rowHeight, totalRows } = gridMeta;
|
|
510
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.
|
|
511
642
|
let clickTarget = normalizedName;
|
|
512
|
-
// Check the
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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);
|
|
517
649
|
// Read rendered channels to update the cache and confirm the channel is present.
|
|
518
650
|
const rendered = await readRenderedChannels(page);
|
|
519
651
|
if (rendered) {
|
|
@@ -522,9 +654,9 @@ async function guideGridStrategy(page, profile) {
|
|
|
522
654
|
return await clickOnNowCellAndPlay(page, normalizedName, playSelector, channelName);
|
|
523
655
|
}
|
|
524
656
|
}
|
|
525
|
-
// 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.
|
|
526
658
|
LOG.debug("tuning:hulu", "Guide cache miss for %s. Falling back to binary search.", channelName);
|
|
527
|
-
|
|
659
|
+
cachedEntry.rowNumber = undefined;
|
|
528
660
|
}
|
|
529
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,
|
|
530
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
|
|
@@ -539,8 +671,8 @@ async function guideGridStrategy(page, profile) {
|
|
|
539
671
|
}
|
|
540
672
|
const mid = Math.floor((low + high) / 2);
|
|
541
673
|
// eslint-disable-next-line no-await-in-loop
|
|
542
|
-
await
|
|
543
|
-
// 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.
|
|
544
676
|
// eslint-disable-next-line no-await-in-loop
|
|
545
677
|
const rendered = await readRenderedChannels(page);
|
|
546
678
|
if (!rendered || (rendered.length === 0)) {
|
|
@@ -580,20 +712,15 @@ async function guideGridStrategy(page, profile) {
|
|
|
580
712
|
LOG.debug("tuning:hulu", "Inferred local affiliate %s for network name %s.", inferred, channelName);
|
|
581
713
|
clickTarget = inferred;
|
|
582
714
|
found = true;
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
if (inferredRow !== undefined) {
|
|
586
|
-
guideRowCache.set(normalizedName, inferredRow);
|
|
587
|
-
}
|
|
588
|
-
// 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
|
|
589
|
-
// 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
|
|
590
717
|
// cross-reference, local affiliates would always fall through to cold-cache guide grid tunes because the names never match.
|
|
591
|
-
const
|
|
592
|
-
if (
|
|
593
|
-
|
|
594
|
-
LOG.debug("tuning:hulu", "Cross-referenced
|
|
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);
|
|
595
722
|
// eslint-disable-next-line no-await-in-loop
|
|
596
|
-
const fastPathSuccess = await tryFastPathTune(page,
|
|
723
|
+
const fastPathSuccess = await tryFastPathTune(page, inferredEntry, channelName);
|
|
597
724
|
if (fastPathSuccess) {
|
|
598
725
|
return { success: true };
|
|
599
726
|
}
|
|
@@ -608,7 +735,7 @@ async function guideGridStrategy(page, profile) {
|
|
|
608
735
|
LOG.debug("tuning:hulu", "Binary search did not find %s. Starting linear scan fallback.", channelName);
|
|
609
736
|
for (let row = 0; row < totalRows; row += 10) {
|
|
610
737
|
// eslint-disable-next-line no-await-in-loop
|
|
611
|
-
await
|
|
738
|
+
await scrollToGuideRow(page, gridDocTop, rowHeight, row);
|
|
612
739
|
// eslint-disable-next-line no-await-in-loop
|
|
613
740
|
const rendered = await readRenderedChannels(page);
|
|
614
741
|
if (!rendered) {
|
|
@@ -622,9 +749,9 @@ async function guideGridStrategy(page, profile) {
|
|
|
622
749
|
}
|
|
623
750
|
}
|
|
624
751
|
if (!found) {
|
|
625
|
-
// Log available channels from the
|
|
626
|
-
// during binary search and linear scan, so it contains most or all channels even though the virtualized grid only renders ~13 at a time.
|
|
627
|
-
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();
|
|
628
755
|
if (availableChannels.length > 0) {
|
|
629
756
|
logAvailableChannels({
|
|
630
757
|
availableChannels,
|
|
@@ -636,12 +763,12 @@ async function guideGridStrategy(page, profile) {
|
|
|
636
763
|
}
|
|
637
764
|
return { reason: "Could not find channel " + channelName + " in guide grid.", success: false };
|
|
638
765
|
}
|
|
639
|
-
// Fast path: attempt direct tune via
|
|
766
|
+
// Fast path: attempt direct tune via unified cache injection or interceptor self-resolution detection. The Channels tab click (before binary search)
|
|
640
767
|
// triggers full Details+Listing API responses. For non-affiliates, the in-page interceptor may have already self-resolved from those responses (capturing
|
|
641
|
-
// UUID+EAB and swapping the playlist autonomously). For channels where the interceptor hasn't self-resolved,
|
|
768
|
+
// UUID+EAB and swapping the playlist autonomously). For channels where the interceptor hasn't self-resolved, the unified cache provides UUID+EAB for
|
|
642
769
|
// injection. Either path avoids the redundant on-now cell click. Note: for affiliates, the inference block above may have already called tryFastPathTune with
|
|
643
|
-
// the same
|
|
644
|
-
const fastPathSuccess = await tryFastPathTune(page,
|
|
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);
|
|
645
772
|
if (fastPathSuccess) {
|
|
646
773
|
return { success: true };
|
|
647
774
|
}
|
|
@@ -675,11 +802,11 @@ async function guideGridWithRetry(page, profile) {
|
|
|
675
802
|
return result;
|
|
676
803
|
}
|
|
677
804
|
/**
|
|
678
|
-
* Sets up server-side response interception on the page to capture channel
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
* in-page
|
|
682
|
-
*
|
|
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.
|
|
683
810
|
* @param page - The Puppeteer page object.
|
|
684
811
|
*/
|
|
685
812
|
function setupDetailsResponseInterception(page) {
|
|
@@ -695,15 +822,28 @@ function setupDetailsResponseInterception(page) {
|
|
|
695
822
|
LOG.debug("tuning:hulu", text);
|
|
696
823
|
}
|
|
697
824
|
// Affiliate UUID capture: the in-page interceptor emits "[HULU-CACHE] targetName=channelUuid" when it observes a playlist passthrough for an affiliate guide
|
|
698
|
-
// 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.
|
|
699
826
|
if (text.startsWith("[HULU-CACHE] ")) {
|
|
700
827
|
const payload = text.slice("[HULU-CACHE] ".length);
|
|
701
828
|
const eqIdx = payload.indexOf("=");
|
|
702
829
|
if (eqIdx > 0) {
|
|
703
830
|
const name = payload.slice(0, eqIdx);
|
|
704
831
|
const channelUuid = payload.slice(eqIdx + 1);
|
|
705
|
-
|
|
706
|
-
|
|
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);
|
|
707
847
|
}
|
|
708
848
|
}
|
|
709
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
|
|
@@ -718,28 +858,20 @@ function setupDetailsResponseInterception(page) {
|
|
|
718
858
|
if (response.status() !== 200) {
|
|
719
859
|
return;
|
|
720
860
|
}
|
|
721
|
-
// Details API:
|
|
861
|
+
// Details API: populate the unified channel cache by joining name → UUID from this response with programs from the listing staging map.
|
|
722
862
|
if (url.includes("guide.hulu.com/guide/details")) {
|
|
723
863
|
void response.json().then((data) => {
|
|
724
864
|
if (!Array.isArray(data.items)) {
|
|
725
865
|
return;
|
|
726
866
|
}
|
|
727
|
-
|
|
728
|
-
for (const item of data.items) {
|
|
729
|
-
const info = item.channel_info;
|
|
730
|
-
if (info?.name && info.id) {
|
|
731
|
-
huluUuidCache.set(normalizeChannelName(info.name), info.id);
|
|
732
|
-
channelsSeen.add(info.name);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
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);
|
|
736
868
|
}).catch(() => {
|
|
737
869
|
// CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
|
|
738
870
|
});
|
|
739
871
|
return;
|
|
740
872
|
}
|
|
741
|
-
// Listing API: capture program schedules (EABs with airing times)
|
|
742
|
-
//
|
|
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.
|
|
743
875
|
if (url.includes("guide.hulu.com/guide/listing")) {
|
|
744
876
|
void response.json().then((data) => {
|
|
745
877
|
if (!Array.isArray(data.channels)) {
|
|
@@ -748,56 +880,48 @@ function setupDetailsResponseInterception(page) {
|
|
|
748
880
|
let programCount = 0;
|
|
749
881
|
for (const channel of data.channels) {
|
|
750
882
|
if (channel.id && Array.isArray(channel.programs) && (channel.programs.length > 0)) {
|
|
751
|
-
|
|
883
|
+
huluListingStaging.set(channel.id, channel.programs);
|
|
752
884
|
programCount += channel.programs.length;
|
|
753
885
|
}
|
|
754
886
|
}
|
|
755
|
-
|
|
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);
|
|
756
898
|
}).catch(() => {
|
|
757
899
|
// CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
|
|
758
900
|
});
|
|
759
901
|
}
|
|
760
902
|
});
|
|
761
903
|
}
|
|
762
|
-
/**
|
|
763
|
-
* 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
|
|
764
|
-
* 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
|
|
765
|
-
* or program boundary gap).
|
|
766
|
-
* @param channelUuid - The channel UUID to look up in the EAB cache.
|
|
767
|
-
* @returns The currently-airing EAB string, or null if no match.
|
|
768
|
-
*/
|
|
769
|
-
function findCurrentEab(channelUuid) {
|
|
770
|
-
const programs = huluEabCache.get(channelUuid);
|
|
771
|
-
if (!programs) {
|
|
772
|
-
return null;
|
|
773
|
-
}
|
|
774
|
-
const now = Date.now();
|
|
775
|
-
for (const program of programs) {
|
|
776
|
-
if ((now >= new Date(program.airingStart).getTime()) && (now < new Date(program.airingEnd).getTime())) {
|
|
777
|
-
return program.eab;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
return null;
|
|
781
|
-
}
|
|
782
904
|
/**
|
|
783
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
|
|
784
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
|
|
785
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
|
|
786
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.
|
|
787
|
-
* On all tunes, the interceptor also expands listing and details API requests to populate the full
|
|
909
|
+
* On all tunes, the interceptor also expands listing and details API requests to populate the full cache for future warm tunes.
|
|
788
910
|
* @param channelSelector - The channel selector string (e.g., "Fox", "CNN", "ESPN").
|
|
789
911
|
* @param page - The Puppeteer page for evaluateOnNewDocument installation and response interception setup.
|
|
790
912
|
* @returns The Hulu live URL for direct tuning, or null on cold cache (no UUID) or interceptor installation failure.
|
|
791
913
|
*/
|
|
792
914
|
async function resolveHuluDirectUrl(channelSelector, page) {
|
|
793
915
|
const normalizedName = normalizeChannelName(channelSelector);
|
|
794
|
-
const
|
|
795
|
-
|
|
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
|
|
796
919
|
// during both the guide grid flow (cold cache) and the intercepted page load (warm cache).
|
|
797
920
|
setupDetailsResponseInterception(page);
|
|
798
|
-
// Look up the currently-airing EAB for the target channel (if UUID
|
|
799
|
-
// 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
|
|
800
|
-
|
|
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;
|
|
801
925
|
const isWarmCache = Boolean(cachedUuid && cachedEab);
|
|
802
926
|
if (isWarmCache) {
|
|
803
927
|
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: warm cache for %s (uuid=%s, eab=%s).", channelSelector, cachedUuid, cachedEab);
|
|
@@ -815,17 +939,22 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
815
939
|
else {
|
|
816
940
|
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: UUID cached for %s but no current EAB. Attempting direct tune via API interception.", channelSelector);
|
|
817
941
|
}
|
|
818
|
-
// Collect all unique UUIDs from the cache for
|
|
819
|
-
// tunes after the first, this expands
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
// 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
|
|
823
|
-
// 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 = [];
|
|
824
946
|
const allCurrentEabs = [];
|
|
825
|
-
for (const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
+
}
|
|
829
958
|
}
|
|
830
959
|
}
|
|
831
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
|
|
@@ -887,7 +1016,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
887
1016
|
directTuneResolve = null;
|
|
888
1017
|
}
|
|
889
1018
|
}
|
|
890
|
-
// Injection endpoint for the guide grid strategy's fast-path tune. After binary search identifies the target channel and the
|
|
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
|
|
891
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
|
|
892
1021
|
// swapped channel_id and content_eab_id. Returns true if the injection was accepted (directTunePromise not yet resolved), false if the Promise already
|
|
893
1022
|
// resolved (self-resolution from API data, 8s timeout, or a previous injection).
|
|
@@ -900,7 +1029,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
900
1029
|
tryResolveDirectTune();
|
|
901
1030
|
return true;
|
|
902
1031
|
};
|
|
903
|
-
// 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),
|
|
904
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
|
|
905
1034
|
// the affiliate capture path ([HULU-CACHE]) on subsequent requests from the play button click.
|
|
906
1035
|
window.__prismcastReleasePlaylist = () => {
|
|
@@ -926,7 +1055,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
926
1055
|
listingCapturedResolve = resolve;
|
|
927
1056
|
});
|
|
928
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
|
|
929
|
-
// 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).
|
|
930
1059
|
function captureListingData(response) {
|
|
931
1060
|
try {
|
|
932
1061
|
void response.clone().json().then((data) => {
|
|
@@ -1186,17 +1315,162 @@ async function resolveHuluDirectUrl(channelSelector, page) {
|
|
|
1186
1315
|
return HULU_LIVE_URL;
|
|
1187
1316
|
}
|
|
1188
1317
|
/**
|
|
1189
|
-
* Invalidates the cached
|
|
1190
|
-
*
|
|
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.
|
|
1191
1322
|
* @param channelSelector - The channel selector string to invalidate.
|
|
1192
1323
|
*/
|
|
1193
1324
|
function invalidateHuluDirectUrl(channelSelector) {
|
|
1194
|
-
|
|
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();
|
|
1195
1460
|
}
|
|
1196
|
-
export const
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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"
|
|
1201
1475
|
};
|
|
1202
1476
|
//# sourceMappingURL=hulu.js.map
|