from __future__ import annotations from click import Context import hashlib import click import requests from langcodes import Language from typing import Any, Union, Optional from devine.core.utils.pyhulu import Device, HuluClient from pywidevine.cdm import Cdm as WidevineCdm import json import logging from http.cookiejar import CookieJar from devine.core.service import Service from devine.core.titles import Movies, Movie, Titles_T, Title_T, Series, Episode from devine.core.config import config from devine.core.credential import Credential from devine.core.tracks import Chapters, Tracks, Subtitle, Chapter from devine.core.manifests import HLS, DASH from devine.core.tracks import Audio, Chapter, Subtitle, Tracks, Video class HULU(Service): """ Service code for the Hulu streaming service (https://hulu.com). \b Authorization: Cookies Security: UHD@L3 """ 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})" ) @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.pass_context def cli(ctx, **kwargs): return HULU(ctx, **kwargs) def __init__(self, ctx: Context, title, movie: bool): self.url = title #m = self.parse_title(ctx, title) #self.movie = movie or m.get("type") == "movie" self.title = title self.movie = movie super().__init__(ctx) self.vcodec = ctx.parent.params.get("vcodec") self.range = ctx.parent.params.get("range_") self.device: Device self.playback_params: dict = {} self.hulu_h264_client: HuluClient self.license_url: str def get_titles(self): titles = [] if self.movie: r = self.session.get( self.config["endpoints"]["movie"].format(id=self.title) ).json() if "message" in r: if r["message"] == "Unable to authenticate user": self.log.exit( " x Unable to authenticate user, are you sure the credentials are correct?" ) title_data = r["details"]["vod_items"]["focus"]["entity"] movie = Movie( id_=self.title, service=self.__class__, name=title_data["name"], year=int(title_data["premiere_date"][:4]), language="en", data=title_data ) return Movies([movie]) else: r = self.session.get( self.config["endpoints"]["series"].format(id=self.title) ).json() if r.get("code", 200) != 200: if "Invalid uuid for param 'entity_id'" in r["message"]: if len("-".join(self.title.split("-")[-5:])) != 36: missing_chars = 36 - len("-".join(self.title.split("-")[-5:])) self.log.exit( f"Content id '{'-'.join(self.title.split('-')[-5:])}' should have 36 characters.\nYou're missing {missing_chars} character(s). Please make sure you provide the complete link." ) else: self.log.exit( f"We were unable to retrieve this title from HULU...\nAre you sure '{'-'.join(self.title.split('-')[-5:])}' is the right content id?" ) self.log.exit( f"Failed to get titles for {self.title}: {r['message']} [{r['code']}]" ) season_data = next( (x for x in r["components"] if x["name"] == "Episodes"), None ) if not season_data: self.log.exit( f"We were unable to retrieve the episodes of '{r['name']}'\nIt's most likely you need a '{r['details']['entity']['primary_branding']['name']}' add-on at HULU" ) episode_count = 0 for season in season_data["items"]: episode_count += season["pagination"]["total_count"] self.total_titles = (len(season_data["items"]), episode_count) episodes = [] for season in season_data["items"]: episodes.extend( self.session.get( self.config["endpoints"]["season"].format( id=self.title, season=season["id"].rsplit("::", 1)[1] ) ).json()["items"] ) original_language = self.hulu_h264_client.load_playlist( episodes[0]["bundle"]["eab_id"] )["video_metadata"]["language"] titles = Series() for episode in episodes: titles.add( Episode( id_=f"{season['id']}::{episode['season']}::{episode['number']}", service=self.__class__, title=episode["series_name"], season=int(episode["season"]), number=int(episode["number"]), name=episode["name"], language="en", data=episode )) return titles def get_tracks(self, title: Title, HDR_available=False): if self.vcodec == "H.264" and self.range[0].name == "SDR": playlist = self.hulu_h264_client.load_playlist( title.data["bundle"]["eab_id"] ) self.license_url = playlist["wv_server"] tracks = DASH.from_url(playlist["stream_url"], self.session).to_tracks(title.language) else: playlist = self.hulu_h265_client.load_playlist( title.data["bundle"]["eab_id"] ) self.license_url = playlist["wv_server"] tracks = DASH.from_url(playlist["stream_url"], self.session).to_tracks(title.language) # video_pssh = next(x.pssh for x in tracks.videos if x.pssh) #for track in tracks.videos: # if track.hdr10: # """MPD only says HDR10+, but Hulu HDR streams are always # Dolby Vision Profile 8 with HDR10+ compatibility""" # HDR_available = True #if not HDR_available: if self.vcodec == "H.265" and self.range[0].name != "SDR": playlist = self.hulu_h264_client.load_playlist( title.data["bundle"]["eab_id"] ) self.license_url = playlist["wv_server"] tracks = DASH.from_url(playlist["stream_url"], self.session).to_tracks(title.language) self.range[0].name == "SDR" # for track in tracks.audio: # if not track.pssh: # track.pssh = video_pssh subtitle_tracks = [] for sub_lang, sub_url in playlist["transcripts_urls"]["webvtt"].items(): tracks.add(Subtitle( id_=hashlib.md5(sub_url.encode()).hexdigest()[0:6], #source=self.ALIASES[0], url=sub_url, # metadata codec=Subtitle.Codec.from_mime('vtt'), language=sub_lang, forced=False, # TODO: find out if sub is forced sdh=False # TODO: find out if sub is SDH/CC, it's actually quite likely to be true )) tracks.add(subtitle_tracks) #for subtitle in tracks.subtitles: # if subtitle.language.language == "en": # subtitle.sdh = True # TODO: don't assume SDH # title.tracks.subtitles.append(subtitle) return tracks def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]: return [] def get_widevine_service_certificate(self, **_: Any) -> str: return WidevineCdm.common_privacy_cert def get_widevine_license(self, challenge, track, **_): return self.session.post( url=self.license_url, data=challenge # expects bytes ).content # Service specific functions def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: self.session.cookies.update(cookies) self.device = Device( device_code=self.config["device"]["FireTV4K"]["code"], device_key=self.config["device"]["FireTV4K"]["key"], ) self.session.headers.update( { "User-Agent": self.config["user_agent"], } ) self.h264_playback_params = { "all_cdn": False, "region": "US", "language": "en", "interface_version": "1.9.0", "network_mode": "wifi", "play_intent": "resume", "playback": { "version": 2, "video": { "codecs": { "values": [ x for x in self.config["codecs"]["video"] if x["type"] == "H264" ], "selection_mode": self.config["codecs"]["video_selection"], } }, "audio": { "codecs": { "values": self.config["codecs"]["audio"], "selection_mode": self.config["codecs"]["audio_selection"], } }, "drm": { "values": self.config["drm"]["schemas"], "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", }, }, } self.h265_playback_params = { "all_cdn": False, "region": "US", "language": "en", "interface_version": "1.9.0", "network_mode": "wifi", "play_intent": "resume", "playback": { "version": 2, "video": { "dynamic_range": "HDR10PLUS", "codecs": { "values": [ x for x in self.config["codecs"]["video"] if x["type"] == "H265" ], "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"], "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", }, }, } self.hulu_h264_client = HuluClient( device=self.device, session=self.session, version=self.config["device"].get("device_version"), **self.h264_playback_params, ) self.hulu_h265_client = HuluClient( device=self.device, session=self.session, version=self.config["device"].get("device_version"), **self.h265_playback_params, )