Update __init__.py

This commit is contained in:
n0stal6ic
2026-03-12 14:38:09 -05:00
committed by GitHub
parent 56cd071191
commit 468de5a244
+99 -40
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from http.cookiejar import CookieJar
from typing import Any
import re
import base64 import base64
import hashlib import hashlib
from http.cookiejar import CookieJar
from typing import Any, Optional, Union
import click import click
import requests import requests
from click import Context from click import Context
@@ -20,6 +21,7 @@ class HULU(Service):
\b \b
Authorization: Cookies Authorization: Cookies
Security: UHD@L3, UHD@SL3000
""" """
ALIASES = ["HULU"] ALIASES = ["HULU"]
@@ -37,27 +39,29 @@ class HULU(Service):
@staticmethod @staticmethod
@click.command(name="HULU", short_help="hulu.com") @click.command(name="HULU", short_help="hulu.com")
@click.argument("title", type=str) @click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Title is a Movie.") @click.option(
@click.option("-mt", "--mpd-type", type=click.Choice(["new", "old"], case_sensitive=False), default="new", help="Which MPD type to use.") "-mt", "--mpd-type",
type=click.Choice(["new", "old"], case_sensitive=False),
default="new",
help="Which MPD type to use.",
)
@click.pass_context @click.pass_context
def cli(ctx, **kwargs): def cli(ctx, **kwargs):
return HULU(ctx, **kwargs) return HULU(ctx, **kwargs)
def __init__(self, ctx: Context, title: str, movie: bool, mpd_type: str): def __init__(self, ctx: Context, title: str, mpd_type: str):
self.title = title self.title = title
self.movie = movie
self.mpd_type = mpd_type self.mpd_type = mpd_type
self.vcodec = ctx.parent.params.get("vcodec") self.vcodec = ctx.parent.params.get("vcodec")
self.range = ctx.parent.params.get("range_")
self.acodec = ctx.parent.params["acodec"] self.acodec = ctx.parent.params["acodec"]
self.cdm = ctx.obj.cdm self.cdm = ctx.obj.cdm
self.license_url_widevine = None self.license_url_widevine: str | None = None
self.license_url_playready = None self.license_url_playready: str | None = None
super().__init__(ctx) super().__init__(ctx)
def _fatal(self, msg: str) -> SystemExit: def _fatal(self, msg: str) -> None:
self.log.error(msg) self.log.error(msg)
return SystemExit(1) raise SystemExit(1)
@staticmethod @staticmethod
def _safe_json(response) -> dict: def _safe_json(response) -> dict:
@@ -66,14 +70,36 @@ class HULU(Service):
except Exception: except Exception:
return {} return {}
def get_titles(self): @staticmethod
if self.movie: def _strip_duplicate_representations(mpd: str) -> 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,
mpd,
flags=re.DOTALL,
)
def get_titles(self, *, _force_movie: bool = False):
if _force_movie:
return self._get_movie()
return self._get_series()
def _get_movie(self):
resp = self.session.get(self.config["endpoints"]["movie"].format(id=self.title)) resp = self.session.get(self.config["endpoints"]["movie"].format(id=self.title))
try: try:
resp.raise_for_status() resp.raise_for_status()
except requests.HTTPError as e: except requests.HTTPError as e:
info = self._safe_json(e.response) info = self._safe_json(e.response)
raise self._fatal(f" - Failed to get movie {self.title}: {info.get('message', e)}") self._fatal(f" - Failed to get movie {self.title}: {info.get('message', e)}")
title_data = resp.json()["details"]["vod_items"]["focus"]["entity"] title_data = resp.json()["details"]["vod_items"]["focus"]["entity"]
return Movies([Movie( return Movies([Movie(
@@ -85,6 +111,7 @@ class HULU(Service):
data=title_data, data=title_data,
)]) )])
def _get_series(self):
resp = self.session.get(self.config["endpoints"]["series"].format(id=self.title)) resp = self.session.get(self.config["endpoints"]["series"].format(id=self.title))
try: try:
resp.raise_for_status() resp.raise_for_status()
@@ -93,14 +120,13 @@ class HULU(Service):
message = info.get("message", "") message = info.get("message", "")
if e.response.status_code == 400 and "entity type" in message.lower(): if e.response.status_code == 400 and "entity type" in message.lower():
self.log.info(" - Detected movie UUID, retrying on movie endpoint.") self.log.info(" - Detected movie UUID, retrying on movie endpoint.")
self.movie = True return self.get_titles(_force_movie=True)
return self.get_titles() self._fatal(f" - Failed to get series {self.title}: {message} [{info.get('code')}]")
raise self._fatal(f" - Failed to get series {self.title}: {message} [{info.get('code')}]")
res = resp.json() res = resp.json()
season_data = next((x for x in res.get("components", []) if x.get("name") == "Episodes"), None) season_data = next((x for x in res.get("components", []) if x.get("name") == "Episodes"), None)
if not season_data: if not season_data:
raise self._fatal(" - Failed to get episodes.") self._fatal(" - Failed to get episodes.")
series = Series() series = Series()
for season in season_data.get("items", []): for season in season_data.get("items", []):
@@ -112,7 +138,7 @@ class HULU(Service):
season_resp.raise_for_status() season_resp.raise_for_status()
except requests.HTTPError as e: except requests.HTTPError as e:
info = self._safe_json(e.response) info = self._safe_json(e.response)
raise self._fatal(f" - Failed to get season {season_id_part}: {info.get('message', e)}") self._fatal(f" - Failed to get season {season_id_part}: {info.get('message', e)}")
for episode in season_resp.json().get("items", []): for episode in season_resp.json().get("items", []):
try: try:
@@ -136,18 +162,28 @@ class HULU(Service):
return series return series
def get_tracks(self, title): def get_tracks(self, title):
codec = "H265" if self.vcodec == Video.Codec.HEVC else "H264" if self.vcodec == Video.Codec.HEVC:
codec = "H265"
elif self.vcodec == Video.Codec.AVC:
codec = "H264"
else:
self.log.warning(f" - Unrecognised vcodec '{self.vcodec}', defaulting to H264.")
codec = "H264"
eab_id = (title.data.get("bundle") or {}).get("eab_id") eab_id = (title.data.get("bundle") or {}).get("eab_id")
if not eab_id: if not eab_id:
raise self._fatal(f" - Could not find eab_id in {title}") self._fatal(f" - Could not find eab_id in title data for '{title}'.")
device_cfg = self.config["device_ids"]
deejay_id = device_cfg["new"] if self.mpd_type == "new" else device_cfg["old"]
version = 1 if self.mpd_type == "new" else 9999999
try: try:
resp = self.session.post( resp = self.session.post(
url=self.config["endpoints"]["manifest"], url=self.config["endpoints"]["manifest"],
json={ json={
"deejay_device_id": 210 if self.mpd_type == "new" else 166, "deejay_device_id": deejay_id,
"version": 1 if self.mpd_type == "new" else 9999999, "version": version,
"all_cdn": False, "all_cdn": False,
"content_eab_id": eab_id, "content_eab_id": eab_id,
"region": "US", "region": "US",
@@ -162,17 +198,21 @@ class HULU(Service):
"codecs": { "codecs": {
"values": [x for x in self.config["codecs"]["video"] if x["type"] == codec], "values": [x for x in self.config["codecs"]["video"] if x["type"] == codec],
"selection_mode": self.config["codecs"]["video_selection"], "selection_mode": self.config["codecs"]["video_selection"],
} },
}, },
"audio": { "audio": {
"codecs": { "codecs": {
"values": self.config["codecs"]["audio"], "values": self.config["codecs"]["audio"],
"selection_mode": self.config["codecs"]["audio_selection"], "selection_mode": self.config["codecs"]["audio_selection"],
} },
}, },
"drm": { "drm": {
"multi_key": True, "multi_key": True,
"values": self.config["drm"]["schemas_pr"] if isinstance(self.cdm, PlayReadyCdm) else self.config["drm"]["schemas_wv"], "values": (
self.config["drm"]["schemas_pr"]
if isinstance(self.cdm, PlayReadyCdm)
else self.config["drm"]["schemas_wv"]
),
"selection_mode": self.config["drm"]["selection_mode"], "selection_mode": self.config["drm"]["selection_mode"],
"hdcp": self.config["drm"]["hdcp"], "hdcp": self.config["drm"]["hdcp"],
}, },
@@ -193,20 +233,20 @@ class HULU(Service):
"https": True, "https": True,
}], }],
"selection_mode": "ONE", "selection_mode": "ONE",
} },
} },
} },
) )
resp.raise_for_status() resp.raise_for_status()
playlist = resp.json() playlist = resp.json()
except requests.HTTPError as e: except requests.HTTPError as e:
info = self._safe_json(e.response) info = self._safe_json(e.response)
raise self._fatal(f" - Failed to fetch manifest: {info.get('message', e)} ({info.get('code')})") self._fatal(f" - Failed to fetch manifest: {info.get('message', e)} ({info.get('code')})")
except ValueError as e: except ValueError as e:
raise self._fatal(f" - Failed to decode manifest JSON: {e}") self._fatal(f" - Failed to decode manifest JSON: {e}")
if "stream_url" not in playlist: if "stream_url" not in playlist:
raise self._fatal(f" - Manifest response missing 'stream_url'. Keys: {list(playlist.keys())}") self._fatal(f" - Manifest response missing 'stream_url'. Keys: {list(playlist.keys())}")
self.license_url_widevine = playlist.get("wv_server") self.license_url_widevine = playlist.get("wv_server")
self.license_url_playready = playlist.get("dash_pr_server") self.license_url_playready = playlist.get("dash_pr_server")
@@ -214,7 +254,10 @@ class HULU(Service):
manifest = playlist["stream_url"] manifest = playlist["stream_url"]
self.log.info(f"DASH: {manifest}") self.log.info(f"DASH: {manifest}")
tracks = DASH.from_url(manifest, self.session).to_tracks(title.language) mpd_resp = self.session.get(manifest)
mpd_resp.raise_for_status()
mpd_text = self._strip_duplicate_representations(mpd_resp.text)
tracks = DASH.from_text(mpd_text, manifest).to_tracks(title.language)
if self.acodec: if self.acodec:
mapped = self.AUDIO_CODEC_MAP.get(self.acodec) mapped = self.AUDIO_CODEC_MAP.get(self.acodec)
@@ -222,8 +265,8 @@ class HULU(Service):
tracks.audio = [x for x in tracks.audio if (x.codec or "").startswith(mapped)] tracks.audio = [x for x in tracks.audio if (x.codec or "").startswith(mapped)]
for track in tracks.audio: for track in tracks.audio:
if track.bitrate > 768000: if track.bitrate > 768_000:
track.bitrate = 768000 track.bitrate = 768_000
if track.channels == 6.0: if track.channels == 6.0:
track.channels = 5.1 track.channels = 5.1
@@ -239,16 +282,26 @@ class HULU(Service):
return tracks return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]: def get_chapters(self, title: Movie | Episode) -> list[Chapter]:
return [] return []
def get_widevine_license(self, challenge, track, **_): def get_widevine_license(self, challenge, track, **_: Any):
try:
resp = self.session.post( resp = self.session.post(
url=self.license_url_widevine, url=self.license_url_widevine,
data=challenge, data=challenge,
headers={"Content-Type": "application/octet-stream"}, 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]}") resp.raise_for_status()
except requests.HTTPError as e:
self._fatal(f" - Widevine license request failed: {e}")
self.log.debug(
f"License HTTP {resp.status_code}, "
f"Content-Type: {resp.headers.get('Content-Type')}, "
f"Body[:80]: {resp.content[:80]}"
)
ct = resp.headers.get("Content-Type", "") ct = resp.headers.get("Content-Type", "")
if "json" in ct or resp.content[:1] == b"{": if "json" in ct or resp.content[:1] == b"{":
try: try:
@@ -258,14 +311,20 @@ class HULU(Service):
return base64.b64decode(data[key]) return base64.b64decode(data[key])
except Exception: except Exception:
pass pass
return resp.content return resp.content
def get_playready_license(self, challenge, track, **_): def get_playready_license(self, challenge, track, **_: Any):
try:
resp = self.session.post(url=self.license_url_playready, data=challenge) resp = self.session.post(url=self.license_url_playready, data=challenge)
resp.raise_for_status()
except requests.HTTPError as e:
self._fatal(f" - PlayReady license request failed: {e}")
self.log.debug(resp.content) self.log.debug(resp.content)
return resp.content return resp.content
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: def authenticate(self, cookies: CookieJar | None = None, credential: Credential | None = None) -> None:
if cookies: if cookies:
self.session.cookies.update(cookies) self.session.cookies.update(cookies)
self.session.headers.update({"User-Agent": self.config["user_agent"]}) self.session.headers.update({"User-Agent": self.config["user_agent"]})