prismcast 1.3.4 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +3 -2
- package/dist/app.js +32 -17
- package/dist/app.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +3 -2
- package/dist/browser/channelSelection.js +43 -19
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/index.d.ts +1 -6
- package/dist/browser/index.js +14 -28
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/tuning/hulu.js +613 -13
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.js +1 -1
- package/dist/browser/tuning/thumbnailRow.js +69 -71
- package/dist/browser/tuning/thumbnailRow.js.map +1 -1
- package/dist/browser/tuning/tileClick.js +111 -77
- package/dist/browser/tuning/tileClick.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.js +5 -5
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +3 -10
- package/dist/browser/video.js +91 -10
- package/dist/browser/video.js.map +1 -1
- package/dist/config/index.d.ts +9 -3
- package/dist/config/index.js +28 -28
- package/dist/config/index.js.map +1 -1
- package/dist/config/paths.d.ts +41 -0
- package/dist/config/paths.js +88 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/providers.js +1 -1
- package/dist/config/sites.js +29 -12
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +1 -1
- package/dist/config/userChannels.js +10 -13
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +14 -7
- package/dist/config/userConfig.js +59 -34
- package/dist/config/userConfig.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +187 -25
- package/dist/index.js.map +1 -1
- package/dist/routes/config.js +15 -7
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/debug.js +41 -6
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +3 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +3 -3
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/playlist.d.ts +2 -2
- package/dist/routes/playlist.js +11 -9
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root.js +41 -2
- package/dist/routes/root.js.map +1 -1
- package/dist/routes/theme.js +4 -0
- package/dist/routes/theme.js.map +1 -1
- package/dist/routes/ui.js +2 -0
- package/dist/routes/ui.js.map +1 -1
- package/dist/routes/upgrade.d.ts +6 -0
- package/dist/routes/upgrade.js +68 -0
- package/dist/routes/upgrade.js.map +1 -0
- package/dist/service/commands.js +9 -7
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.js +10 -15
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +28 -0
- package/dist/streaming/fmp4Segmenter.js +140 -47
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.js +3 -1
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/lifecycle.js +13 -6
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +19 -10
- package/dist/streaming/mp4Parser.js +39 -29
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/setup.d.ts +2 -0
- package/dist/streaming/setup.js +4 -1
- package/dist/streaming/setup.js.map +1 -1
- package/dist/types/index.d.ts +10 -7
- package/dist/types/index.js.map +1 -1
- package/dist/upgrade/commands.d.ts +6 -0
- package/dist/upgrade/commands.js +210 -0
- package/dist/upgrade/commands.js.map +1 -0
- package/dist/upgrade/detection.d.ts +15 -0
- package/dist/upgrade/detection.js +75 -0
- package/dist/upgrade/detection.js.map +1 -0
- package/dist/upgrade/index.d.ts +2 -0
- package/dist/upgrade/index.js +7 -0
- package/dist/upgrade/index.js.map +1 -0
- package/dist/utils/fileLogger.d.ts +2 -1
- package/dist/utils/fileLogger.js +10 -8
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/logger.js +1 -1
- package/dist/utils/m3u.js +1 -7
- package/dist/utils/m3u.js.map +1 -1
- package/dist/utils/platform.d.ts +8 -2
- package/dist/utils/platform.js +26 -6
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/version.d.ts +18 -0
- package/dist/utils/version.js +3 -3
- package/dist/utils/version.js.map +1 -1
- package/package.json +6 -6
|
@@ -4,11 +4,27 @@ import { CONFIG } from "../../config/index.js";
|
|
|
4
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
5
|
// during binary search iterations and used for direct-scroll optimization on subsequent tunes. Session-scoped — cleared when the browser restarts.
|
|
6
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();
|
|
14
|
+
// Tracks pages with details API response listeners to avoid duplicate registration. Mirrors the pagesWithListeners pattern in sling.ts.
|
|
15
|
+
const huluPagesWithListeners = new WeakSet();
|
|
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.
|
|
19
|
+
const HULU_LIVE_URL = "https://www.hulu.com/live";
|
|
7
20
|
/**
|
|
8
|
-
* Clears
|
|
21
|
+
* Clears all Hulu caches: guide row cache, channel UUID cache, and EAB program cache. Called by clearChannelSelectionCaches() in the coordinator when the
|
|
22
|
+
* browser restarts, since cached state may be stale in a new browser session.
|
|
9
23
|
*/
|
|
10
24
|
function clearHuluCache() {
|
|
11
25
|
guideRowCache.clear();
|
|
26
|
+
huluEabCache.clear();
|
|
27
|
+
huluUuidCache.clear();
|
|
12
28
|
}
|
|
13
29
|
/**
|
|
14
30
|
* 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
|
|
@@ -98,13 +114,18 @@ async function locateOnNowCell(page, targetName) {
|
|
|
98
114
|
// US broadcast call sign pattern. Local affiliate stations have 3-4 uppercase letter call signs starting with W (east of the Mississippi) or K (west). This
|
|
99
115
|
// pattern is used to identify call signs in the guide grid so position-based inference can find local affiliates that sort by their hidden network name.
|
|
100
116
|
const CALL_SIGN_PATTERN = /^[WK][A-Z]{2,3}$/i;
|
|
117
|
+
// Network names that map to local affiliate call signs in Hulu's guide. The details API returns channel_info.name as the local call sign rather than the
|
|
118
|
+
// network name, so these names won't match any details response entry. Cold direct tuning is skipped for these — the guide grid handles them via
|
|
119
|
+
// position-based inference. On the first guide grid tune, the in-page interceptor captures the affiliate's channel_id from the playlist request and caches
|
|
120
|
+
// it under the channelSelector key (e.g., "abc" → uuid). Subsequent tunes resolve via warm direct tuning.
|
|
121
|
+
const NETWORK_NAMES_WITH_AFFILIATES = new Set(["abc", "cbs", "cw", "fox", "nbc", "pbs"]);
|
|
101
122
|
/**
|
|
102
123
|
* Position-based inference for local affiliates. When binary search returns "missing" (target name sorts between rendered channels but no exact match), this
|
|
103
124
|
* function identifies the local affiliate at the target's alphabetical insertion point.
|
|
104
125
|
*
|
|
105
|
-
* The guide sorts local affiliates by their network name (ABC, CBS,
|
|
106
|
-
*
|
|
107
|
-
*
|
|
126
|
+
* The guide sorts most local affiliates by their network name (ABC, CBS, etc.), but displays call signs in data-testid. The binary search converges to the
|
|
127
|
+
* correct scroll position because the target network name sorts correctly, but the name match fails because the data-testid contains the call sign. The
|
|
128
|
+
* affiliate occupies the DOM position where the network name would be if it existed.
|
|
108
129
|
*
|
|
109
130
|
* Algorithm:
|
|
110
131
|
* 1. Filter rendered channels to non-call-sign names (these sort correctly by their displayed name)
|
|
@@ -202,10 +223,10 @@ async function waitForPlayButton(page, playSelector, timeout) {
|
|
|
202
223
|
}
|
|
203
224
|
}
|
|
204
225
|
/**
|
|
205
|
-
* Clicks the on-now program cell and waits for the play button
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
226
|
+
* Clicks the on-now program cell and waits for the play button, retrying on failure. Handles two distinct failure modes: (1) the on-now cell click doesn't
|
|
227
|
+
* register because React hasn't fully hydrated event handlers — the play button never appears; (2) the play button appears and is clicked but the click is
|
|
228
|
+
* silently swallowed — playback doesn't start. Both failures are detected by waitForPlayButton and trigger a retry of the full on-now cell and play button
|
|
229
|
+
* sequence after a brief delay.
|
|
209
230
|
* @param page - The Puppeteer page object.
|
|
210
231
|
* @param clickTarget - The lowercased, trimmed channel name to locate in the guide grid.
|
|
211
232
|
* @param playSelector - The CSS selector for the play button, or undefined if no play button is needed.
|
|
@@ -238,7 +259,7 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
|
|
|
238
259
|
if (result.success) {
|
|
239
260
|
return result;
|
|
240
261
|
}
|
|
241
|
-
// The play button either didn't appear (on-now cell click missed) or appeared and was clicked but
|
|
262
|
+
// The play button either didn't appear (on-now cell click missed) or appeared and was clicked but playback didn't start (play button click swallowed).
|
|
242
263
|
// Retry the full sequence after a brief delay to allow further React hydration.
|
|
243
264
|
if (attempt < MAX_CLICK_ATTEMPTS - 1) {
|
|
244
265
|
LOG.debug("tuning:hulu", "Channel selection attempt failed for %s: %s. Retrying (attempt %s of %s).", channelName, result.reason, attempt + 2, MAX_CLICK_ATTEMPTS);
|
|
@@ -293,6 +314,54 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
|
|
|
293
314
|
}
|
|
294
315
|
return { reason: "Play button did not appear after " + String(MAX_CLICK_ATTEMPTS) + " on-now cell click attempts for " + channelName + ".", success: false };
|
|
295
316
|
}
|
|
317
|
+
/**
|
|
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.
|
|
322
|
+
* @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.
|
|
325
|
+
* @param channelName - The original channel name for logging.
|
|
326
|
+
* @returns True if the injection succeeded, false if the interceptor's Promise had already resolved.
|
|
327
|
+
*/
|
|
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);
|
|
333
|
+
}
|
|
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);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
// 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.
|
|
341
|
+
try {
|
|
342
|
+
await page.keyboard.press("Escape");
|
|
343
|
+
await delay(300);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
LOG.debug("tuning:hulu", "Could not dismiss guide after fast-path tune: %s.", formatError(error));
|
|
347
|
+
}
|
|
348
|
+
LOG.debug("tuning:hulu", "Direct tune via fetch interception for inferred affiliate %s (uuid=%s).", channelName, channelUuid);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
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]).
|
|
355
|
+
* @param page - The Puppeteer page object.
|
|
356
|
+
*/
|
|
357
|
+
async function releaseHeldPlaylist(page) {
|
|
358
|
+
await page.evaluate(() => {
|
|
359
|
+
const release = window.__prismcastReleasePlaylist;
|
|
360
|
+
if (typeof release === "function") {
|
|
361
|
+
release();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
296
365
|
/**
|
|
297
366
|
* Guide grid strategy: finds a channel in a virtualized, alphabetically sorted channel grid by scrolling the page to the target row using binary search, then
|
|
298
367
|
* clicking the on-now program cell to open the playback overlay. This strategy works for sites like Hulu Live TV where the channel guide is rendered as a
|
|
@@ -301,7 +370,7 @@ async function clickOnNowCellAndPlay(page, clickTarget, playSelector, channelNam
|
|
|
301
370
|
*
|
|
302
371
|
* Three mechanisms handle different channel types:
|
|
303
372
|
* 1. Binary search with passive row number caching — primary mechanism for most channels (~800ms first time, ~200ms on cache hit)
|
|
304
|
-
* 2. Position-based inference — handles local affiliates when searching by network name (e.g., "ABC" finds
|
|
373
|
+
* 2. Position-based inference — handles local affiliates when searching by network name (e.g., "ABC" finds the local call sign at the right sort position)
|
|
305
374
|
* 3. Linear scan fallback — safety net for raw call sign searches or any channel the binary search cannot find (~2.4 seconds)
|
|
306
375
|
*
|
|
307
376
|
* The selection process:
|
|
@@ -482,12 +551,31 @@ async function guideGridStrategy(page, profile) {
|
|
|
482
551
|
if (inferredRow !== undefined) {
|
|
483
552
|
guideRowCache.set(normalizedName, inferredRow);
|
|
484
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
|
|
556
|
+
// 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
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
485
573
|
}
|
|
486
574
|
break;
|
|
487
575
|
}
|
|
488
576
|
// If binary search did not find the channel (and position inference didn't identify a local affiliate), fall back to a linear scan through all channels. This
|
|
489
|
-
// handles edge cases like raw call sign searches
|
|
490
|
-
//
|
|
577
|
+
// handles edge cases like raw call sign searches where localeCompare gives the wrong direction, or channels like "Lakeshore PBS" that sort by hidden network
|
|
578
|
+
// name but don't match the W/K call sign pattern.
|
|
491
579
|
if (!found) {
|
|
492
580
|
LOG.debug("tuning:hulu", "Binary search did not find %s. Starting linear scan fallback.", channelName);
|
|
493
581
|
for (let row = 0; row < totalRows; row += 10) {
|
|
@@ -520,6 +608,9 @@ async function guideGridStrategy(page, profile) {
|
|
|
520
608
|
}
|
|
521
609
|
return { reason: "Could not find channel " + channelName + " in guide grid.", success: false };
|
|
522
610
|
}
|
|
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.
|
|
613
|
+
await releaseHeldPlaylist(page);
|
|
523
614
|
// Click the on-now program cell and wait for the play button, with click retries to handle React hydration timing.
|
|
524
615
|
return await clickOnNowCellAndPlay(page, clickTarget, playSelector, channelName);
|
|
525
616
|
}
|
|
@@ -546,5 +637,514 @@ async function guideGridWithRetry(page, profile) {
|
|
|
546
637
|
}
|
|
547
638
|
return result;
|
|
548
639
|
}
|
|
549
|
-
|
|
640
|
+
/**
|
|
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.
|
|
646
|
+
* @param page - The Puppeteer page object.
|
|
647
|
+
*/
|
|
648
|
+
function setupDetailsResponseInterception(page) {
|
|
649
|
+
if (huluPagesWithListeners.has(page)) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
huluPagesWithListeners.add(page);
|
|
653
|
+
// Bridge in-page console.log("[HULU-DIAG]", ...) messages from the evaluateOnNewDocument interceptor to our LOG system. This is the only way to get
|
|
654
|
+
// diagnostic output from code running in the browser context back to the Node.js process.
|
|
655
|
+
page.on("console", (msg) => {
|
|
656
|
+
const text = msg.text();
|
|
657
|
+
if (text.startsWith("[HULU-DIAG]")) {
|
|
658
|
+
LOG.debug("tuning:hulu", text);
|
|
659
|
+
}
|
|
660
|
+
// 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.
|
|
662
|
+
if (text.startsWith("[HULU-CACHE] ")) {
|
|
663
|
+
const payload = text.slice("[HULU-CACHE] ".length);
|
|
664
|
+
const eqIdx = payload.indexOf("=");
|
|
665
|
+
if (eqIdx > 0) {
|
|
666
|
+
const name = payload.slice(0, eqIdx);
|
|
667
|
+
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);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
page.on("response", (response) => {
|
|
674
|
+
const url = response.url();
|
|
675
|
+
if (response.status() !== 200) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// Details API: capture channel UUID mappings for the persistent cache.
|
|
679
|
+
if (url.includes("guide.hulu.com/guide/details")) {
|
|
680
|
+
void response.json().then((data) => {
|
|
681
|
+
if (!Array.isArray(data.items)) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
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);
|
|
693
|
+
}).catch(() => {
|
|
694
|
+
// CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
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.
|
|
700
|
+
if (url.includes("guide.hulu.com/guide/listing")) {
|
|
701
|
+
void response.json().then((data) => {
|
|
702
|
+
if (!Array.isArray(data.channels)) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
let programCount = 0;
|
|
706
|
+
for (const channel of data.channels) {
|
|
707
|
+
if (channel.id && Array.isArray(channel.programs) && (channel.programs.length > 0)) {
|
|
708
|
+
huluEabCache.set(channel.id, channel.programs);
|
|
709
|
+
programCount += channel.programs.length;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
LOG.debug("tuning:hulu", "Listing API: %s channels, %s programs. EAB cache size: %s.", data.channels.length, programCount, huluEabCache.size);
|
|
713
|
+
}).catch(() => {
|
|
714
|
+
// CORS preflight responses (OPTIONS) return empty bodies that fail JSON parsing. This is expected and harmless.
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
}
|
|
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
|
+
/**
|
|
740
|
+
* 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.
|
|
746
|
+
* @param channelSelector - The channel selector string (e.g., "Fox", "CNN", "ESPN").
|
|
747
|
+
* @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.
|
|
749
|
+
*/
|
|
750
|
+
async function resolveHuluDirectUrl(channelSelector, page) {
|
|
751
|
+
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
|
|
754
|
+
// during both the guide grid flow (cold cache) and the intercepted page load (warm cache).
|
|
755
|
+
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;
|
|
760
|
+
const isWarmCache = Boolean(cachedUuid && cachedEab);
|
|
761
|
+
if (isWarmCache) {
|
|
762
|
+
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: warm cache for %s (uuid=%s, eab=%s).", channelSelector, cachedUuid, cachedEab);
|
|
763
|
+
}
|
|
764
|
+
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.
|
|
767
|
+
if (NETWORK_NAMES_WITH_AFFILIATES.has(normalizedName)) {
|
|
768
|
+
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s (local affiliate). Falling through to guide grid.", channelSelector);
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: cold cache for %s. Attempting cold direct tune via API interception.", channelSelector);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
LOG.debug("tuning:hulu", "resolveHuluDirectUrl: UUID cached for %s but no current EAB. Attempting direct tune via API interception.", channelSelector);
|
|
776
|
+
}
|
|
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.
|
|
783
|
+
const allCurrentEabs = [];
|
|
784
|
+
for (const channelUuid of huluEabCache.keys()) {
|
|
785
|
+
const currentEab = findCurrentEab(channelUuid);
|
|
786
|
+
if (currentEab) {
|
|
787
|
+
allCurrentEabs.push(currentEab);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/* 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.
|
|
795
|
+
*/
|
|
796
|
+
try {
|
|
797
|
+
// Always hold the playlist request. On warm cache, the interceptor swaps immediately. On cold cache (including affiliates), the guide grid strategy will either
|
|
798
|
+
// inject UUID+EAB via the fast path (resolving the held playlist) or release the hold before falling through to the click path.
|
|
799
|
+
const attemptDirectTune = true;
|
|
800
|
+
await page.evaluateOnNewDocument((initialUuid, initialEab, cachedUuids, cachedEabs, targetName, holdPlaylist) => {
|
|
801
|
+
// evaluateOnNewDocument runs in every frame, including ad iframes. We only want to intercept fetches in the main frame — iframes don't make Hulu API
|
|
802
|
+
// calls. The try/catch handles cross-origin iframes where accessing window.top throws a SecurityError.
|
|
803
|
+
try {
|
|
804
|
+
if (window.self !== window.top) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
/* Save original fetch before any page script runs. Using bind(window) ensures correct this-context when called outside window's property access chain,
|
|
812
|
+
* since Hulu's modules capture window.fetch in closures and invoke it as a plain function call.
|
|
813
|
+
*/
|
|
814
|
+
const originalFetch = window.fetch.bind(window);
|
|
815
|
+
// Mutable copies of the warm-cache values. On warm cache, these are set at install time. On cold cache, they start empty and are populated by
|
|
816
|
+
// captureDetailsData (uuid) and captureListingData (eab) as API responses arrive during page load.
|
|
817
|
+
let uuid = initialUuid;
|
|
818
|
+
let eab = initialEab;
|
|
819
|
+
// Mutable copy of holdPlaylist. The guide grid strategy sets this to false via __prismcastReleasePlaylist when the fast-path injection fails, reverting to
|
|
820
|
+
// the click-based flow where the playlist should pass through immediately and capture the affiliate UUID via [HULU-CACHE].
|
|
821
|
+
let holdActive = holdPlaylist;
|
|
822
|
+
// Tracks whether the initial page-load playlist request has been seen. When holdActive is false (cold affiliate guide grid click fallback), the first live
|
|
823
|
+
// playlist request carries the previously-playing channel's UUID — not the target's. We skip that one and only capture from subsequent requests, which
|
|
824
|
+
// are triggered by the guide grid's play button click and carry the correct affiliate UUID.
|
|
825
|
+
let initialPlaylistSeen = false;
|
|
826
|
+
// eslint-disable-next-line no-console
|
|
827
|
+
console.log("[HULU-DIAG] Fetch interceptor installed (" + ((uuid && eab) ? "warm" : "cold") + ")." + (uuid ? " uuid=" + uuid + " eab=" + (eab || "pending") : ""));
|
|
828
|
+
// Promise that resolves when both UUID and EAB are available for the playlist swap. On warm cache, resolves immediately (both injected at install time).
|
|
829
|
+
// On cold cache, resolves when the in-page listing and details API response parsers have captured both values. The playlist handler awaits this Promise
|
|
830
|
+
// to hold the request until the target channel's data is ready.
|
|
831
|
+
let directTuneResolve = null;
|
|
832
|
+
const directTunePromise = (uuid && eab) ?
|
|
833
|
+
Promise.resolve() :
|
|
834
|
+
new Promise((resolve) => { directTuneResolve = resolve; });
|
|
835
|
+
// 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
|
+
// responses. Order-independent — handles both "listing first, details second" and "details first, listing second" sequences.
|
|
837
|
+
function tryResolveDirectTune() {
|
|
838
|
+
if (uuid && eab && directTuneResolve) {
|
|
839
|
+
directTuneResolve();
|
|
840
|
+
directTuneResolve = null;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
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).
|
|
847
|
+
window.__prismcastResolveDirectTune = (u, e) => {
|
|
848
|
+
if (!directTuneResolve) {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
uuid = u;
|
|
852
|
+
eab = e;
|
|
853
|
+
tryResolveDirectTune();
|
|
854
|
+
return true;
|
|
855
|
+
};
|
|
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),
|
|
857
|
+
// 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
|
+
// the affiliate capture path ([HULU-CACHE]) on subsequent requests from the play button click.
|
|
859
|
+
window.__prismcastReleasePlaylist = () => {
|
|
860
|
+
holdActive = false;
|
|
861
|
+
if (directTuneResolve) {
|
|
862
|
+
directTuneResolve();
|
|
863
|
+
directTuneResolve = null;
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
// 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
|
+
// map is the sole source of expansion data for details API requests. Populated asynchronously when the first listing response arrives.
|
|
868
|
+
const capturedCurrentEabs = new Map();
|
|
869
|
+
// Deferred Promise that resolves when captureListingData finishes parsing the first listing response. Details API requests await this Promise (with a 2s
|
|
870
|
+
// timeout) before expanding, so even the very first details request gets expanded with listing-derived EABs. The listing response typically arrives
|
|
871
|
+
// ~200-600ms after the request fires, adding minimal latency to the details response — and the details API is not on the critical path for the channel
|
|
872
|
+
// grid that binary search needs (it only provides program info for the mini-guide overlay).
|
|
873
|
+
let listingCapturedResolve = null;
|
|
874
|
+
const listingCapturedPromise = new Promise((resolve) => {
|
|
875
|
+
listingCapturedResolve = resolve;
|
|
876
|
+
});
|
|
877
|
+
// 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).
|
|
879
|
+
function captureListingData(response) {
|
|
880
|
+
try {
|
|
881
|
+
void response.clone().json().then((data) => {
|
|
882
|
+
const channels = data.channels;
|
|
883
|
+
if (!Array.isArray(channels)) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const now = Date.now();
|
|
887
|
+
let captured = 0;
|
|
888
|
+
for (const channel of channels) {
|
|
889
|
+
if ((typeof channel.id !== "string") || !Array.isArray(channel.programs)) {
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
for (const program of channel.programs) {
|
|
893
|
+
if ((typeof program.eab === "string") && (typeof program.airingStart === "string") && (typeof program.airingEnd === "string")) {
|
|
894
|
+
if ((now >= new Date(program.airingStart).getTime()) && (now < new Date(program.airingEnd).getTime())) {
|
|
895
|
+
capturedCurrentEabs.set(channel.id, program.eab);
|
|
896
|
+
captured++;
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (captured > 0) {
|
|
903
|
+
// eslint-disable-next-line no-console
|
|
904
|
+
console.log("[HULU-DIAG] Listing response captured: " + String(captured) + " current EABs for details expansion.");
|
|
905
|
+
}
|
|
906
|
+
// If the target UUID is already known (from a details response that arrived first), look up its EAB in the freshly captured listing data. This
|
|
907
|
+
// handles the "details first, listing second" ordering — the details parser set uuid but couldn't find the EAB yet.
|
|
908
|
+
if (uuid && !eab) {
|
|
909
|
+
const capturedEab = capturedCurrentEabs.get(uuid);
|
|
910
|
+
if (capturedEab) {
|
|
911
|
+
eab = capturedEab;
|
|
912
|
+
tryResolveDirectTune();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// Signal that listing data is available. Any details request awaiting listingCapturedPromise will now proceed with expansion.
|
|
916
|
+
if (listingCapturedResolve) {
|
|
917
|
+
listingCapturedResolve();
|
|
918
|
+
listingCapturedResolve = null;
|
|
919
|
+
}
|
|
920
|
+
}).catch(() => { });
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
// Response doesn't support clone() or json() — silently skip capture.
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// Fire-and-forget: parses a details API response to extract the target channel's UUID for cold direct tune. On warm cache (uuid already set at install
|
|
927
|
+
// time), this is a no-op — the UUID is already available. On cold cache, this is the primary mechanism for discovering the target's UUID: the expanded
|
|
928
|
+
// details response contains channel_info for all ~123 channels, and we match by normalized name. After finding the UUID, looks up the EAB in the captured
|
|
929
|
+
// listing data and calls tryResolveDirectTune to release the held playlist request.
|
|
930
|
+
function captureDetailsData(response) {
|
|
931
|
+
if (uuid) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
void response.clone().json().then((data) => {
|
|
936
|
+
const items = data.items;
|
|
937
|
+
if (!Array.isArray(items)) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
for (const item of items) {
|
|
941
|
+
const info = item.channel_info;
|
|
942
|
+
if (info && (typeof info.name === "string") && (typeof info.id === "string")) {
|
|
943
|
+
const name = info.name.trim().toLowerCase().replace(/\s+/g, " ");
|
|
944
|
+
if (name === targetName) {
|
|
945
|
+
uuid = info.id;
|
|
946
|
+
// eslint-disable-next-line no-console
|
|
947
|
+
console.log("[HULU-DIAG] Details response: found UUID " + uuid + " for " + targetName + ".");
|
|
948
|
+
// Look up the EAB for this UUID in already-captured listing data. If the listing response arrived before this details response, the EAB is
|
|
949
|
+
// already in capturedCurrentEabs and we can resolve directTunePromise immediately.
|
|
950
|
+
const capturedEab = capturedCurrentEabs.get(uuid);
|
|
951
|
+
if (capturedEab) {
|
|
952
|
+
eab = capturedEab;
|
|
953
|
+
}
|
|
954
|
+
tryResolveDirectTune();
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}).catch(() => { });
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
// Response doesn't support clone() or json() — silently skip capture.
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// Extracts the request body from either the init options or a cloned Request object. Returns null if the body is not a string and the input is not a
|
|
966
|
+
// Request. Used by all three API handlers to normalize body extraction across the two fetch call patterns Hulu uses.
|
|
967
|
+
async function getBodyText(input, init) {
|
|
968
|
+
if (init && (typeof init.body === "string")) {
|
|
969
|
+
return init.body;
|
|
970
|
+
}
|
|
971
|
+
if (input instanceof Request) {
|
|
972
|
+
return await input.clone().text();
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
// Sends a fetch request with a modified body, reconstructing the request from a URL string when the input is a Request object. Using input.url as a
|
|
977
|
+
// string sidesteps the Fetch spec's body-lock check — getBodyText() locked the ReadableStream via input.clone().text(), and the spec checks lock status
|
|
978
|
+
// on the first argument BEFORE applying init overrides, causing a TypeError even though we provide a replacement body.
|
|
979
|
+
async function fetchWithBody(input, init, body) {
|
|
980
|
+
if (input instanceof Request) {
|
|
981
|
+
return await originalFetch(input.url, {
|
|
982
|
+
body,
|
|
983
|
+
credentials: input.credentials,
|
|
984
|
+
headers: input.headers,
|
|
985
|
+
method: input.method,
|
|
986
|
+
mode: input.mode
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
return await originalFetch(input, Object.assign({}, init ?? {}, { body }));
|
|
990
|
+
}
|
|
991
|
+
// Marked async to satisfy @typescript-eslint/promise-function-async since the function returns Promise<Response>.
|
|
992
|
+
window.fetch = async function (input, init) {
|
|
993
|
+
const url = (typeof input === "string") ? input : (input instanceof URL) ? input.href : input.url;
|
|
994
|
+
// Expand listing API requests to include all cached UUIDs. The mini-guide listing only sends ~10 channel UUIDs, but we have UUIDs from previous
|
|
995
|
+
// tunes. Injecting them into the request keeps the EAB cache fresh for all known channels, so subsequent warm tunes can resolve a current EAB without
|
|
996
|
+
// falling back to the guide grid. The full guide already sends all ~130 UUIDs, so the API handles larger arrays without issue.
|
|
997
|
+
if (url.includes("guide.hulu.com") && url.includes("/guide/listing")) {
|
|
998
|
+
const listingBody = await getBodyText(input, init);
|
|
999
|
+
if (listingBody) {
|
|
1000
|
+
try {
|
|
1001
|
+
const listingObj = JSON.parse(listingBody);
|
|
1002
|
+
if (Array.isArray(listingObj.channels)) {
|
|
1003
|
+
const existing = new Set(listingObj.channels);
|
|
1004
|
+
for (const id of cachedUuids) {
|
|
1005
|
+
existing.add(id);
|
|
1006
|
+
}
|
|
1007
|
+
listingObj.channels = [...existing];
|
|
1008
|
+
const listingResponse = await fetchWithBody(input, init, JSON.stringify(listingObj));
|
|
1009
|
+
captureListingData(listingResponse);
|
|
1010
|
+
return listingResponse;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
catch {
|
|
1014
|
+
// Body parse error — pass through unmodified.
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const listingPassthrough = await originalFetch(input, init);
|
|
1018
|
+
captureListingData(listingPassthrough);
|
|
1019
|
+
return listingPassthrough;
|
|
1020
|
+
}
|
|
1021
|
+
// Expand details API requests to include EABs for all known channels. The mini-guide details request only covers ~7 visible channels (~19 EABs),
|
|
1022
|
+
// but we have EABs from two sources: pre-injected cachedEabs from Node.js (available on warm tunes and subsequent cold tunes), and dynamically
|
|
1023
|
+
// captured capturedCurrentEabs from the listing API response (the primary source on the first cold tune). Merging both ensures the details response
|
|
1024
|
+
// returns channel_info (name→UUID mappings) for all ~123 channels on any tune, even the very first cold tune after a restart.
|
|
1025
|
+
if (url.includes("guide.hulu.com") && url.includes("/guide/details")) {
|
|
1026
|
+
// If the in-page EAB map is empty and no pre-injected EABs are available, wait for the listing response to be parsed before proceeding. This holds
|
|
1027
|
+
// the first details request for ~200-600ms until captureListingData resolves the Promise. The 2s timeout is a safety net — if the listing response
|
|
1028
|
+
// never arrives, the details request proceeds without expansion (same behavior as before).
|
|
1029
|
+
if ((capturedCurrentEabs.size === 0) && (cachedEabs.length === 0)) {
|
|
1030
|
+
await Promise.race([listingCapturedPromise, new Promise((resolve) => { setTimeout(resolve, 2000); })]);
|
|
1031
|
+
}
|
|
1032
|
+
const detailsBody = await getBodyText(input, init);
|
|
1033
|
+
if (detailsBody) {
|
|
1034
|
+
try {
|
|
1035
|
+
const detailsObj = JSON.parse(detailsBody);
|
|
1036
|
+
if (Array.isArray(detailsObj.eabs)) {
|
|
1037
|
+
const existing = new Set(detailsObj.eabs);
|
|
1038
|
+
// Add pre-injected EABs from Node.js cache (available on warm tunes and subsequent cold tunes with partial cache data).
|
|
1039
|
+
for (const id of cachedEabs) {
|
|
1040
|
+
existing.add(id);
|
|
1041
|
+
}
|
|
1042
|
+
// Add dynamically captured EABs from in-page listing response (primary source on the first cold tune when cachedEabs is empty).
|
|
1043
|
+
for (const capturedEab of capturedCurrentEabs.values()) {
|
|
1044
|
+
existing.add(capturedEab);
|
|
1045
|
+
}
|
|
1046
|
+
detailsObj.eabs = [...existing];
|
|
1047
|
+
const expandedResponse = await fetchWithBody(input, init, JSON.stringify(detailsObj));
|
|
1048
|
+
captureDetailsData(expandedResponse);
|
|
1049
|
+
return expandedResponse;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
// Body parse error — pass through unmodified.
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const detailsPassthrough = await originalFetch(input, init);
|
|
1057
|
+
captureDetailsData(detailsPassthrough);
|
|
1058
|
+
return detailsPassthrough;
|
|
1059
|
+
}
|
|
1060
|
+
// Intercept every live playlist request and swap channel_id and content_eab_id to the target channel. The 204 CORS preflight precedes the real POST,
|
|
1061
|
+
// so we keep swapping rather than one-shot to ensure the real request gets the modified body. Each stream gets its own page, so persistence is safe.
|
|
1062
|
+
if (url.includes("play.hulu.com") && url.includes("playlist")) {
|
|
1063
|
+
// Wait for UUID and EAB to be available. On warm cache, directTunePromise resolved immediately at creation time. On cold cache with holdActive
|
|
1064
|
+
// 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.
|
|
1067
|
+
if (holdActive) {
|
|
1068
|
+
await Promise.race([directTunePromise, new Promise((resolve) => { setTimeout(resolve, 8000); })]);
|
|
1069
|
+
}
|
|
1070
|
+
if (!uuid || !eab) {
|
|
1071
|
+
// Affiliate guide grid click fallback: holdActive was set to false by __prismcastReleasePlaylist, or was never enabled. The guide grid handles channel
|
|
1072
|
+
// selection via clicking, and Hulu's app fires this playlist request with the affiliate's channel_id. Capture it and emit a console signal so the
|
|
1073
|
+
// server-side listener can cache it under the channelSelector key for warm direct tuning on subsequent tunes.
|
|
1074
|
+
if (!holdActive) {
|
|
1075
|
+
const affiliateBody = await getBodyText(input, init);
|
|
1076
|
+
if (affiliateBody) {
|
|
1077
|
+
try {
|
|
1078
|
+
const bodyObj = JSON.parse(affiliateBody);
|
|
1079
|
+
if ((bodyObj.play_intent === "live") && (typeof bodyObj.channel_id === "string")) {
|
|
1080
|
+
if (initialPlaylistSeen) {
|
|
1081
|
+
// eslint-disable-next-line no-console
|
|
1082
|
+
console.log("[HULU-CACHE] " + targetName + "=" + bodyObj.channel_id);
|
|
1083
|
+
}
|
|
1084
|
+
initialPlaylistSeen = true;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
// Body parse error — skip capture.
|
|
1089
|
+
}
|
|
1090
|
+
// Must use fetchWithBody — getBodyText may have locked the Request's ReadableStream via clone().
|
|
1091
|
+
return fetchWithBody(input, init, affiliateBody);
|
|
1092
|
+
}
|
|
1093
|
+
return originalFetch(input, init);
|
|
1094
|
+
}
|
|
1095
|
+
// 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);
|
|
1098
|
+
}
|
|
1099
|
+
const bodyText = await getBodyText(input, init);
|
|
1100
|
+
if (bodyText) {
|
|
1101
|
+
try {
|
|
1102
|
+
const bodyObj = JSON.parse(bodyText);
|
|
1103
|
+
if (bodyObj.play_intent === "live") {
|
|
1104
|
+
const originalChannelId = String(bodyObj.channel_id);
|
|
1105
|
+
// eslint-disable-next-line camelcase
|
|
1106
|
+
bodyObj.channel_id = uuid;
|
|
1107
|
+
// eslint-disable-next-line camelcase
|
|
1108
|
+
bodyObj.content_eab_id = eab;
|
|
1109
|
+
// eslint-disable-next-line no-console
|
|
1110
|
+
console.log("[HULU-DIAG] Playlist swapped: channel_id " + originalChannelId + " -> " + uuid + ", eab -> " + eab);
|
|
1111
|
+
return await fetchWithBody(input, init, JSON.stringify(bodyObj));
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// Body parse error — pass through unmodified.
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return originalFetch(input, init);
|
|
1120
|
+
};
|
|
1121
|
+
}, cachedUuid ?? "", cachedEab ?? "", allCachedUuids, allCurrentEabs, normalizedName, attemptDirectTune);
|
|
1122
|
+
}
|
|
1123
|
+
catch (error) {
|
|
1124
|
+
LOG.debug("tuning:hulu", "Failed to install Hulu fetch interceptor: %s.", formatError(error));
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
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)) {
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
LOG.debug("tuning:hulu", "Fetch interceptor installed. Returning URL: %s.", HULU_LIVE_URL);
|
|
1134
|
+
return HULU_LIVE_URL;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
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.
|
|
1139
|
+
* @param channelSelector - The channel selector string to invalidate.
|
|
1140
|
+
*/
|
|
1141
|
+
function invalidateHuluDirectUrl(channelSelector) {
|
|
1142
|
+
huluUuidCache.delete(normalizeChannelName(channelSelector));
|
|
1143
|
+
}
|
|
1144
|
+
export const huluStrategy = {
|
|
1145
|
+
clearCache: clearHuluCache,
|
|
1146
|
+
execute: guideGridWithRetry,
|
|
1147
|
+
invalidateDirectUrl: invalidateHuluDirectUrl,
|
|
1148
|
+
resolveDirectUrl: resolveHuluDirectUrl
|
|
1149
|
+
};
|
|
550
1150
|
//# sourceMappingURL=hulu.js.map
|