Files
unshackle-services/HULU/__init__.py
T

533 lines
20 KiB
Python
Raw Normal View History

2026-04-22 21:17:11 -05:00
from __future__ import annotations
import base64
import hashlib
import re
from http.cookiejar import CookieJar
from typing import Any, Optional
import click
2026-07-13 07:25:35 -05:00
from click.core import ParameterSource
2026-04-22 21:17:11 -05:00
from langcodes import Language
from lxml import etree
from unshackle.core.constants import AnyTrack
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, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Subtitle, Tracks, Video
class HULU(Service):
"""
Service code for Hulu (https://hulu.com)
2026-07-09 08:34:30 -05:00
www.nostalgic.cc
2026-04-22 21:17:11 -05:00
Authorization: Cookies
Geofence: US
"""
ALIASES = ("HULU", "hulu")
GEOFENCE = ("US",)
TITLE_RE = (
r"^(?:https?://(?:www\.)?hulu\.com/(?P<type>movie|series)/)?"
r"(?:[a-z0-9-]+-)?(?P<id>[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})"
)
2026-07-13 07:25:35 -05:00
HULU_RANGE_MAP = {
"SDR": "SDR",
"HLG": "DOLBY_VISION",
"HDR10": "DOLBY_VISION",
"HDR10P": "DOLBY_VISION",
"DV": "DOLBY_VISION",
2026-04-22 21:17:11 -05:00
}
@staticmethod
@click.command(name="HULU", short_help="hulu.com")
@click.argument("title", type=str)
@click.option(
"-mt", "--mpd-type",
type=click.Choice(["new", "old"], case_sensitive=False),
2026-07-14 10:12:47 -05:00
default="new",
2026-07-13 07:25:35 -05:00
help="Device profile to request (Old=166, New=210).",
2026-04-22 21:17:11 -05:00
)
2026-07-13 07:25:35 -05:00
@click.option("-m", "--movie", is_flag=True, default=False,
help="Force the title to be treated as a movie.")
@click.option("-sh", "--show", "force_series", is_flag=True, default=False,
help="Force the title to be treated as a show.")
2026-04-22 21:17:11 -05:00
@click.pass_context
def cli(ctx, **kwargs):
return HULU(ctx, **kwargs)
2026-07-13 07:25:35 -05:00
def __init__(self, ctx, title: str, mpd_type: str, movie: bool, force_series: bool):
2026-04-22 21:17:11 -05:00
self.title = title
self.mpd_type = mpd_type
2026-07-13 07:25:35 -05:00
self.movie = movie
self.force_series = force_series
self.vcodec = ctx.parent.params.get("vcodec") or []
self.acodec = ctx.parent.params.get("acodec") or []
range_ = ctx.parent.params.get("range_")
self.range = range_[0].name if range_ else "SDR"
self.range_source = ctx.get_parameter_source("range_")
2026-04-22 21:17:11 -05:00
self.license_url_widevine: Optional[str] = None
self.license_url_playready: Optional[str] = None
self._playlist: Optional[dict] = None
try:
from pyplayready.cdm import Cdm as PlayReadyCdm
self.use_playready: bool = isinstance(ctx.obj.cdm, PlayReadyCdm)
except ImportError:
self.use_playready = False
super().__init__(ctx)
def get_titles(self) -> Titles_T:
m = re.search(self.TITLE_RE, self.title)
if not m:
raise ValueError(f"Could not parse title ID from: {self.title!r}")
title_id = m.group("id")
detected_type = m.group("type")
2026-07-13 07:25:35 -05:00
if self.movie:
return self._get_movie(title_id)
if self.force_series:
return self._get_series(title_id, allow_movie_fallback=False)
2026-04-22 21:17:11 -05:00
if detected_type == "movie":
return self._get_movie(title_id)
2026-07-13 07:25:35 -05:00
return self._get_series(title_id, allow_movie_fallback=True)
2026-04-22 21:17:11 -05:00
def _get_movie(self, title_id: str) -> Movies:
resp = self.session.get(self.config["endpoints"]["movie"].format(id=title_id))
try:
resp.raise_for_status()
except Exception as e:
info = self._safe_json(resp)
raise ValueError(f"Failed to get movie {title_id}: {info.get('message', e)}")
title_data = resp.json()["details"]["vod_items"]["focus"]["entity"]
return Movies([Movie(
id_=title_id,
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,
)])
2026-07-13 07:25:35 -05:00
def _get_series(self, title_id: str, allow_movie_fallback: bool = True) -> Series:
2026-04-22 21:17:11 -05:00
resp = self.session.get(self.config["endpoints"]["series"].format(id=title_id))
try:
resp.raise_for_status()
except Exception as e:
info = self._safe_json(resp)
2026-07-13 07:25:35 -05:00
if allow_movie_fallback and resp.status_code == 400 and "entity type" in info.get("message", "").lower():
self.log.info("Detected movie UUID. Retrying movie endpoint.")
2026-04-22 21:17:11 -05:00
return self._get_movie(title_id)
raise ValueError(
f"Failed to get series {title_id}: {info.get('message', e)} [{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 ValueError("Could not find episode list.")
series = Series()
for season in season_data.get("items", []):
2026-07-13 07:25:35 -05:00
embedded = season.get("items")
if embedded and any(ep.get("_type") == "episode" for ep in embedded):
for episode in embedded:
self._add_episode(series, season, episode)
continue
2026-04-22 21:17:11 -05:00
season_id_part = season.get("id", "").rsplit("::", 1)[-1]
season_resp = self.session.get(
self.config["endpoints"]["season"].format(id=title_id, season=season_id_part)
)
try:
season_resp.raise_for_status()
except Exception as e:
info = self._safe_json(season_resp)
raise ValueError(f"Failed to get season {season_id_part}: {info.get('message', e)}")
for episode in season_resp.json().get("items", []):
2026-07-13 07:25:35 -05:00
self._add_episode(series, season, episode)
2026-04-22 21:17:11 -05:00
return series
2026-07-13 07:25:35 -05:00
def _add_episode(self, series: Series, season: dict, episode: dict) -> None:
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,
))
def _dynamic_range(self) -> str:
if self.range_source != ParameterSource.COMMANDLINE:
return "DOLBY_VISION"
return self.HULU_RANGE_MAP.get(self.range, "SDR")
def _codec_preference(self) -> list:
if Video.Codec.HEVC in self.vcodec:
return ["H265"]
if Video.Codec.AVC in self.vcodec:
return ["H264"]
return ["H265", "H264"]
def _request_playlist(self, eab_id: str, codec: str, dynamic_range: str) -> dict:
2026-04-22 21:17:11 -05:00
deejay_id = (
2026-07-13 07:25:35 -05:00
self.config["device_ids"]["new"] if self.mpd_type == "new"
2026-04-22 21:17:11 -05:00
else self.config["device_ids"]["old"]
)
version = 1 if self.mpd_type == "new" else 9999999
resp = None
try:
resp = self.session.post(
url=self.config["endpoints"]["manifest"],
json={
"deejay_device_id": deejay_id,
"version": version,
"all_cdn": False,
"content_eab_id": eab_id,
"region": "US",
"language": "en",
"unencrypted": True,
"network_mode": "wifi",
"play_intent": "resume",
"playback": {
"version": 2,
"video": {
2026-07-13 07:25:35 -05:00
"dynamic_range": dynamic_range,
2026-04-22 21:17:11 -05:00
"codecs": {
2026-07-13 07:25:35 -05:00
"values": [x for x in self.config["codecs"]["video"] if x["type"] == codec],
2026-04-22 21:17:11 -05:00
"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 self.use_playready
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,
"forced_narratives": True,
"full_language_locales": True,
},
"segments": {
"values": [{
"type": "FMP4",
"encryption": {"mode": "CENC", "type": "CENC"},
"https": True,
}],
"selection_mode": "ONE",
},
},
},
)
resp.raise_for_status()
playlist = resp.json()
except Exception as e:
info = self._safe_json(resp) if resp is not None else {}
2026-07-13 07:25:35 -05:00
raise ValueError(f"{info.get('message', e)} ({info.get('code', '')})")
2026-04-22 21:17:11 -05:00
if "stream_url" not in playlist:
2026-07-13 07:25:35 -05:00
raise ValueError(f"Manifest response missing 'stream_url' (Keys: {list(playlist.keys())})")
return playlist
2026-04-22 21:17:11 -05:00
2026-07-13 07:25:35 -05:00
def get_tracks(self, title: Title_T) -> Tracks:
eab_id = (title.data.get("bundle") or {}).get("eab_id")
if not eab_id:
raise ValueError(f"Could not find eab_id in title data for '{title}'.")
2026-04-22 21:17:11 -05:00
2026-07-13 07:25:35 -05:00
codec_chain = self._codec_preference()
tracks: Optional[Tracks] = None
last_reason = ""
2026-04-22 21:17:11 -05:00
2026-07-13 07:25:35 -05:00
for codec in codec_chain:
dynamic_range = self._dynamic_range() if codec == "H265" else "SDR"
if len(codec_chain) > 1:
self.log.info(f"Requesting {codec} manifest ({dynamic_range})...")
2026-04-22 21:17:11 -05:00
2026-07-13 07:25:35 -05:00
try:
playlist = self._request_playlist(eab_id, codec, dynamic_range)
except ValueError as e:
last_reason = str(e)
self.log.warning(f" - {codec} manifest unavailable: {e}")
continue
self._playlist = playlist
self.license_url_widevine = playlist.get("wv_server")
self.license_url_playready = playlist.get("dash_pr_server")
lang = playlist.get("video_metadata", {}).get("language") or "en"
title.language = Language.get(lang)
manifest_url = playlist["stream_url"]
self.log.info(f"DASH: {manifest_url}")
mpd_resp = self.session.get(manifest_url)
mpd_resp.raise_for_status()
mpd_text = mpd_resp.text
if "disney" in manifest_url:
mpd_text = self._normalize_ad_markers(mpd_text)
mpd_text = self._strip_duplicate_representations(mpd_text)
parsed = self._parse_dash(mpd_text, manifest_url, title.language)
if parsed.videos:
if codec != codec_chain[0]:
self.log.info(f"{codec_chain[0]} not available for this title. Using {codec}.")
tracks = parsed
break
last_reason = f"no {codec} video tracks in manifest"
self.log.warning(f" - {last_reason}; trying next codec.")
if tracks is None:
raise ValueError(
f"Could not get a usable video manifest for '{title}' "
f"(tried {', '.join(codec_chain)}). {last_reason}"
)
2026-04-22 21:17:11 -05:00
if self.acodec:
2026-07-13 07:25:35 -05:00
tracks.audio = [x for x in tracks.audio if x.codec in self.acodec]
2026-04-22 21:17:11 -05:00
for track in tracks.audio:
2026-07-13 07:25:35 -05:00
if track.bitrate and track.bitrate > 768_000:
2026-04-22 21:17:11 -05:00
track.bitrate = 768_000
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: Title_T) -> list[Chapter]:
if not self._playlist:
return []
2026-07-13 07:25:35 -05:00
try:
meta = self._playlist.get("video_metadata", {})
segments_raw = meta.get("segments")
end_credits_raw = meta.get("end_credits_time")
frame_rate = int(meta.get("frame_rate") or 24) or 24
if not segments_raw and not end_credits_raw:
return []
def smpte_to_timestamp(tc: str) -> str:
tc = tc.strip()
if ";" in tc:
time_part, frames = tc.rsplit(";", 1)
ms = int(round(int(frames) * 1000 / frame_rate))
elif "." in tc:
time_part, frac = tc.rsplit(".", 1)
ms = int(frac[:3].ljust(3, "0"))
else:
time_part = tc
ms = 0
if ms >= 1000:
h, m, s = (int(x) for x in time_part.split(":"))
total_s = h * 3600 + m * 60 + s + ms // 1000
ms = ms % 1000
h, rem = divmod(total_s, 3600)
m, s = divmod(rem, 60)
time_part = f"{h:02d}:{m:02d}:{s:02d}"
return f"{time_part}.{ms:03d}"
timestamps = ["00:00:00.000"]
if segments_raw:
for seg in segments_raw.split(","):
seg = seg.strip().lstrip("T:")
if seg:
timestamps.append(smpte_to_timestamp(seg))
if end_credits_raw:
ts = smpte_to_timestamp(end_credits_raw)
if ts != "00:00:00.000":
timestamps.append(ts)
timestamps = sorted(set(timestamps))
return [Chapter(timestamp=ts) for ts in timestamps]
except Exception as e:
self.log.warning(f"Failed to parse chapters: {e}")
2026-04-22 21:17:11 -05:00
return []
def get_widevine_service_certificate(self, **_: Any) -> None:
return None
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.license_url_widevine:
raise ValueError("Widevine license URL not set.")
resp = self.session.post(
url=self.license_url_widevine,
data=challenge,
headers={"Content-Type": "application/octet-stream"},
)
resp.raise_for_status()
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: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.license_url_playready:
raise ValueError("PlayReady license URL not set.")
resp = self.session.post(
url=self.license_url_playready,
data=challenge,
)
if not resp.ok:
self.log.error(f"PlayReady license error ({resp.status_code}): {resp.text[:500]}")
resp.raise_for_status()
return resp.content
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
if not cookies:
2026-07-13 07:25:35 -05:00
raise EnvironmentError("Hulu requires cookies for authentication.")
2026-04-22 21:17:11 -05:00
self.session.cookies.update(cookies)
2026-07-13 07:25:35 -05:00
self.session.headers.update({
"User-Agent": self.config["user_agent"],
"Origin": "https://www.hulu.com",
"Referer": "https://www.hulu.com/",
})
2026-04-22 21:17:11 -05:00
def _parse_dash(self, mpd_text: str, manifest_url: str, language) -> Tracks:
try:
return DASH.from_text(mpd_text, manifest_url).to_tracks(language)
except ValueError as e:
if "duplicate" not in str(e).lower():
raise
self.log.warning(
"Duplicate track IDs detected in manifest "
2026-07-13 07:25:35 -05:00
"Retrying with deduplication."
2026-04-22 21:17:11 -05:00
)
from unshackle.core.tracks import Tracks as _Tracks
_orig_add = _Tracks.add
def _warn_only_add(self_t, tracks_in, warn_only: bool = False):
return _orig_add(self_t, tracks_in, warn_only=True)
_Tracks.add = _warn_only_add
try:
return DASH.from_text(mpd_text, manifest_url).to_tracks(language)
finally:
_Tracks.add = _orig_add
@staticmethod
def _safe_json(resp) -> dict:
try:
return resp.json()
except Exception:
return {}
@staticmethod
def _normalize_ad_markers(mpd: str) -> str:
DASH_NS = "urn:mpeg:dash:schema:mpd:2011"
TVA_DESC = ("urn:tva:metadata:cs:AudioPurposeCS:2007", "1")
DASH_DESC = ("urn:mpeg:dash:role:2011", "descriptive")
root = etree.fromstring(mpd.encode("utf-8"))
for adaptation_set in root.iter(f"{{{DASH_NS}}}AdaptationSet"):
if "audio" not in adaptation_set.get("mimeType", ""):
continue
has_desc_role = any(
role.get("schemeIdUri") == "urn:mpeg:dash:role:2011"
and role.get("value") == "description"
for role in adaptation_set.findall(f"{{{DASH_NS}}}Role")
)
if not has_desc_role:
continue
already_marked = any(
(acc.get("schemeIdUri"), acc.get("value")) in (TVA_DESC, DASH_DESC)
for acc in adaptation_set.findall(f"{{{DASH_NS}}}Accessibility")
)
if already_marked:
continue
acc = etree.SubElement(adaptation_set, f"{{{DASH_NS}}}Accessibility")
acc.set("schemeIdUri", TVA_DESC[0])
acc.set("value", TVA_DESC[1])
return etree.tostring(root, encoding="unicode")
@staticmethod
def _strip_duplicate_representations(mpd: str) -> str:
def _dedup_block(match: re.Match) -> str:
seen: set[str] = set()
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'<Representation[^>]*\bid="([^"]+)"[^>]*>.*?</Representation>',
_dedupe,
match.group(0),
flags=re.DOTALL,
)
return re.sub(
r'<AdaptationSet[^>]*>.*?</AdaptationSet>',
_dedup_block,
mpd,
flags=re.DOTALL,
2026-07-09 08:34:30 -05:00
)