diff --git a/HULU/__init__.py b/HULU/__init__.py new file mode 100644 index 0000000..334f539 --- /dev/null +++ b/HULU/__init__.py @@ -0,0 +1,271 @@ +from __future__ import annotations +import base64 +import hashlib +from http.cookiejar import CookieJar +from typing import Any, Optional, Union +import click +import requests +from click import Context +from pyplayready.cdm import Cdm as PlayReadyCdm +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 +from unshackle.core.tracks import Chapter, Subtitle, Video + + +class HULU(Service): + """ + Service code for the Hulu streaming service (https://hulu.com). + + \b + Authorization: Cookies + """ + + ALIASES = ["HULU"] + + TITLE_RE = ( + r"^(?:https?://(?:www\.)?hulu\.com/(?Pmovie|series)/)?(?:[a-z0-9-]+-)?" + r"(?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("-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.pass_context + def cli(ctx, **kwargs): + return HULU(ctx, **kwargs) + + def __init__(self, ctx: Context, title: str, movie: bool, 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 + super().__init__(ctx) + + def _fatal(self, msg: str) -> SystemExit: + self.log.error(msg) + return SystemExit(1) + + @staticmethod + def _safe_json(response) -> dict: + try: + return response.json() + 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)}") + + 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, + )]) + + resp = self.session.get(self.config["endpoints"]["series"].format(id=self.title)) + try: + resp.raise_for_status() + except requests.HTTPError as e: + info = self._safe_json(e.response) + 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')}]") + + 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.") + + 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=self.title, season=season_id_part) + ) + try: + 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)}") + + 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): + codec = "H265" if self.vcodec == Video.Codec.HEVC else "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}") + + 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, + "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 isinstance(self.cdm, PlayReadyCdm) 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, + }, + "segments": { + "values": [{ + "type": "FMP4", + "encryption": {"mode": "CENC", "type": "CENC"}, + "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')})") + except ValueError as e: + raise 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.license_url_widevine = playlist.get("wv_server") + self.license_url_playready = playlist.get("dash_pr_server") + + manifest = playlist["stream_url"] + self.log.info(f"DASH: {manifest}") + + tracks = DASH.from_url(manifest, self.session).to_tracks(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 > 768000: + track.bitrate = 768000 + 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: Union[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"}, + ) + 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: + 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, track, **_): + resp = self.session.post(url=self.license_url_playready, data=challenge) + self.log.debug(resp.content) + return resp.content + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + if cookies: + self.session.cookies.update(cookies) + self.session.headers.update({"User-Agent": self.config["user_agent"]}) diff --git a/HULU/config.yaml b/HULU/config.yaml new file mode 100644 index 0000000..7240fbc --- /dev/null +++ b/HULU/config.yaml @@ -0,0 +1,71 @@ +user_agent: 'Mozilla/5.0 (Linux; Android 9; AFTMM Build/PS7233; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/118.0.0.0 Mobile Safari/537.36' + +endpoints: + movie: 'https://discover.hulu.com/content/v5/hubs/movie/{id}?limit=999&schema=9&referralHost=production' + series: 'https://discover.hulu.com/content/v5/hubs/series/{id}?limit=999&schema=9&referralHost=production' + season: 'https://discover.hulu.com/content/v5/hubs/series/{id}/season/{season}?limit=999&schema=9&referralHost=production' + manifest: 'https://play.hulu.com/v6/playlist' + +device: + PC: + code: '159' + key: '6ebfc84f3c304217b20fd9a66cb5957f' + + Chrome: + code: '190' + key: '6ebfc84f3c304217b20fd9a66cb5957f' + + FireTV: + code: '188' + key: 'ca8d311a734854871623d906b968a073' + + FireTV4K: + code: '208' + key: 'fa49ca06261fe41b6e56fa2d24b4f295' + + Shield: + code: '109' + key: 'd0f4adc1d8a774256acb00c0fff46f5f' + + Shield2: + code: '142' + key: 'd6bdf1f49c73db36f465536162ccc830' + +codecs: + video_selection: 'ONE' + audio_selection: 'ALL' + + video: + - type: 'H265' + profile: 'MAIN_10' + width: 3840 + height: 2160 + framerate: 60 + level: '5.1' + tier: 'MAIN' + + - type: 'H264' + profile: 'HIGH' + width: 1920 + height: 1080 + framerate: 60 + level: '5.2' + + audio: + - type: 'AAC' + + - type: 'EC3' + +drm: + selection_mode: 'ONE' + hdcp: true + + schemas_wv: + - type: 'WIDEVINE' + version: 'MODULAR' + security_level: 'L1' + + schemas_pr: + - type: 'PLAYREADY' + version: 'V2' + security_level: 'SL3000'