sp4rky-devine-services/services/HULU/__init__.py
2025-04-09 15:07:35 -06:00

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,
)