from __future__ import annotations import base64 import hashlib import re from http.cookiejar import CookieJar from typing import Any, Optional import click from langcodes import Language from lxml import etree from unshackle.core.constants import AnyTrack from unshackle.core.credential import Credential from unshackle.core.manifests import DASH from unshackle.core.service import Service from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from unshackle.core.tracks import Chapter, Subtitle, Tracks, Video class HULU(Service): """ Service code for Hulu (https://hulu.com) www.nostalgic.cc Authorization: Cookies Geofence: US """ ALIASES = ("HULU", "hulu") GEOFENCE = ("US",) TITLE_RE = ( r"^(?:https?://(?:www\.)?hulu\.com/(?Pmovie|series)/)?" r"(?:[a-z0-9-]+-)?(?P[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})" ) AUDIO_CODEC_MAP = { "AAC": "mp4a", "EC3": "ec-3", } @staticmethod @click.command(name="HULU", short_help="hulu.com") @click.argument("title", type=str) @click.option( "-mt", "--mpd-type", type=click.Choice(["new", "old"], case_sensitive=False), default="new", help="Which MPD device type to use.", ) @click.pass_context def cli(ctx, **kwargs): return HULU(ctx, **kwargs) def __init__(self, ctx, title: str, mpd_type: str): self.title = title self.mpd_type = mpd_type self.vcodec = ctx.parent.params.get("vcodec") self.acodec = ctx.parent.params.get("acodec") self.license_url_widevine: Optional[str] = None self.license_url_playready: Optional[str] = None self._playlist: Optional[dict] = None try: from pyplayready.cdm import Cdm as PlayReadyCdm self.use_playready: bool = isinstance(ctx.obj.cdm, PlayReadyCdm) except ImportError: self.use_playready = False super().__init__(ctx) def get_titles(self) -> Titles_T: m = re.search(self.TITLE_RE, self.title) if not m: raise ValueError(f"Could not parse title ID from: {self.title!r}") title_id = m.group("id") detected_type = m.group("type") if detected_type == "movie": return self._get_movie(title_id) return self._get_series(title_id) def _get_movie(self, title_id: str) -> Movies: resp = self.session.get(self.config["endpoints"]["movie"].format(id=title_id)) try: resp.raise_for_status() except Exception as e: info = self._safe_json(resp) raise ValueError(f"Failed to get movie {title_id}: {info.get('message', e)}") title_data = resp.json()["details"]["vod_items"]["focus"]["entity"] return Movies([Movie( id_=title_id, service=self.__class__, name=title_data.get("name"), year=int(title_data["premiere_date"][:4]) if title_data.get("premiere_date") else None, language="en", data=title_data, )]) def _get_series(self, title_id: str) -> Series: resp = self.session.get(self.config["endpoints"]["series"].format(id=title_id)) try: resp.raise_for_status() except Exception as e: info = self._safe_json(resp) if resp.status_code == 400 and "entity type" in info.get("message", "").lower(): self.log.info("Detected movie UUID — retrying on movie endpoint.") return self._get_movie(title_id) raise ValueError( f"Failed to get series {title_id}: {info.get('message', e)} [{info.get('code')}]" ) res = resp.json() season_data = next( (x for x in res.get("components", []) if x.get("name") == "Episodes"), None, ) if not season_data: raise ValueError("Could not find episode list.") series = Series() for season in season_data.get("items", []): season_id_part = season.get("id", "").rsplit("::", 1)[-1] season_resp = self.session.get( self.config["endpoints"]["season"].format(id=title_id, season=season_id_part) ) try: season_resp.raise_for_status() except Exception as e: info = self._safe_json(season_resp) raise ValueError(f"Failed to get season {season_id_part}: {info.get('message', e)}") for episode in season_resp.json().get("items", []): try: ep_season = int(episode["season"]) if episode.get("season") is not None else None ep_number = int(episode["number"]) if episode.get("number") is not None else None except (ValueError, TypeError): ep_season = episode.get("season") ep_number = episode.get("number") series.add(Episode( id_=f"{season.get('id')}::{episode.get('season')}::{episode.get('number')}", service=self.__class__, title=episode.get("series_name"), season=ep_season, number=ep_number, name=episode.get("name"), language="en", data=episode, )) return series def get_tracks(self, title: Title_T) -> Tracks: if self.vcodec == Video.Codec.HEVC: codec = "H265" elif self.vcodec == Video.Codec.AVC: codec = "H264" else: codec = "H264" eab_id = (title.data.get("bundle") or {}).get("eab_id") if not eab_id: raise ValueError(f"Could not find eab_id in title data for '{title}'.") deejay_id = ( self.config["device_ids"]["new"] if self.mpd_type == "new" else self.config["device_ids"]["old"] ) version = 1 if self.mpd_type == "new" else 9999999 resp = None try: resp = self.session.post( url=self.config["endpoints"]["manifest"], json={ "deejay_device_id": deejay_id, "version": version, "all_cdn": False, "content_eab_id": eab_id, "region": "US", "language": "en", "unencrypted": True, "network_mode": "wifi", "play_intent": "resume", "playback": { "version": 2, "video": { "dynamic_range": "DOLBY_VISION", "codecs": { "values": [ x for x in self.config["codecs"]["video"] if x["type"] == codec ], "selection_mode": self.config["codecs"]["video_selection"], }, }, "audio": { "codecs": { "values": self.config["codecs"]["audio"], "selection_mode": self.config["codecs"]["audio_selection"], }, }, "drm": { "multi_key": True, "values": ( self.config["drm"]["schemas_pr"] if self.use_playready else self.config["drm"]["schemas_wv"] ), "selection_mode": self.config["drm"]["selection_mode"], "hdcp": self.config["drm"]["hdcp"], }, "manifest": { "type": "DASH", "https": True, "multiple_cdns": False, "patch_updates": True, "hulu_types": True, "live_dai": True, "secondary_audio": True, "live_fragment_delay": 3, "forced_narratives": True, "full_language_locales": True, }, "segments": { "values": [{ "type": "FMP4", "encryption": {"mode": "CENC", "type": "CENC"}, "https": True, }], "selection_mode": "ONE", }, }, }, ) resp.raise_for_status() playlist = resp.json() except Exception as e: info = self._safe_json(resp) if resp is not None else {} raise ValueError( f"Failed to fetch manifest: {info.get('message', e)} ({info.get('code', '')})" ) if "stream_url" not in playlist: raise ValueError( f"Manifest response missing 'stream_url'. Keys: {list(playlist.keys())}" ) self._playlist = playlist self.license_url_widevine = playlist.get("wv_server") self.license_url_playready = playlist.get("dash_pr_server") lang = playlist.get("video_metadata", {}).get("language") or "en" title.language = Language.get(lang) manifest_url = playlist["stream_url"] self.log.info(f"DASH: {manifest_url}") mpd_resp = self.session.get(manifest_url) mpd_resp.raise_for_status() mpd_text = mpd_resp.text if "disney" in manifest_url: mpd_text = self._normalize_ad_markers(mpd_text) mpd_text = self._strip_duplicate_representations(mpd_text) tracks = self._parse_dash(mpd_text, manifest_url, title.language) if self.acodec: mapped = self.AUDIO_CODEC_MAP.get(self.acodec) if mapped: tracks.audio = [x for x in tracks.audio if (x.codec or "").startswith(mapped)] for track in tracks.audio: if track.bitrate > 768_000: track.bitrate = 768_000 if track.channels == 6.0: track.channels = 5.1 for sub_lang, sub_url in playlist.get("transcripts_urls", {}).get("webvtt", {}).items(): tracks.add(Subtitle( id_=hashlib.md5(sub_url.encode()).hexdigest()[:6], url=sub_url, codec=Subtitle.Codec.from_mime("vtt"), language=sub_lang, forced=False, sdh=False, )) return tracks def get_chapters(self, title: Title_T) -> list[Chapter]: if not self._playlist: return [] meta = self._playlist.get("video_metadata", {}) segments_raw = meta.get("segments") end_credits_raw = meta.get("end_credits_time") frame_rate = int(meta.get("frame_rate") or 24) or 24 if not segments_raw and not end_credits_raw: return [] def smpte_to_timestamp(tc: str) -> str: tc = tc.strip() if ";" in tc: time_part, frames = tc.rsplit(";", 1) ms = int(round(int(frames) * 1000 / frame_rate)) elif "." in tc: time_part, frac = tc.rsplit(".", 1) ms = int(frac[:3].ljust(3, "0")) else: time_part = tc ms = 0 if ms >= 1000: h, m, s = (int(x) for x in time_part.split(":")) total_s = h * 3600 + m * 60 + s + ms // 1000 ms = ms % 1000 h, rem = divmod(total_s, 3600) m, s = divmod(rem, 60) time_part = f"{h:02d}:{m:02d}:{s:02d}" return f"{time_part}.{ms:03d}" timestamps = ["00:00:00.000"] if segments_raw: for seg in segments_raw.split(","): seg = seg.strip().lstrip("T:") if seg: timestamps.append(smpte_to_timestamp(seg)) if end_credits_raw: ts = smpte_to_timestamp(end_credits_raw) if ts != "00:00:00.000": timestamps.append(ts) return [ Chapter(timestamp=ts, name=f"Chapter {i + 1:02d}") for i, ts in enumerate(timestamps) ] def get_widevine_service_certificate(self, **_: Any) -> None: return None def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes: if not self.license_url_widevine: raise ValueError("Widevine license URL not set.") resp = self.session.post( url=self.license_url_widevine, data=challenge, headers={"Content-Type": "application/octet-stream"}, ) resp.raise_for_status() ct = resp.headers.get("Content-Type", "") if "json" in ct or resp.content[:1] == b"{": try: data = resp.json() for key in ("license", "license_data", "licenseData"): if key in data: return base64.b64decode(data[key]) except Exception: pass return resp.content def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes: if not self.license_url_playready: raise ValueError("PlayReady license URL not set.") resp = self.session.post( url=self.license_url_playready, data=challenge, ) if not resp.ok: self.log.error(f"PlayReady license error ({resp.status_code}): {resp.text[:500]}") resp.raise_for_status() return resp.content def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: if not cookies: raise EnvironmentError("Hulu requires browser cookies for authentication.") self.session.cookies.update(cookies) self.session.headers.update({"User-Agent": self.config["user_agent"]}) def _parse_dash(self, mpd_text: str, manifest_url: str, language) -> Tracks: try: return DASH.from_text(mpd_text, manifest_url).to_tracks(language) except ValueError as e: if "duplicate" not in str(e).lower(): raise self.log.warning( "Duplicate track IDs detected in manifest " "Retrying with graceful deduplication." ) from unshackle.core.tracks import Tracks as _Tracks _orig_add = _Tracks.add def _warn_only_add(self_t, tracks_in, warn_only: bool = False): return _orig_add(self_t, tracks_in, warn_only=True) _Tracks.add = _warn_only_add try: return DASH.from_text(mpd_text, manifest_url).to_tracks(language) finally: _Tracks.add = _orig_add @staticmethod def _safe_json(resp) -> dict: try: return resp.json() except Exception: return {} @staticmethod def _normalize_ad_markers(mpd: str) -> str: DASH_NS = "urn:mpeg:dash:schema:mpd:2011" TVA_DESC = ("urn:tva:metadata:cs:AudioPurposeCS:2007", "1") DASH_DESC = ("urn:mpeg:dash:role:2011", "descriptive") root = etree.fromstring(mpd.encode("utf-8")) for adaptation_set in root.iter(f"{{{DASH_NS}}}AdaptationSet"): if "audio" not in adaptation_set.get("mimeType", ""): continue has_desc_role = any( role.get("schemeIdUri") == "urn:mpeg:dash:role:2011" and role.get("value") == "description" for role in adaptation_set.findall(f"{{{DASH_NS}}}Role") ) if not has_desc_role: continue already_marked = any( (acc.get("schemeIdUri"), acc.get("value")) in (TVA_DESC, DASH_DESC) for acc in adaptation_set.findall(f"{{{DASH_NS}}}Accessibility") ) if already_marked: continue acc = etree.SubElement(adaptation_set, f"{{{DASH_NS}}}Accessibility") acc.set("schemeIdUri", TVA_DESC[0]) acc.set("value", TVA_DESC[1]) return etree.tostring(root, encoding="unicode") @staticmethod def _strip_duplicate_representations(mpd: str) -> str: def _dedup_block(match: re.Match) -> str: seen: set[str] = set() def _dedupe(m: re.Match) -> str: rid = m.group(1) if rid in seen: return "" seen.add(rid) return m.group(0) return re.sub( r']*\bid="([^"]+)"[^>]*>.*?', _dedupe, match.group(0), flags=re.DOTALL, ) return re.sub( r']*>.*?', _dedup_block, mpd, flags=re.DOTALL, )