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.
Files changed (178) hide show
  1. package/dist/app.js +18 -1
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/cdp.d.ts +2 -1
  4. package/dist/browser/cdp.js +44 -23
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +45 -1
  7. package/dist/browser/channelSelection.js +164 -27
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/display.d.ts +0 -4
  10. package/dist/browser/display.js +0 -7
  11. package/dist/browser/display.js.map +1 -1
  12. package/dist/browser/index.d.ts +5 -0
  13. package/dist/browser/index.js +14 -0
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/precaching.d.ts +5 -0
  16. package/dist/browser/precaching.js +112 -0
  17. package/dist/browser/precaching.js.map +1 -0
  18. package/dist/browser/tuning/directv.d.ts +2 -0
  19. package/dist/browser/tuning/directv.js +723 -0
  20. package/dist/browser/tuning/directv.js.map +1 -0
  21. package/dist/browser/tuning/fox.d.ts +2 -2
  22. package/dist/browser/tuning/fox.js +141 -22
  23. package/dist/browser/tuning/fox.js.map +1 -1
  24. package/dist/browser/tuning/hbo.d.ts +2 -2
  25. package/dist/browser/tuning/hbo.js +110 -41
  26. package/dist/browser/tuning/hbo.js.map +1 -1
  27. package/dist/browser/tuning/hulu.d.ts +2 -2
  28. package/dist/browser/tuning/hulu.js +442 -168
  29. package/dist/browser/tuning/hulu.js.map +1 -1
  30. package/dist/browser/tuning/sling.d.ts +2 -2
  31. package/dist/browser/tuning/sling.js +200 -56
  32. package/dist/browser/tuning/sling.js.map +1 -1
  33. package/dist/browser/tuning/youtubeTv.d.ts +2 -2
  34. package/dist/browser/tuning/youtubeTv.js +243 -57
  35. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  36. package/dist/browser/video.d.ts +1 -1
  37. package/dist/browser/video.js +4 -2
  38. package/dist/browser/video.js.map +1 -1
  39. package/dist/channels/index.js +280 -34
  40. package/dist/channels/index.js.map +1 -1
  41. package/dist/config/health.d.ts +78 -0
  42. package/dist/config/health.js +202 -0
  43. package/dist/config/health.js.map +1 -0
  44. package/dist/config/paths.d.ts +10 -0
  45. package/dist/config/paths.js +14 -0
  46. package/dist/config/paths.js.map +1 -1
  47. package/dist/config/profiles.d.ts +6 -4
  48. package/dist/config/profiles.js +65 -14
  49. package/dist/config/profiles.js.map +1 -1
  50. package/dist/config/providerPacks.d.ts +46 -0
  51. package/dist/config/providerPacks.js +200 -0
  52. package/dist/config/providerPacks.js.map +1 -0
  53. package/dist/config/providers.d.ts +36 -3
  54. package/dist/config/providers.js +168 -8
  55. package/dist/config/providers.js.map +1 -1
  56. package/dist/config/sites.d.ts +1 -12
  57. package/dist/config/sites.js +38 -5
  58. package/dist/config/sites.js.map +1 -1
  59. package/dist/config/userChannels.d.ts +12 -0
  60. package/dist/config/userChannels.js +81 -14
  61. package/dist/config/userChannels.js.map +1 -1
  62. package/dist/config/userConfig.d.ts +5 -1
  63. package/dist/config/userConfig.js +58 -6
  64. package/dist/config/userConfig.js.map +1 -1
  65. package/dist/config/userProfiles.d.ts +81 -0
  66. package/dist/config/userProfiles.js +424 -0
  67. package/dist/config/userProfiles.js.map +1 -0
  68. package/dist/routes/config/channels/index.d.ts +3 -0
  69. package/dist/routes/config/channels/index.js +3 -0
  70. package/dist/routes/config/channels/index.js.map +1 -0
  71. package/dist/routes/config/channels/routes.d.ts +6 -0
  72. package/dist/routes/config/channels/routes.js +797 -0
  73. package/dist/routes/config/channels/routes.js.map +1 -0
  74. package/dist/routes/config/channels/table.d.ts +46 -0
  75. package/dist/routes/config/channels/table.js +811 -0
  76. package/dist/routes/config/channels/table.js.map +1 -0
  77. package/dist/routes/config/index.d.ts +37 -0
  78. package/dist/routes/config/index.js +81 -0
  79. package/dist/routes/config/index.js.map +1 -0
  80. package/dist/routes/config/providers.d.ts +18 -0
  81. package/dist/routes/config/providers.js +573 -0
  82. package/dist/routes/config/providers.js.map +1 -0
  83. package/dist/routes/config/settings.d.ts +39 -0
  84. package/dist/routes/config/settings.js +820 -0
  85. package/dist/routes/config/settings.js.map +1 -0
  86. package/dist/routes/index.d.ts +3 -2
  87. package/dist/routes/index.js +7 -4
  88. package/dist/routes/index.js.map +1 -1
  89. package/dist/routes/playlist.d.ts +5 -1
  90. package/dist/routes/playlist.js +32 -7
  91. package/dist/routes/playlist.js.map +1 -1
  92. package/dist/routes/providers.d.ts +6 -0
  93. package/dist/routes/providers.js +166 -0
  94. package/dist/routes/providers.js.map +1 -0
  95. package/dist/routes/root/content.d.ts +32 -0
  96. package/dist/routes/root/content.js +857 -0
  97. package/dist/routes/root/content.js.map +1 -0
  98. package/dist/routes/root/index.js +197 -0
  99. package/dist/routes/root/index.js.map +1 -0
  100. package/dist/routes/root/scripts/channels.d.ts +1 -0
  101. package/dist/routes/root/scripts/channels.js +745 -0
  102. package/dist/routes/root/scripts/channels.js.map +1 -0
  103. package/dist/routes/root/scripts/config.d.ts +1 -0
  104. package/dist/routes/root/scripts/config.js +1662 -0
  105. package/dist/routes/root/scripts/config.js.map +1 -0
  106. package/dist/routes/root/scripts/index.d.ts +3 -0
  107. package/dist/routes/root/scripts/index.js +8 -0
  108. package/dist/routes/root/scripts/index.js.map +1 -0
  109. package/dist/routes/root/scripts/status.d.ts +1 -0
  110. package/dist/routes/root/scripts/status.js +436 -0
  111. package/dist/routes/root/scripts/status.js.map +1 -0
  112. package/dist/routes/root/styles.d.ts +5 -0
  113. package/dist/routes/root/styles.js +447 -0
  114. package/dist/routes/root/styles.js.map +1 -0
  115. package/dist/routes/ui.js +8 -3
  116. package/dist/routes/ui.js.map +1 -1
  117. package/dist/service/commands.d.ts +5 -3
  118. package/dist/service/commands.js +117 -33
  119. package/dist/service/commands.js.map +1 -1
  120. package/dist/service/generators.d.ts +27 -0
  121. package/dist/service/generators.js +97 -6
  122. package/dist/service/generators.js.map +1 -1
  123. package/dist/streaming/hls.js +18 -1
  124. package/dist/streaming/hls.js.map +1 -1
  125. package/dist/streaming/lifecycle.js +1 -1
  126. package/dist/streaming/lifecycle.js.map +1 -1
  127. package/dist/streaming/monitor.d.ts +1 -38
  128. package/dist/streaming/monitor.js +2 -289
  129. package/dist/streaming/monitor.js.map +1 -1
  130. package/dist/streaming/recovery.d.ts +143 -0
  131. package/dist/streaming/recovery.js +286 -0
  132. package/dist/streaming/recovery.js.map +1 -0
  133. package/dist/streaming/registry.d.ts +1 -1
  134. package/dist/streaming/setup.d.ts +1 -1
  135. package/dist/streaming/setup.js +3 -1
  136. package/dist/streaming/setup.js.map +1 -1
  137. package/dist/streaming/statusEmitter.d.ts +4 -2
  138. package/dist/streaming/statusEmitter.js +5 -0
  139. package/dist/streaming/statusEmitter.js.map +1 -1
  140. package/dist/types/channels.d.ts +68 -0
  141. package/dist/types/channels.js +2 -0
  142. package/dist/types/channels.js.map +1 -0
  143. package/dist/types/config.d.ts +127 -0
  144. package/dist/types/config.js +2 -0
  145. package/dist/types/config.js.map +1 -0
  146. package/dist/types/index.d.ts +7 -429
  147. package/dist/types/index.js +1 -5
  148. package/dist/types/index.js.map +1 -1
  149. package/dist/types/profiles.d.ts +141 -0
  150. package/dist/types/profiles.js +2 -0
  151. package/dist/types/profiles.js.map +1 -0
  152. package/dist/types/selection.d.ts +101 -0
  153. package/dist/types/selection.js +6 -0
  154. package/dist/types/selection.js.map +1 -0
  155. package/dist/types/shared.d.ts +7 -0
  156. package/dist/types/shared.js +6 -0
  157. package/dist/types/shared.js.map +1 -0
  158. package/dist/types/streaming.d.ts +96 -0
  159. package/dist/types/streaming.js +2 -0
  160. package/dist/types/streaming.js.map +1 -0
  161. package/dist/utils/debugFilter.js +3 -1
  162. package/dist/utils/debugFilter.js.map +1 -1
  163. package/dist/utils/format.d.ts +7 -0
  164. package/dist/utils/format.js +22 -0
  165. package/dist/utils/format.js.map +1 -1
  166. package/dist/utils/index.d.ts +1 -0
  167. package/dist/utils/index.js +1 -0
  168. package/dist/utils/index.js.map +1 -1
  169. package/dist/utils/sanitize.d.ts +14 -0
  170. package/dist/utils/sanitize.js +37 -0
  171. package/dist/utils/sanitize.js.map +1 -0
  172. package/package.json +5 -3
  173. package/dist/routes/config.d.ts +0 -76
  174. package/dist/routes/config.js +0 -2187
  175. package/dist/routes/config.js.map +0 -1
  176. package/dist/routes/root.js +0 -3158
  177. package/dist/routes/root.js.map +0 -1
  178. /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
- // Guide grid row number cache. Maps lowercased, trimmed channel names from data-testid attributes to their row numbers (from sr-only text). Populated passively
5
- // during binary search iterations and used for direct-scroll optimization on subsequent tunes. Session-scoped — cleared when the browser restarts.
6
- const guideRowCache = new Map();
7
- // Hulu channel UUID cache. Maps normalized channel names (from guide.hulu.com/guide/details API responses) to channel UUIDs used in the playlist API's channel_id
8
- // field. Populated server-side by intercepting details API responses during page load. Session-scoped cleared when the browser restarts via clearHuluCache().
9
- const huluUuidCache = new Map();
10
- // Hulu channel EAB cache. Maps channel UUIDs to their program schedules (from guide.hulu.com/guide/listing API responses). Each entry is an array of programs with
11
- // EAB IDs and airing times. Used to supply the correct content_eab_id when swapping channel_id in the playlist API request — the server requires a valid EAB for
12
- // the target channel. Session-scoped cleared when the browser restarts via clearHuluCache().
13
- const huluEabCache = new Map();
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: guide row cache, channel UUID cache, and EAB program cache. Called by clearChannelSelectionCaches() in the coordinator when the
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
- guideRowCache.clear();
26
- huluEabCache.clear();
27
- huluUuidCache.clear();
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
- * row number cache as a side effect.
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
- const match = /row (\d+) of/.exec(srOnly.textContent);
53
- if (match) {
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(match[1], 10) - 1;
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 number cache.
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
- guideRowCache.set(ch.name, ch.rowNumber);
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 server-side caches into the in-page interceptor, or detecting that the interceptor has already
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: looks up the currently-airing EAB for the given channel UUID and calls __prismcastResolveDirectTune to inject both values into the
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 channelUuid - The channel UUID from server-side cache, or null if not yet available.
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, channelUuid, channelName) {
498
+ async function tryFastPathTune(page, entry, channelName) {
335
499
  let resolved = false;
336
500
  let resolveDetail = "";
337
- // Phase 1: If UUID is available from the server-side cache, attempt to inject it along with the current EAB into the in-page interceptor. This is the primary
338
- // mechanism for local affiliates (where the interceptor can't self-resolve by name) and a secondary mechanism for exact-match channels.
339
- if (channelUuid) {
340
- const currentEab = findCurrentEab(channelUuid);
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
- }, channelUuid, currentEab);
512
+ }, entry.uuid, currentEab);
349
513
  if (injected) {
350
514
  resolved = true;
351
- resolveDetail = "uuid=" + channelUuid;
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 row number cache for a direct-scroll shortcut
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
- // Read grid metadata by walking up from a rendered row to find the spacer and viewport divs. The spacer div is the direct parent of all absolutely-positioned
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 row number cache for a direct-scroll shortcut. If we've seen this channel before, we can skip binary search entirely and scroll directly to it.
513
- const cachedRow = guideRowCache.get(normalizedName);
514
- if (cachedRow !== undefined) {
515
- LOG.debug("tuning:hulu", "Guide cache hit for %s at row %s.", channelName, cachedRow);
516
- await scrollToRow(cachedRow);
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 this entry and fall through to binary search.
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
- guideRowCache.delete(normalizedName);
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 scrollToRow(mid);
543
- // Read all rendered channels, populating the row number cache as a side effect.
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
- // Cache the network name affiliate's row number so subsequent tunes for the same network name become direct scrolls.
584
- const inferredRow = guideRowCache.get(inferred);
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 inferredUuid = huluUuidCache.get(inferred);
592
- if (inferredUuid) {
593
- huluUuidCache.set(normalizedName, inferredUuid);
594
- LOG.debug("tuning:hulu", "Cross-referenced UUID cache: %s -> %s (from inferred affiliate %s).", channelName, inferredUuid, inferred);
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, inferredUuid, channelName);
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 scrollToRow(row);
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 guide row cache to help users identify the correct channelSelector value. The cache accumulates all channel names encountered
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(guideRowCache.keys()).sort();
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 server-side cache injection or interceptor self-resolution detection. The Channels tab click (before binary search)
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, server-side caches provide UUID+EAB for
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 UUID — that call is redundant here but harmless (~10ms), and avoiding it with a flag would create a fragile coupling to the inference block.
644
- const fastPathSuccess = await tryFastPathTune(page, huluUuidCache.get(normalizedName) ?? null, channelName);
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 UUID mappings from Hulu's guide details API and EAB schedules from the listing API.
679
- * As the live page loads, Hulu fetches program details from guide.hulu.com/guide/details in batches. Each response item includes channel_info with the
680
- * channel's UUID and display name. We intercept these responses to populate the huluUuidCache, enabling instant UUID resolution on subsequent tunes. The
681
- * in-page interceptor in resolveHuluDirectUrl() expands details request bodies with additional EABs so these responses cover all ~123 channels. Also bridges
682
- * in-page console signals (HULU-DIAG, HULU-CACHE, and HULU-FAIL) to the Node.js LOG. Uses a WeakSet to prevent duplicate listener registration.
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. Cache the UUID under the channelSelector key so subsequent tunes resolve via warm direct tuning instead of the guide grid.
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
- huluUuidCache.set(name, channelUuid);
706
- LOG.debug("tuning:hulu", "Cached affiliate UUID from playlist: %s -> %s. UUID cache size: %s.", name, channelUuid, huluUuidCache.size);
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: capture channel UUID mappings for the persistent cache.
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
- const channelsSeen = new Set();
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) for each channel. The app fires listing requests on every page load covering all ~130
742
- // channels with ~8 hours of programs. We cache the full schedule per channel UUID so resolveHuluDirectUrl can find the currently-airing EAB at tune time.
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
- huluEabCache.set(channel.id, channel.programs);
883
+ huluListingStaging.set(channel.id, channel.programs);
752
884
  programCount += channel.programs.length;
753
885
  }
754
886
  }
755
- LOG.debug("tuning:hulu", "Listing API: %s channels, %s programs. EAB cache size: %s.", data.channels.length, programCount, huluEabCache.size);
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 UUID cache for future warm tunes.
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 cachedUuid = huluUuidCache.get(normalizedName) ?? null;
795
- // Set up server-side response listeners to populate the UUID and EAB caches. Must be set up before navigation so we capture details and listing API responses
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 is known). On warm cache (both UUID and EAB available), the interceptor has both at install
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 expansion.
800
- const cachedEab = cachedUuid ? findCurrentEab(cachedUuid) : null;
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 listing API request expansion. On warm tunes, this keeps EAB schedules fresh for all known channels. On cold
819
- // tunes after the first, this expands the listing request beyond the mini-guide's ~10 UUIDs. Empty on the very first cold tune (no cached data yet).
820
- const allCachedUuids = [...new Set(huluUuidCache.values())];
821
- // Collect one current EAB per cached channel for details API request expansion. On warm tunes, this fills the UUID cache completely. On cold tunes after the
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 channelUuid of huluEabCache.keys()) {
826
- const currentEab = findCurrentEab(channelUuid);
827
- if (currentEab) {
828
- allCurrentEabs.push(currentEab);
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 server-side caches provide 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 server-side caches),
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 findCurrentEab() logic. Called on all listing return paths (expanded and passthrough).
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 channel UUID for the given channel selector. Called when a cached direct URL fails to produce a working stream, so the next tune
1190
- * attempts the cold cache path (details API extraction) or falls back to the guide grid.
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
- huluUuidCache.delete(normalizeChannelName(channelSelector));
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 huluStrategy = {
1197
- clearCache: clearHuluCache,
1198
- execute: guideGridWithRetry,
1199
- invalidateDirectUrl: invalidateHuluDirectUrl,
1200
- resolveDirectUrl: resolveHuluDirectUrl
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