prismcast 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) 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 +537 -211
  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 +47 -6
  54. package/dist/config/providers.js +211 -17
  55. package/dist/config/providers.js.map +1 -1
  56. package/dist/config/sites.d.ts +1 -12
  57. package/dist/config/sites.js +40 -7
  58. package/dist/config/sites.js.map +1 -1
  59. package/dist/config/userChannels.d.ts +41 -19
  60. package/dist/config/userChannels.js +152 -35
  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/theme.js +7 -1
  116. package/dist/routes/theme.js.map +1 -1
  117. package/dist/routes/ui.js +8 -3
  118. package/dist/routes/ui.js.map +1 -1
  119. package/dist/service/commands.d.ts +5 -3
  120. package/dist/service/commands.js +117 -33
  121. package/dist/service/commands.js.map +1 -1
  122. package/dist/service/generators.d.ts +27 -0
  123. package/dist/service/generators.js +97 -6
  124. package/dist/service/generators.js.map +1 -1
  125. package/dist/streaming/hls.js +18 -1
  126. package/dist/streaming/hls.js.map +1 -1
  127. package/dist/streaming/lifecycle.js +1 -1
  128. package/dist/streaming/lifecycle.js.map +1 -1
  129. package/dist/streaming/monitor.d.ts +1 -38
  130. package/dist/streaming/monitor.js +2 -289
  131. package/dist/streaming/monitor.js.map +1 -1
  132. package/dist/streaming/recovery.d.ts +143 -0
  133. package/dist/streaming/recovery.js +286 -0
  134. package/dist/streaming/recovery.js.map +1 -0
  135. package/dist/streaming/registry.d.ts +1 -1
  136. package/dist/streaming/setup.d.ts +1 -1
  137. package/dist/streaming/setup.js +3 -1
  138. package/dist/streaming/setup.js.map +1 -1
  139. package/dist/streaming/showInfo.js +12 -14
  140. package/dist/streaming/showInfo.js.map +1 -1
  141. package/dist/streaming/statusEmitter.d.ts +4 -2
  142. package/dist/streaming/statusEmitter.js +5 -0
  143. package/dist/streaming/statusEmitter.js.map +1 -1
  144. package/dist/types/channels.d.ts +68 -0
  145. package/dist/types/channels.js +2 -0
  146. package/dist/types/channels.js.map +1 -0
  147. package/dist/types/config.d.ts +127 -0
  148. package/dist/types/config.js +2 -0
  149. package/dist/types/config.js.map +1 -0
  150. package/dist/types/index.d.ts +7 -406
  151. package/dist/types/index.js +1 -5
  152. package/dist/types/index.js.map +1 -1
  153. package/dist/types/profiles.d.ts +141 -0
  154. package/dist/types/profiles.js +2 -0
  155. package/dist/types/profiles.js.map +1 -0
  156. package/dist/types/selection.d.ts +101 -0
  157. package/dist/types/selection.js +6 -0
  158. package/dist/types/selection.js.map +1 -0
  159. package/dist/types/shared.d.ts +7 -0
  160. package/dist/types/shared.js +6 -0
  161. package/dist/types/shared.js.map +1 -0
  162. package/dist/types/streaming.d.ts +96 -0
  163. package/dist/types/streaming.js +2 -0
  164. package/dist/types/streaming.js.map +1 -0
  165. package/dist/utils/debugFilter.js +3 -1
  166. package/dist/utils/debugFilter.js.map +1 -1
  167. package/dist/utils/format.d.ts +7 -0
  168. package/dist/utils/format.js +22 -0
  169. package/dist/utils/format.js.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.js +1 -0
  172. package/dist/utils/index.js.map +1 -1
  173. package/dist/utils/sanitize.d.ts +14 -0
  174. package/dist/utils/sanitize.js +37 -0
  175. package/dist/utils/sanitize.js.map +1 -0
  176. package/package.json +6 -4
  177. package/dist/routes/config.d.ts +0 -72
  178. package/dist/routes/config.js +0 -1987
  179. package/dist/routes/config.js.map +0 -1
  180. package/dist/routes/root.js +0 -2959
  181. package/dist/routes/root.js.map +0 -1
  182. /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
@@ -1,34 +1,132 @@
1
1
  import { LOG, delay, evaluateWithAbort, formatError } from "../../utils/index.js";
2
2
  import { logAvailableChannels, normalizeChannelName, scrollAndClick } from "../channelSelection.js";
3
3
  import { CONFIG } from "../../config/index.js";
4
- // 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
17
- // target channel through its own pipeline without guide grid interaction. On warm cache, UUID and EAB are injected at install time. On cold cache (non-affiliate),
18
- // the interceptor captures them from listing and details API responses during page load, holding the playlist request until both are resolved.
17
+ // target channel through its own pipeline without guide grid interaction. On warm cache, UUID and EAB are injected at install time. On cold cache, the guide
18
+ // grid's Channels tab click triggers full API expansion, and the interceptor captures UUID+EAB from those expanded responses.
19
19
  const HULU_LIVE_URL = "https://www.hulu.com/live";
20
20
  /**
21
- * Clears all Hulu caches: 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,26 +479,60 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
315
479
  return { reason: "Play button did not appear after " + String(MAX_CLICK_ATTEMPTS) + " on-now cell click attempts for " + channelName + ".", success: false };
316
480
  }
317
481
  /**
318
- * Attempts to tune to a channel via the fast path: injects UUID and EAB into the in-page fetch interceptor, which swaps the held playlist request to the target
319
- * channel. Called after binary search identifies a local affiliate whose UUID and EAB are available in the server-side caches. On success, dismisses the guide
320
- * overlay so the video player is visible for capture, then returns true. On failure (interceptor's directTunePromise already resolved due to timeout), returns
321
- * false so the caller can fall through to the click-based path.
482
+ * Attempts a fast-path tune by either injecting UUID+EAB from the unified cache into the in-page interceptor, or detecting that the interceptor has already
483
+ * self-resolved from in-page API data. Two resolution mechanisms handle different channel types:
484
+ *
485
+ * 1. Server-side injection: finds the currently-airing EAB from the entry's program schedule and calls __prismcastResolveDirectTune to inject both values into the
486
+ * held playlist request. Primary mechanism for local affiliates whose call signs don't match the channelSelector network name — the interceptor can't
487
+ * self-resolve by name for these, so external injection is the only path.
488
+ * 2. Self-resolution detection: queries __prismcastIsDirectTuneResolved to check if the interceptor already captured UUID+EAB from the expanded
489
+ * Details+Listing API responses and resolved the playlist autonomously. Primary mechanism for non-affiliate channels on cold cache, where the interceptor's
490
+ * in-page name matching succeeds before the guide grid completes binary search.
491
+ *
492
+ * On success, dismisses the guide overlay so the video player is visible for capture.
322
493
  * @param page - The Puppeteer page object.
323
- * @param channelUuid - The channel UUID to inject into the interceptor.
324
- * @param currentEab - The currently-airing EAB to inject into the interceptor.
494
+ * @param entry - The unified cache entry for the target channel, or null if not yet available.
325
495
  * @param channelName - The original channel name for logging.
326
- * @returns True if the injection succeeded, false if the interceptor's Promise had already resolved.
496
+ * @returns True if the tune was resolved (via injection or self-resolution), false otherwise.
327
497
  */
328
- async function tryFastPathTune(page, channelUuid, currentEab, channelName) {
329
- const injected = await page.evaluate((u, e) => {
330
- const resolver = window.__prismcastResolveDirectTune;
331
- if (typeof resolver === "function") {
332
- return resolver(u, e);
498
+ async function tryFastPathTune(page, entry, channelName) {
499
+ let resolved = false;
500
+ let resolveDetail = "";
501
+ // Phase 1: If UUID and programs are available from the unified cache, attempt to inject the UUID along with the current EAB into the in-page interceptor. This
502
+ // is the primary mechanism for local affiliates (where the interceptor can't self-resolve by name) and a secondary mechanism for exact-match channels.
503
+ if (entry?.uuid && entry.programs) {
504
+ const currentEab = findCurrentEabFromPrograms(entry.programs);
505
+ if (currentEab) {
506
+ const injected = await page.evaluate((u, e) => {
507
+ const resolver = window.__prismcastResolveDirectTune;
508
+ if (typeof resolver === "function") {
509
+ return resolver(u, e);
510
+ }
511
+ return false;
512
+ }, entry.uuid, currentEab);
513
+ if (injected) {
514
+ resolved = true;
515
+ resolveDetail = "uuid=" + entry.uuid;
516
+ }
333
517
  }
334
- return false;
335
- }, channelUuid, currentEab);
336
- if (!injected) {
337
- LOG.debug("tuning:hulu", "Fast-path injection failed for %s (interceptor Promise already resolved).", channelName);
518
+ }
519
+ // Phase 2: If injection didn't succeed, check if the interceptor self-resolved from in-page API data. This happens on cold cache for non-affiliate channels
520
+ // whose names match the Details API response (e.g., "CNN International", "ESPN"). The interceptor captures UUID+EAB from the expanded Details+Listing
521
+ // responses triggered by the Channels tab click and resolves the held playlist autonomously, making the guide grid click redundant.
522
+ if (!resolved) {
523
+ const selfResolved = await page.evaluate(() => {
524
+ const checker = window.__prismcastIsDirectTuneResolved;
525
+ if (typeof checker === "function") {
526
+ return checker();
527
+ }
528
+ return false;
529
+ });
530
+ if (selfResolved) {
531
+ resolved = true;
532
+ resolveDetail = "interceptor self-resolved from API data";
533
+ }
534
+ }
535
+ if (!resolved) {
338
536
  return false;
339
537
  }
340
538
  // Dismiss the guide overlay so the video player is visible for capture. The guide was opened for binary search and is still covering the player.
@@ -345,13 +543,13 @@ async function tryFastPathTune(page, channelUuid, currentEab, channelName) {
345
543
  catch (error) {
346
544
  LOG.debug("tuning:hulu", "Could not dismiss guide after fast-path tune: %s.", formatError(error));
347
545
  }
348
- LOG.debug("tuning:hulu", "Direct tune via fetch interception for inferred affiliate %s (uuid=%s).", channelName, channelUuid);
546
+ LOG.debug("tuning:hulu", "Direct tune via fetch interception for %s (%s).", channelName, resolveDetail);
349
547
  return true;
350
548
  }
351
549
  /**
352
- * Releases the held playlist request in the in-page fetch interceptor, reverting to the click-based flow. Called when the fast-path injection can't proceed
353
- * (UUID or EAB not in server-side caches) or wasn't attempted. Sets holdActive to false in the interceptor so subsequent playlist requests from the play button
354
- * click follow the affiliate capture path ([HULU-CACHE]).
550
+ * Releases the held playlist request in the in-page fetch interceptor, reverting to the click-based flow. Called when tryFastPathTune returns false (neither
551
+ * server-side injection nor interceptor self-resolution succeeded). Sets holdActive to false in the interceptor so subsequent playlist requests from the play
552
+ * button click follow the affiliate capture path ([HULU-CACHE]).
355
553
  * @param page - The Puppeteer page object.
356
554
  */
357
555
  async function releaseHeldPlaylist(page) {
@@ -376,7 +574,7 @@ async function releaseHeldPlaylist(page) {
376
574
  * The selection process:
377
575
  * 1. If listSelector is provided, click the tab/button to reveal the channel list (e.g., a "Channels" tab)
378
576
  * 2. Wait for the channel grid rows to render in the DOM
379
- * 3. Check the row number cache for a direct-scroll shortcut
577
+ * 3. Check the unified cache for a row number direct-scroll shortcut
380
578
  * 4. Binary search: scroll to the midpoint row, read rendered channels (caching row numbers), check for exact match or infer local affiliate
381
579
  * 5. If binary search fails, linear scan from top to bottom as a universal fallback
382
580
  * 6. Click the on-now program cell (`.LiveGuideProgram--first`) in the target channel's row to open the playback overlay
@@ -433,53 +631,21 @@ async function guideGridStrategy(page, profile) {
433
631
  if (!rowsVisible) {
434
632
  return { reason: "Channel grid rows did not render.", success: false };
435
633
  }
436
- // Each row in the virtualized grid is exactly 112px tall. The total number of channels is derived from the spacer div's height.
437
- const ROW_HEIGHT = 112;
438
634
  // Normalize the channel name to lowercase for case-insensitive matching against data-testid suffixes.
439
635
  const normalizedName = normalizeChannelName(channelName);
440
- // 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
441
- // rows, and its height equals totalRows * ROW_HEIGHT. The viewport div is the spacer's parent (overflow: hidden). We calculate gridDocTop as the viewport's
442
- // document-level offset, so that scrolling to gridDocTop + (rowIndex * ROW_HEIGHT) places that row at the top of the browser viewport.
443
- const gridMeta = await page.evaluate((rowHeight) => {
444
- const row = document.querySelector("[data-testid=\"live-guide-row\"]");
445
- if (!row) {
446
- return null;
447
- }
448
- // The spacer div is the parent of all row elements.
449
- const spacer = row.parentElement;
450
- if (!spacer) {
451
- return null;
452
- }
453
- const spacerHeight = spacer.offsetHeight;
454
- if (spacerHeight < rowHeight) {
455
- return null;
456
- }
457
- // The viewport div is the spacer's parent. Its position relative to the document determines our scroll offset.
458
- const viewport = spacer.parentElement;
459
- if (!viewport) {
460
- return null;
461
- }
462
- const gridDocTop = viewport.getBoundingClientRect().top + document.documentElement.scrollTop;
463
- return { gridDocTop, totalRows: Math.round(spacerHeight / rowHeight) };
464
- }, ROW_HEIGHT);
636
+ const gridMeta = await readGridMeta(page);
465
637
  if (!gridMeta) {
466
638
  return { reason: "Could not locate channel grid spacer element.", success: false };
467
639
  }
468
- const { gridDocTop, totalRows } = gridMeta;
469
- // Helper: scroll to a specific row index and wait for the virtualizer to render.
470
- const scrollToRow = async (rowIndex) => {
471
- await page.evaluate((scrollTo) => {
472
- document.documentElement.scrollTop = scrollTo;
473
- }, gridDocTop + (rowIndex * ROW_HEIGHT));
474
- await delay(200);
475
- };
640
+ const { gridDocTop, rowHeight, totalRows } = gridMeta;
476
641
  // The name of the channel to click. This starts as the normalized target name but may be replaced by a local affiliate call sign via position inference.
477
642
  let clickTarget = normalizedName;
478
- // Check the 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.
479
- const cachedRow = guideRowCache.get(normalizedName);
480
- if (cachedRow !== undefined) {
481
- LOG.debug("tuning:hulu", "Guide cache hit for %s at row %s.", channelName, cachedRow);
482
- 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);
483
649
  // Read rendered channels to update the cache and confirm the channel is present.
484
650
  const rendered = await readRenderedChannels(page);
485
651
  if (rendered) {
@@ -488,9 +654,9 @@ async function guideGridStrategy(page, profile) {
488
654
  return await clickOnNowCellAndPlay(page, normalizedName, playSelector, channelName);
489
655
  }
490
656
  }
491
- // Cache hit but channel not found at expected position. The guide may have changed. Clear 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.
492
658
  LOG.debug("tuning:hulu", "Guide cache miss for %s. Falling back to binary search.", channelName);
493
- guideRowCache.delete(normalizedName);
659
+ cachedEntry.rowNumber = undefined;
494
660
  }
495
661
  // Binary search through the virtualized channel list. On each iteration we scroll to the midpoint of the current range, wait for the virtualizer to render,
496
662
  // then check if the target channel is among the ~13 rendered rows. If not, we compare the target name alphabetically against the first and last rendered
@@ -505,8 +671,8 @@ async function guideGridStrategy(page, profile) {
505
671
  }
506
672
  const mid = Math.floor((low + high) / 2);
507
673
  // eslint-disable-next-line no-await-in-loop
508
- await scrollToRow(mid);
509
- // 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.
510
676
  // eslint-disable-next-line no-await-in-loop
511
677
  const rendered = await readRenderedChannels(page);
512
678
  if (!rendered || (rendered.length === 0)) {
@@ -546,28 +712,17 @@ async function guideGridStrategy(page, profile) {
546
712
  LOG.debug("tuning:hulu", "Inferred local affiliate %s for network name %s.", inferred, channelName);
547
713
  clickTarget = inferred;
548
714
  found = true;
549
- // Cache the network name → affiliate's row number so subsequent tunes for the same network name become direct scrolls.
550
- const inferredRow = guideRowCache.get(inferred);
551
- if (inferredRow !== undefined) {
552
- guideRowCache.set(normalizedName, inferredRow);
553
- }
554
- // Cross-reference the UUID cache so the network name can resolve to a warm-cache direct tune on subsequent requests. The details API returns the local
555
- // call sign as channel_info.name, so the UUID cache is keyed by call sign. The user's channelSelector uses the network name (e.g., "ABC"). Without this
715
+ // Cross-reference the unified cache so the network name resolves to a warm-cache direct tune on subsequent requests. The details API returns the local
716
+ // call sign as channel_info.name, so the cache is keyed by call sign. The user's channelSelector uses the network name (e.g., "ABC"). Without this
556
717
  // cross-reference, local affiliates would always fall through to cold-cache guide grid tunes because the names never match.
557
- const inferredUuid = huluUuidCache.get(inferred);
558
- if (inferredUuid) {
559
- huluUuidCache.set(normalizedName, inferredUuid);
560
- LOG.debug("tuning:hulu", "Cross-referenced UUID cache: %s -> %s (from inferred affiliate %s).", channelName, inferredUuid, inferred);
561
- // Fast path: inject UUID+EAB into the in-page interceptor to tune via fetch interception instead of clicking. The server-side caches are populated
562
- // from details and listing API responses during page load, which typically complete before binary search finishes (~600ms). If both UUID and EAB are
563
- // available, the interceptor swaps the held playlist request to the target channel — no on-now cell click or play button interaction needed.
564
- const currentEab = findCurrentEab(inferredUuid);
565
- if (currentEab) {
566
- // eslint-disable-next-line no-await-in-loop
567
- const fastPathSuccess = await tryFastPathTune(page, inferredUuid, currentEab, channelName);
568
- if (fastPathSuccess) {
569
- return { success: true };
570
- }
718
+ const inferredEntry = huluChannelCache.get(inferred);
719
+ if (inferredEntry) {
720
+ huluChannelCache.set(normalizedName, inferredEntry);
721
+ LOG.debug("tuning:hulu", "Cross-referenced cache: %s -> %s (from inferred affiliate %s).", channelName, inferredEntry.uuid ?? "no-uuid", inferred);
722
+ // eslint-disable-next-line no-await-in-loop
723
+ const fastPathSuccess = await tryFastPathTune(page, inferredEntry, channelName);
724
+ if (fastPathSuccess) {
725
+ return { success: true };
571
726
  }
572
727
  }
573
728
  }
@@ -580,7 +735,7 @@ async function guideGridStrategy(page, profile) {
580
735
  LOG.debug("tuning:hulu", "Binary search did not find %s. Starting linear scan fallback.", channelName);
581
736
  for (let row = 0; row < totalRows; row += 10) {
582
737
  // eslint-disable-next-line no-await-in-loop
583
- await scrollToRow(row);
738
+ await scrollToGuideRow(page, gridDocTop, rowHeight, row);
584
739
  // eslint-disable-next-line no-await-in-loop
585
740
  const rendered = await readRenderedChannels(page);
586
741
  if (!rendered) {
@@ -594,9 +749,9 @@ async function guideGridStrategy(page, profile) {
594
749
  }
595
750
  }
596
751
  if (!found) {
597
- // Log available channels from the guide row cache to help users identify the correct channelSelector value. The cache accumulates all channel names encountered
598
- // during binary search and linear scan, so it contains most or all channels even though the virtualized grid only renders ~13 at a time.
599
- const availableChannels = Array.from(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();
600
755
  if (availableChannels.length > 0) {
601
756
  logAvailableChannels({
602
757
  availableChannels,
@@ -608,8 +763,17 @@ async function guideGridStrategy(page, profile) {
608
763
  }
609
764
  return { reason: "Could not find channel " + channelName + " in guide grid.", success: false };
610
765
  }
611
- // Release the held playlist before falling through to the click path. This is a safety net — the inference block releases the hold in all its sub-paths, but
612
- // exact-match and linear-scan paths may reach here without having released. The release is a no-op if the hold was already released or was never active.
766
+ // Fast path: attempt direct tune via unified cache injection or interceptor self-resolution detection. The Channels tab click (before binary search)
767
+ // triggers full Details+Listing API responses. For non-affiliates, the in-page interceptor may have already self-resolved from those responses (capturing
768
+ // UUID+EAB and swapping the playlist autonomously). For channels where the interceptor hasn't self-resolved, the unified cache provides UUID+EAB for
769
+ // injection. Either path avoids the redundant on-now cell click. Note: for affiliates, the inference block above may have already called tryFastPathTune with
770
+ // the same entry — that call is redundant here but harmless (~10ms), and avoiding it with a flag would create a fragile coupling to the inference block.
771
+ const fastPathSuccess = await tryFastPathTune(page, findHuluChannelEntry(normalizedName), channelName);
772
+ if (fastPathSuccess) {
773
+ return { success: true };
774
+ }
775
+ // Release the held playlist before falling through to the click path. Neither tryFastPathTune nor the inference block calls releaseHeldPlaylist — they either
776
+ // inject successfully (resolving the held playlist directly) or return false without side effects. The release is a no-op if the hold was already resolved.
613
777
  await releaseHeldPlaylist(page);
614
778
  // Click the on-now program cell and wait for the play button, with click retries to handle React hydration timing.
615
779
  return await clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName);
@@ -638,11 +802,11 @@ async function guideGridWithRetry(page, profile) {
638
802
  return result;
639
803
  }
640
804
  /**
641
- * Sets up server-side response interception on the page to capture channel UUID mappings from Hulu's guide details API and EAB schedules from the listing API.
642
- * 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
643
- * channel's UUID and display name. We intercept these responses to populate the huluUuidCache, enabling instant UUID resolution on subsequent tunes. The
644
- * in-page interceptor in resolveHuluDirectUrl() expands details request bodies with additional EABs so these responses cover all ~123 channels. Also bridges
645
- * in-page console signals (HULU-DIAG and HULU-CACHE) 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.
646
810
  * @param page - The Puppeteer page object.
647
811
  */
648
812
  function setupDetailsResponseInterception(page) {
@@ -658,45 +822,56 @@ function setupDetailsResponseInterception(page) {
658
822
  LOG.debug("tuning:hulu", text);
659
823
  }
660
824
  // Affiliate UUID capture: the in-page interceptor emits "[HULU-CACHE] targetName=channelUuid" when it observes a playlist passthrough for an affiliate guide
661
- // grid tune. 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.
662
826
  if (text.startsWith("[HULU-CACHE] ")) {
663
827
  const payload = text.slice("[HULU-CACHE] ".length);
664
828
  const eqIdx = payload.indexOf("=");
665
829
  if (eqIdx > 0) {
666
830
  const name = payload.slice(0, eqIdx);
667
831
  const channelUuid = payload.slice(eqIdx + 1);
668
- huluUuidCache.set(name, channelUuid);
669
- 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);
670
847
  }
671
848
  }
849
+ // Direct tune failure: the interceptor's hold period expired without resolving UUID+EAB for the target channel. The 503 response prevents Hulu from playing
850
+ // the wrong channel. The guide grid binary search runs as the fallback. Since all cold tunes go through the guide grid (triggering full API expansion), this
851
+ // path should rarely be reached — it provides defense-in-depth against silent false positives.
852
+ if (text.startsWith("[HULU-FAIL]")) {
853
+ LOG.warn(text);
854
+ }
672
855
  });
673
856
  page.on("response", (response) => {
674
857
  const url = response.url();
675
858
  if (response.status() !== 200) {
676
859
  return;
677
860
  }
678
- // Details API: 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.
679
862
  if (url.includes("guide.hulu.com/guide/details")) {
680
863
  void response.json().then((data) => {
681
864
  if (!Array.isArray(data.items)) {
682
865
  return;
683
866
  }
684
- const channelsSeen = new Set();
685
- for (const item of data.items) {
686
- const info = item.channel_info;
687
- if (info?.name && info.id) {
688
- huluUuidCache.set(normalizeChannelName(info.name), info.id);
689
- channelsSeen.add(info.name);
690
- }
691
- }
692
- LOG.debug("tuning:hulu", "Details API: %s items, %s unique channels. UUID cache size: %s.", data.items.length, channelsSeen.size, huluUuidCache.size);
867
+ populateHuluChannelCache(data.items);
693
868
  }).catch(() => {
694
869
  // CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
695
870
  });
696
871
  return;
697
872
  }
698
- // Listing API: capture program schedules (EABs with airing times) for each channel. The app fires listing requests on every page load covering all ~130
699
- // 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.
700
875
  if (url.includes("guide.hulu.com/guide/listing")) {
701
876
  void response.json().then((data) => {
702
877
  if (!Array.isArray(data.channels)) {
@@ -705,93 +880,89 @@ function setupDetailsResponseInterception(page) {
705
880
  let programCount = 0;
706
881
  for (const channel of data.channels) {
707
882
  if (channel.id && Array.isArray(channel.programs) && (channel.programs.length > 0)) {
708
- huluEabCache.set(channel.id, channel.programs);
883
+ huluListingStaging.set(channel.id, channel.programs);
709
884
  programCount += channel.programs.length;
710
885
  }
711
886
  }
712
- 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);
713
898
  }).catch(() => {
714
899
  // CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
715
900
  });
716
901
  }
717
902
  });
718
903
  }
719
- /**
720
- * Finds the currently-airing EAB for a channel from the EAB cache. Searches the cached program schedule for the given channel UUID and returns the EAB of the
721
- * program whose airing window brackets the current time. Returns null if the channel has no cached programs or if no program is currently airing (stale cache
722
- * or program boundary gap).
723
- * @param channelUuid - The channel UUID to look up in the EAB cache.
724
- * @returns The currently-airing EAB string, or null if no match.
725
- */
726
- function findCurrentEab(channelUuid) {
727
- const programs = huluEabCache.get(channelUuid);
728
- if (!programs) {
729
- return null;
730
- }
731
- const now = Date.now();
732
- for (const program of programs) {
733
- if ((now >= new Date(program.airingStart).getTime()) && (now < new Date(program.airingEnd).getTime())) {
734
- return program.eab;
735
- }
736
- }
737
- return null;
738
- }
739
904
  /**
740
905
  * Resolves a direct URL for Hulu channel tuning and installs a fetch interceptor that handles both warm and cold tunes. On warm cache (UUID and EAB known from
741
- * previous API responses), the interceptor has both values at install time and swaps the first playlist request immediately. On cold cache, the interceptor
742
- * captures the target channel's UUID from the details API response and EAB from the listing API response, holding the playlist request until both are resolved
743
- * (with an 8-second timeout). On all tunes, the interceptor also expands listing and details API requests to populate the full UUID cache (~123 channels) for
744
- * future warm tunes. Returns null only for known local affiliate network names on cold cache (ABC, CBS, NBC, Fox, PBS, CW) — their channel_info.name in the
745
- * details API uses call signs that don't match the channelSelector, so the guide grid handles them via position-based inference.
906
+ * previous API responses), the interceptor has both values at install time and swaps the first playlist request immediately. On cold cache (no UUID), returns
907
+ * null so the guide grid runs — the Channels tab click triggers full Details+Listing API expansion for all ~123 channels, and the interceptor captures UUID+EAB
908
+ * from those expanded responses to resolve the held playlist. Without the Channels tab click, the initial page load only provides data for ~10 visible channels.
909
+ * On all tunes, the interceptor also expands listing and details API requests to populate the full cache for future warm tunes.
746
910
  * @param channelSelector - The channel selector string (e.g., "Fox", "CNN", "ESPN").
747
911
  * @param page - The Puppeteer page for evaluateOnNewDocument installation and response interception setup.
748
- * @returns The Hulu live URL for direct tuning, or null for local affiliate networks on cold cache or interceptor installation failure.
912
+ * @returns The Hulu live URL for direct tuning, or null on cold cache (no UUID) or interceptor installation failure.
749
913
  */
750
914
  async function resolveHuluDirectUrl(channelSelector, page) {
751
915
  const normalizedName = normalizeChannelName(channelSelector);
752
- const cachedUuid = huluUuidCache.get(normalizedName) ?? null;
753
- // 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
754
919
  // during both the guide grid flow (cold cache) and the intercepted page load (warm cache).
755
920
  setupDetailsResponseInterception(page);
756
- // Look up the currently-airing EAB for the target channel (if UUID is known). On warm cache (both UUID and EAB available), the interceptor has both at install
757
- // time and swaps immediately. On cold cache or stale EAB, the interceptor captures the values from API responses during page load, holding the playlist until
758
- // both are resolved. The guide grid only runs for local affiliate network names (NETWORK_NAMES_WITH_AFFILIATES) whose names don't match the details API.
759
- 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;
760
925
  const isWarmCache = Boolean(cachedUuid && cachedEab);
761
926
  if (isWarmCache) {
762
927
  LOG.debug("tuning:hulu", "resolveHuluDirectUrl: warm cache for %s (uuid=%s, eab=%s).", channelSelector, cachedUuid, cachedEab);
763
928
  }
764
929
  else if (!cachedUuid) {
765
- // On cold cache, local affiliate network names fall through to the guide grid because their channel_info.name uses call signs that the interceptor
766
- // can't match. All other channels attempt cold direct tuning — the interceptor captures UUID+EAB from API responses during page load.
930
+ // On cold cache, all channels fall through to the guide grid — the Channels tab click triggers full Details+Listing API expansion for all ~123 channels.
931
+ // The affiliate/non-affiliate distinction is diagnostic only (both return null). Affiliates use position-based inference; non-affiliates use exact match.
767
932
  if (NETWORK_NAMES_WITH_AFFILIATES.has(normalizedName)) {
768
933
  LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s (local affiliate). Falling through to guide grid.", channelSelector);
769
934
  }
770
935
  else {
771
- LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s. Attempting cold direct tune via API interception.", channelSelector);
936
+ LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s. Falling through to guide grid for full API expansion.", channelSelector);
772
937
  }
773
938
  }
774
939
  else {
775
940
  LOG.debug("tuning:hulu", "resolveHuluDirectUrl: UUID cached for %s but no current EAB. Attempting direct tune via API interception.", channelSelector);
776
941
  }
777
- // Collect all unique UUIDs from the cache for listing API request expansion. On warm tunes, this keeps EAB schedules fresh for all known channels. On cold
778
- // 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).
779
- const allCachedUuids = [...new Set(huluUuidCache.values())];
780
- // 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
781
- // first, this supplements the in-page listing-derived EABs. Empty on the very first cold tune — the interceptor builds EABs dynamically from the listing API
782
- // response instead.
942
+ // Collect all unique UUIDs and current EABs from the unified cache for API request expansion. On warm tunes, this keeps EAB schedules fresh for all known
943
+ // channels. On cold tunes after the first, this expands requests beyond the mini-guide's ~10 channels. Empty on the very first cold tune.
944
+ const seenEntries = new Set();
945
+ const allCachedUuids = [];
783
946
  const allCurrentEabs = [];
784
- for (const channelUuid of huluEabCache.keys()) {
785
- const currentEab = findCurrentEab(channelUuid);
786
- if (currentEab) {
787
- 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
+ }
788
958
  }
789
959
  }
790
960
  /* Install the fetch interceptor before navigation on both warm and cold tunes. On warm tunes, it swaps channel_id and content_eab_id in playlist requests
791
- * immediately. On cold non-affiliate tunes, it holds the playlist request and swaps after capturing both values from listing and details API responses. On all
792
- * tunes, it captures listing API responses to build an in-page EAB map and expands subsequent details API requests, populating the UUID cache to ~123 channels
793
- * on a single page load. The script runs via evaluateOnNewDocument — it executes before any page JavaScript, patching window.fetch so Hulu's module-scoped
794
- * fetch reference captures the interceptor. Each stream gets its own page via createPageWithCapture(), so there's no persistence concern.
961
+ * immediately. On cold tunes, the guide grid's Channels tab click triggers full API expansion, and the interceptor holds the playlist request until both
962
+ * UUID and EAB are captured from those responses. On all tunes, it captures listing API responses to build an in-page EAB map and expands subsequent
963
+ * details API requests, populating the UUID cache to ~123 channels on a single page load. The script runs via evaluateOnNewDocument — it executes before
964
+ * any page JavaScript, patching window.fetch so Hulu's module-scoped fetch reference captures the interceptor. Each stream gets its own page via
965
+ * createPageWithCapture(), so there's no persistence concern.
795
966
  */
796
967
  try {
797
968
  // Always hold the playlist request. On warm cache, the interceptor swaps immediately. On cold cache (including affiliates), the guide grid strategy will either
@@ -832,18 +1003,23 @@ async function resolveHuluDirectUrl(channelSelector, page) {
832
1003
  const directTunePromise = (uuid && eab) ?
833
1004
  Promise.resolve() :
834
1005
  new Promise((resolve) => { directTuneResolve = resolve; });
1006
+ // Tracks whether the direct tune has been successfully resolved (UUID+EAB available and playlist swap in progress). Set by tryResolveDirectTune when both
1007
+ // values are captured from API responses or injected externally. Warm cache starts resolved. Used by __prismcastIsDirectTuneResolved to let the guide grid
1008
+ // strategy detect that the interceptor already handled the tune autonomously, avoiding a redundant on-now cell click.
1009
+ let directTuneResolved = Boolean(uuid && eab);
835
1010
  // Checks whether both UUID and EAB are now known and resolves directTunePromise if so. Called after each successful capture from listing or details API
836
1011
  // responses. Order-independent — handles both "listing first, details second" and "details first, listing second" sequences.
837
1012
  function tryResolveDirectTune() {
838
1013
  if (uuid && eab && directTuneResolve) {
1014
+ directTuneResolved = true;
839
1015
  directTuneResolve();
840
1016
  directTuneResolve = null;
841
1017
  }
842
1018
  }
843
- // Injection endpoint for the guide grid strategy's fast-path affiliate tune. After binary search identifies the local affiliate and the server-side caches
844
- // provide the UUID and EAB, the strategy calls this function via page.evaluate to feed both values into the interceptor. The held playlist request then
845
- // resumes with the swapped channel_id and content_eab_id. Returns true if the injection was accepted (directTunePromise not yet resolved), false if the
846
- // Promise already resolved (8s timeout expired or a previous injection).
1019
+ // Injection endpoint for the guide grid strategy's fast-path tune. After binary search identifies the target channel and the unified cache provides the
1020
+ // UUID and EAB, the strategy calls this function via page.evaluate to feed both values into the interceptor. The held playlist request then resumes with the
1021
+ // swapped channel_id and content_eab_id. Returns true if the injection was accepted (directTunePromise not yet resolved), false if the Promise already
1022
+ // resolved (self-resolution from API data, 8s timeout, or a previous injection).
847
1023
  window.__prismcastResolveDirectTune = (u, e) => {
848
1024
  if (!directTuneResolve) {
849
1025
  return false;
@@ -853,7 +1029,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
853
1029
  tryResolveDirectTune();
854
1030
  return true;
855
1031
  };
856
- // Release endpoint for the guide grid strategy's click-path fallback. When the fast-path injection can't proceed (UUID or EAB not in 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),
857
1033
  // the strategy calls this to unblock the held playlist request and revert to the click-based flow. Sets holdActive to false so the playlist handler follows
858
1034
  // the affiliate capture path ([HULU-CACHE]) on subsequent requests from the play button click.
859
1035
  window.__prismcastReleasePlaylist = () => {
@@ -863,6 +1039,10 @@ async function resolveHuluDirectUrl(channelSelector, page) {
863
1039
  directTuneResolve = null;
864
1040
  }
865
1041
  };
1042
+ // Query endpoint for the guide grid strategy to check if the interceptor has already resolved the direct tune. Returns true if the playlist was swapped
1043
+ // (self-resolution from API data, external injection, or warm cache). The guide grid checks this after finding the target channel — if the interceptor
1044
+ // already handled the tune, the on-now cell click is redundant and can be skipped.
1045
+ window.__prismcastIsDirectTuneResolved = () => directTuneResolved;
866
1046
  // In-page EAB map built dynamically from listing API responses. On the first cold tune, cachedEabs is empty (no pre-computed data from Node.js), so this
867
1047
  // map is the sole source of expansion data for details API requests. Populated asynchronously when the first listing response arrives.
868
1048
  const capturedCurrentEabs = new Map();
@@ -875,7 +1055,7 @@ async function resolveHuluDirectUrl(channelSelector, page) {
875
1055
  listingCapturedResolve = resolve;
876
1056
  });
877
1057
  // Fire-and-forget: parses a listing API response to build the in-page EAB map. For each channel, finds the currently-airing program by comparing airing
878
- // times against the current time, mirroring the server-side 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).
879
1059
  function captureListingData(response) {
880
1060
  try {
881
1061
  void response.clone().json().then((data) => {
@@ -1062,8 +1242,9 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1062
1242
  if (url.includes("play.hulu.com") && url.includes("playlist")) {
1063
1243
  // Wait for UUID and EAB to be available. On warm cache, directTunePromise resolved immediately at creation time. On cold cache with holdActive
1064
1244
  // enabled, this holds the playlist request until either: (1) the guide grid strategy injects UUID+EAB via __prismcastResolveDirectTune (fast path), or
1065
- // (2) the in-page listing/details parsers capture both values (cold non-affiliate), or (3) the 8-second timeout expires (safety net). When holdActive
1066
- // is false (set by __prismcastReleasePlaylist after fast-path failure), the playlist passes through with affiliate UUID capture for the click fallback.
1245
+ // (2) the in-page listing/details parsers capture both values from the guide grid's Channels tab expansion, or (3) the 8-second timeout expires and the
1246
+ // interceptor returns a 503 (safety net). When holdActive is false (set by __prismcastReleasePlaylist after fast-path failure), the playlist passes
1247
+ // through with affiliate UUID capture for the click fallback.
1067
1248
  if (holdActive) {
1068
1249
  await Promise.race([directTunePromise, new Promise((resolve) => { setTimeout(resolve, 8000); })]);
1069
1250
  }
@@ -1093,8 +1274,8 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1093
1274
  return originalFetch(input, init);
1094
1275
  }
1095
1276
  // eslint-disable-next-line no-console
1096
- console.log("[HULU-DIAG] Playlist passthrough: no uuid/eab resolved for " + targetName + " (uuid=" + uuid + ", eab=" + (eab ? "yes" : "no") + ").");
1097
- return originalFetch(input, init);
1277
+ console.log("[HULU-FAIL] Direct tune failed for " + targetName + ": uuid/eab not resolved within hold period.");
1278
+ return new Response("", { status: 503 });
1098
1279
  }
1099
1280
  const bodyText = await getBodyText(input, init);
1100
1281
  if (bodyText) {
@@ -1124,27 +1305,172 @@ async function resolveHuluDirectUrl(channelSelector, page) {
1124
1305
  LOG.debug("tuning:hulu", "Failed to install Hulu fetch interceptor: %s.", formatError(error));
1125
1306
  return null;
1126
1307
  }
1127
- // For known local affiliate network names on cold cache, skip cold direct tuning — the channel_info.name in the details API uses the local call sign
1128
- // rather than the network name, so the in-page interceptor can't match by name. The guide grid handles these via position-based inference and
1129
- // cross-references the UUID for warm tunes on subsequent requests.
1130
- if (!cachedUuid && NETWORK_NAMES_WITH_AFFILIATES.has(normalizedName)) {
1308
+ // On cold cache (no UUID), fall through to the guide grid. The initial page load only provides Details API data for ~10 visible channels — the guide grid's
1309
+ // Channels tab click triggers full expansion for all ~123 channels, and the interceptor captures UUID+EAB from those expanded responses to resolve the held
1310
+ // playlist. Affiliates additionally need position-based inference because their channel_info.name uses call signs rather than network names.
1311
+ if (!cachedUuid) {
1131
1312
  return null;
1132
1313
  }
1133
1314
  LOG.debug("tuning:hulu", "Fetch interceptor installed. Returning URL: %s.", HULU_LIVE_URL);
1134
1315
  return HULU_LIVE_URL;
1135
1316
  }
1136
1317
  /**
1137
- * Invalidates the cached channel UUID for the given channel selector. Called when a cached direct URL fails to produce a working stream, so the next tune
1138
- * 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.
1139
1322
  * @param channelSelector - The channel selector string to invalidate.
1140
1323
  */
1141
1324
  function invalidateHuluDirectUrl(channelSelector) {
1142
- 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();
1143
1460
  }
1144
- export const huluStrategy = {
1145
- clearCache: clearHuluCache,
1146
- execute: guideGridWithRetry,
1147
- invalidateDirectUrl: invalidateHuluDirectUrl,
1148
- 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"
1149
1475
  };
1150
1476
  //# sourceMappingURL=hulu.js.map