357 lines
13 KiB
Python
357 lines
13 KiB
Python
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/(?P<type>movie|series)/)?(?:[a-z0-9-]+-)?"
|
|
r"(?P<id>[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,
|
|
)
|