diff --git a/HULU/__init__.py b/HULU/__init__.py index 334f539..2912105 100644 --- a/HULU/__init__.py +++ b/HULU/__init__.py @@ -1,8 +1,9 @@ from __future__ import annotations +from http.cookiejar import CookieJar +from typing import Any +import re import base64 import hashlib -from http.cookiejar import CookieJar -from typing import Any, Optional, Union import click import requests from click import Context @@ -20,6 +21,7 @@ class HULU(Service): \b Authorization: Cookies + Security: UHD@L3, UHD@SL3000 """ ALIASES = ["HULU"] @@ -37,27 +39,29 @@ class HULU(Service): @staticmethod @click.command(name="HULU", short_help="hulu.com") @click.argument("title", type=str) - @click.option("-m", "--movie", is_flag=True, default=False, help="Title is a Movie.") - @click.option("-mt", "--mpd-type", type=click.Choice(["new", "old"], case_sensitive=False), default="new", help="Which MPD type to use.") + @click.option( + "-mt", "--mpd-type", + type=click.Choice(["new", "old"], case_sensitive=False), + default="new", + help="Which MPD type to use.", + ) @click.pass_context def cli(ctx, **kwargs): return HULU(ctx, **kwargs) - def __init__(self, ctx: Context, title: str, movie: bool, mpd_type: str): + def __init__(self, ctx: Context, title: str, mpd_type: str): self.title = title - self.movie = movie self.mpd_type = mpd_type self.vcodec = ctx.parent.params.get("vcodec") - self.range = ctx.parent.params.get("range_") self.acodec = ctx.parent.params["acodec"] self.cdm = ctx.obj.cdm - self.license_url_widevine = None - self.license_url_playready = None + self.license_url_widevine: str | None = None + self.license_url_playready: str | None = None super().__init__(ctx) - def _fatal(self, msg: str) -> SystemExit: + def _fatal(self, msg: str) -> None: self.log.error(msg) - return SystemExit(1) + raise SystemExit(1) @staticmethod def _safe_json(response) -> dict: @@ -66,25 +70,48 @@ class HULU(Service): except Exception: return {} - def get_titles(self): - if self.movie: - resp = self.session.get(self.config["endpoints"]["movie"].format(id=self.title)) - try: - resp.raise_for_status() - except requests.HTTPError as e: - info = self._safe_json(e.response) - raise self._fatal(f" - Failed to get movie {self.title}: {info.get('message', e)}") + @staticmethod + def _strip_duplicate_representations(mpd: str) -> str: + seen: set[str] = set() - title_data = resp.json()["details"]["vod_items"]["focus"]["entity"] - return Movies([Movie( - id_=self.title, - 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 _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, + mpd, + flags=re.DOTALL, + ) + + def get_titles(self, *, _force_movie: bool = False): + if _force_movie: + return self._get_movie() + return self._get_series() + + def _get_movie(self): + resp = self.session.get(self.config["endpoints"]["movie"].format(id=self.title)) + try: + resp.raise_for_status() + except requests.HTTPError as e: + info = self._safe_json(e.response) + self._fatal(f" - Failed to get movie {self.title}: {info.get('message', e)}") + + title_data = resp.json()["details"]["vod_items"]["focus"]["entity"] + return Movies([Movie( + id_=self.title, + 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): resp = self.session.get(self.config["endpoints"]["series"].format(id=self.title)) try: resp.raise_for_status() @@ -93,14 +120,13 @@ class HULU(Service): message = info.get("message", "") if e.response.status_code == 400 and "entity type" in message.lower(): self.log.info(" - Detected movie UUID, retrying on movie endpoint.") - self.movie = True - return self.get_titles() - raise self._fatal(f" - Failed to get series {self.title}: {message} [{info.get('code')}]") + return self.get_titles(_force_movie=True) + self._fatal(f" - Failed to get series {self.title}: {message} [{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 self._fatal(" - Failed to get episodes.") + self._fatal(" - Failed to get episodes.") series = Series() for season in season_data.get("items", []): @@ -112,7 +138,7 @@ class HULU(Service): season_resp.raise_for_status() except requests.HTTPError as e: info = self._safe_json(e.response) - raise self._fatal(f" - Failed to get season {season_id_part}: {info.get('message', e)}") + self._fatal(f" - Failed to get season {season_id_part}: {info.get('message', e)}") for episode in season_resp.json().get("items", []): try: @@ -136,18 +162,28 @@ class HULU(Service): return series def get_tracks(self, title): - codec = "H265" if self.vcodec == Video.Codec.HEVC else "H264" + if self.vcodec == Video.Codec.HEVC: + codec = "H265" + elif self.vcodec == Video.Codec.AVC: + codec = "H264" + else: + self.log.warning(f" - Unrecognised vcodec '{self.vcodec}', defaulting to H264.") + codec = "H264" eab_id = (title.data.get("bundle") or {}).get("eab_id") if not eab_id: - raise self._fatal(f" - Could not find eab_id in {title}") + self._fatal(f" - Could not find eab_id in title data for '{title}'.") + + device_cfg = self.config["device_ids"] + deejay_id = device_cfg["new"] if self.mpd_type == "new" else device_cfg["old"] + version = 1 if self.mpd_type == "new" else 9999999 try: resp = self.session.post( url=self.config["endpoints"]["manifest"], json={ - "deejay_device_id": 210 if self.mpd_type == "new" else 166, - "version": 1 if self.mpd_type == "new" else 9999999, + "deejay_device_id": deejay_id, + "version": version, "all_cdn": False, "content_eab_id": eab_id, "region": "US", @@ -162,17 +198,21 @@ class HULU(Service): "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 isinstance(self.cdm, PlayReadyCdm) else self.config["drm"]["schemas_wv"], + "values": ( + self.config["drm"]["schemas_pr"] + if isinstance(self.cdm, PlayReadyCdm) + else self.config["drm"]["schemas_wv"] + ), "selection_mode": self.config["drm"]["selection_mode"], "hdcp": self.config["drm"]["hdcp"], }, @@ -193,20 +233,20 @@ class HULU(Service): "https": True, }], "selection_mode": "ONE", - } - } - } + }, + }, + }, ) resp.raise_for_status() playlist = resp.json() except requests.HTTPError as e: info = self._safe_json(e.response) - raise self._fatal(f" - Failed to fetch manifest: {info.get('message', e)} ({info.get('code')})") + self._fatal(f" - Failed to fetch manifest: {info.get('message', e)} ({info.get('code')})") except ValueError as e: - raise self._fatal(f" - Failed to decode manifest JSON: {e}") + self._fatal(f" - Failed to decode manifest JSON: {e}") if "stream_url" not in playlist: - raise self._fatal(f" - Manifest response missing 'stream_url'. Keys: {list(playlist.keys())}") + self._fatal(f" - Manifest response missing 'stream_url'. Keys: {list(playlist.keys())}") self.license_url_widevine = playlist.get("wv_server") self.license_url_playready = playlist.get("dash_pr_server") @@ -214,7 +254,10 @@ class HULU(Service): manifest = playlist["stream_url"] self.log.info(f"DASH: {manifest}") - tracks = DASH.from_url(manifest, self.session).to_tracks(title.language) + mpd_resp = self.session.get(manifest) + mpd_resp.raise_for_status() + mpd_text = self._strip_duplicate_representations(mpd_resp.text) + tracks = DASH.from_text(mpd_text, manifest).to_tracks(title.language) if self.acodec: mapped = self.AUDIO_CODEC_MAP.get(self.acodec) @@ -222,8 +265,8 @@ class HULU(Service): tracks.audio = [x for x in tracks.audio if (x.codec or "").startswith(mapped)] for track in tracks.audio: - if track.bitrate > 768000: - track.bitrate = 768000 + if track.bitrate > 768_000: + track.bitrate = 768_000 if track.channels == 6.0: track.channels = 5.1 @@ -239,16 +282,26 @@ class HULU(Service): return tracks - def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]: + def get_chapters(self, title: Movie | Episode) -> list[Chapter]: return [] - def get_widevine_license(self, challenge, track, **_): - resp = self.session.post( - url=self.license_url_widevine, - data=challenge, - headers={"Content-Type": "application/octet-stream"}, + def get_widevine_license(self, challenge, track, **_: Any): + try: + resp = self.session.post( + url=self.license_url_widevine, + data=challenge, + headers={"Content-Type": "application/octet-stream"}, + ) + resp.raise_for_status() + except requests.HTTPError as e: + self._fatal(f" - Widevine license request failed: {e}") + + self.log.debug( + f"License HTTP {resp.status_code}, " + f"Content-Type: {resp.headers.get('Content-Type')}, " + f"Body[:80]: {resp.content[:80]}" ) - self.log.debug(f"License HTTP {resp.status_code}, Content-Type: {resp.headers.get('Content-Type')}, Body[:80]: {resp.content[:80]}") + ct = resp.headers.get("Content-Type", "") if "json" in ct or resp.content[:1] == b"{": try: @@ -258,14 +311,20 @@ class HULU(Service): return base64.b64decode(data[key]) except Exception: pass + return resp.content - def get_playready_license(self, challenge, track, **_): - resp = self.session.post(url=self.license_url_playready, data=challenge) + def get_playready_license(self, challenge, track, **_: Any): + try: + resp = self.session.post(url=self.license_url_playready, data=challenge) + resp.raise_for_status() + except requests.HTTPError as e: + self._fatal(f" - PlayReady license request failed: {e}") + self.log.debug(resp.content) return resp.content - def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + def authenticate(self, cookies: CookieJar | None = None, credential: Credential | None = None) -> None: if cookies: self.session.cookies.update(cookies) self.session.headers.update({"User-Agent": self.config["user_agent"]})