Unshackle-Services/MAX/__init__.py
2026-01-03 17:53:34 +02:00

651 lines
26 KiB
Python

import hashlib
import json
import re
import uuid
from datetime import datetime
from hashlib import md5
from typing import Optional, Union, Generator
from http.cookiejar import CookieJar
import click
import requests
import xmltodict
from langcodes import Language
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
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, Chapters, Subtitle, Tracks, Video
class MAX(Service):
"""
Service code for MAX's streaming service (https://max.com).
Version: 1.0.0
Authorization: Cookies
Security: UHD@L1 FHD@L1 HD@L3
Use full URL or title ID with type.
Examples:
- https://play.hbomax.com/movie/urn:hbo:movie:GUID
- https://play.hbomax.com/show/urn:hbo:series:GUID
- movie/GUID
- show/GUID
Note: This service is designed for users who have legal access to MAX content.
Ensure you have proper subscription and authentication before use.
"""
ALIASES = ("MAX", "max", "hbomax")
GEOFENCE = ("US",)
TITLE_RE = r"^(?:https?://(?:www\.|play\.)?hbomax\.com/)?(?P<type>[^/]+)/(?P<id>[^/]+)"
VIDEO_CODEC_MAP = {
"H264": ["avc1"],
"H265": ["hvc1", "dvh1"]
}
AUDIO_CODEC_MAP = {
"AAC": "mp4a",
"AC3": "ac-3",
"EC3": "ec-3"
}
@staticmethod
@click.command(name="MAX", short_help="https://max.com")
@click.argument("title", type=str)
@click.option("-vcodec", "--video-codec", default=None, help="Video codec preference")
@click.option("-acodec", "--audio-codec", default=None, help="Audio codec preference")
@click.pass_context
def cli(ctx, **kwargs):
return MAX(ctx, **kwargs)
def __init__(self, ctx, title, video_codec, audio_codec):
super().__init__(ctx)
self.title = title
self.vcodec = video_codec
self.acodec = audio_codec
# Get range parameter for HDR support
range_param = ctx.parent.params.get("range_")
self.range = range_param[0].name if range_param else "SDR"
if self.range == 'HDR10':
self.vcodec = "H265"
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not cookies:
raise EnvironmentError("Service requires Cookies for Authentication.")
# Extract authentication tokens from cookies
try:
token = next(cookie.value for cookie in cookies if cookie.name == "st")
session_data = next(cookie.value for cookie in cookies if cookie.name == "session")
device_id = json.loads(session_data)
except (StopIteration, json.JSONDecodeError):
raise EnvironmentError("Required authentication cookies not found.")
# Configure headers based on device type
self.session.headers.update({
'User-Agent': 'BEAM-Android/1.0.0.104 (SONY/XR-75X95EL)',
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'x-disco-client': 'SAMSUNGTV:124.0.0.0:beam:4.0.0.118',
'x-disco-params': 'realm=bolt,bid=beam,features=ar',
'x-device-info': 'beam/4.0.0.118 (Samsung/Samsung-Unknown; Tizen/124.0.0.0; f198a6c1-c582-4725-9935-64eb6b17c3cd/87a996fa-4917-41ae-9b6d-c7f521f0cb78)',
'traceparent': '00-315ac07a3de9ad1493956cf1dd5d1313-988e057938681391-01',
'tracestate': f'wbd=session:{device_id}',
'Origin': 'https://play.hbomax.com',
'Referer': 'https://play.hbomax.com/',
})
# Get device token
auth_token = self._get_device_token()
self.session.headers.update({
"x-wbd-session-state": auth_token
})
def search(self) -> Generator[SearchResult, None, None]:
"""
Search for content on MAX platform.
Note: This is a basic implementation - MAX's search API may require additional parameters.
"""
# Basic search implementation - you may need to adjust based on actual API
search_url = "https://default.prd.api.hbomax.com/search"
try:
response = self.session.get(search_url, params={"q": self.title})
response.raise_for_status()
search_data = response.json()
# Parse search results - adjust based on actual API response structure
for result in search_data.get("results", []):
yield SearchResult(
id_=result.get("id"),
title=result.get("title", "Unknown"),
label=result.get("type", "UNKNOWN").upper(),
url=f"https://play.hbomax.com/{result.get('type', 'content')}/{result.get('id')}"
)
except Exception as e:
self.log.warning(f"Search functionality not fully implemented: {e}")
# Return empty generator if search fails
return
yield # This makes it a generator function
def get_titles(self) -> Titles_T:
# Parse title input
match = re.match(self.TITLE_RE, self.title)
if not match:
raise ValueError("Invalid title format. Expected format: type/id or full URL")
content_type = match.group('type')
external_id = match.group('id')
response = self.session.get(
self.config['endpoints']['contentRoutes'] % (content_type, external_id)
)
response.raise_for_status()
try:
content_data = [x for x in response.json()["included"] if "attributes" in x and "title" in
x["attributes"] and x["attributes"]["alias"] == "generic-%s-blueprint-page" % (re.sub(r"-", "", content_type))][0]["attributes"]
content_title = content_data["title"]
except:
content_data = [x for x in response.json()["included"] if "attributes" in x and "alternateId" in
x["attributes"] and x["attributes"]["alternateId"] == external_id and x["attributes"].get("originalName")][0]["attributes"]
content_title = content_data["originalName"]
if content_type == "sport" or content_type == "event":
included_dt = response.json()["included"]
for included in included_dt:
for key, data in included.items():
if key == "attributes":
for k, d in data.items():
if d == "VOD":
event_data = included
release_date = event_data["attributes"].get("airDate") or event_data["attributes"].get("firstAvailableDate")
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
return Movies([
Movie(
id_=external_id,
service=self.__class__,
name=content_title.title(),
year=year,
data=event_data,
)
])
if content_type == "movie" or content_type == "standalone":
metadata = self.session.get(
url=self.config['endpoints']['moviePages'] % external_id
).json()['data']
try:
edit_id = metadata['relationships']['edit']['data']['id']
except:
for x in response.json()["included"]:
if x.get("type") == "video" and x.get("relationships", {}).get("show", {}).get("data", {}).get("id") == external_id:
metadata = x
release_date = metadata["attributes"].get("airDate") or metadata["attributes"].get("firstAvailableDate")
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
return Movies([
Movie(
id_=external_id,
service=self.__class__,
name=content_title,
year=year,
data=metadata,
)
])
if content_type in ["show", "mini-series", "topical"]:
episodes = []
if content_type == "mini-series":
alias = "generic-miniseries-page-rail-episodes"
elif content_type == "topical":
alias = "generic-topical-show-page-rail-episodes"
else:
alias = "-%s-page-rail-episodes-tabbed-content" % (content_type)
included_dt = response.json()["included"]
season_data = [data for included in included_dt for key, data in included.items()
if key == "attributes" for k, d in data.items() if alias in str(d).lower()][0]
season_data = season_data["component"]["filters"][0]
seasons = [int(season["value"]) for season in season_data["options"]]
season_parameters = [(int(season["value"]), season["parameter"]) for season in season_data["options"]
for season_number in seasons if int(season["value"]) == int(season_number)]
if not season_parameters:
raise ValueError("No seasons found")
for (value, parameter) in season_parameters:
data = self.session.get(
url=self.config['endpoints']['showPages'] % (external_id, parameter)
).json()
try:
episodes_dt = sorted([dt for dt in data["included"] if "attributes" in dt and "videoType" in
dt["attributes"] and dt["attributes"]["videoType"] == "EPISODE"
and int(dt["attributes"]["seasonNumber"]) == int(parameter.split("=")[-1])],
key=lambda x: x["attributes"]["episodeNumber"])
except KeyError:
raise ValueError("Season episodes were not found")
episodes.extend(episodes_dt)
episode_titles = []
release_date = episodes[0]["attributes"].get("airDate") or episodes[0]["attributes"].get("firstAvailableDate")
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
season_map = {int(item[1].split("=")[-1]): item[0] for item in season_parameters}
for episode in episodes:
episode_titles.append(
Episode(
id_=episode['id'],
service=self.__class__,
title=content_title,
season=season_map.get(episode['attributes'].get('seasonNumber')),
number=episode['attributes']['episodeNumber'],
name=episode['attributes']['name'],
year=year,
data=episode
)
)
return Series(episode_titles)
def get_tracks(self, title: Title_T) -> Tracks:
edit_id = title.data['relationships']['edit']['data']['id']
response = self.session.post(
url=self.config['endpoints']['playbackInfo'],
json={
'appBundle': 'beam',
'consumptionType': 'streaming',
'deviceInfo': {
'deviceId': '2dec6cb0-eb34-45f9-bbc9-a0533597303c',
'browser': {
'name': 'chrome',
'version': '113.0.0.0',
},
'make': 'Microsoft',
'model': 'XBOX-Unknown',
'os': {
'name': 'Windows',
'version': '113.0.0.0',
},
'platform': 'XBOX',
'deviceType': 'xbox',
'player': {
'sdk': {
'name': 'Beam Player Console',
'version': '1.0.2.4',
},
'mediaEngine': {
'name': 'GLUON_BROWSER',
'version': '1.20.1',
},
'playerView': {
'height': 1080,
'width': 1920,
},
},
},
'editId': edit_id,
'capabilities': {
'manifests': {
'formats': {
'dash': {},
},
},
'codecs': {
'video': {
'hdrFormats': [
'hlg',
'hdr10',
'dolbyvision5',
'dolbyvision8',
],
'decoders': [
{
'maxLevel': '6.2',
'codec': 'h265',
'levelConstraints': {
'width': {
'min': 1920,
'max': 3840,
},
'height': {
'min': 1080,
'max': 2160,
},
'framerate': {
'min': 15,
'max': 60,
},
},
'profiles': [
'main',
'main10',
],
},
{
'maxLevel': '4.2',
'codec': 'h264',
'levelConstraints': {
'width': {
'min': 640,
'max': 3840,
},
'height': {
'min': 480,
'max': 2160,
},
'framerate': {
'min': 15,
'max': 60,
},
},
'profiles': [
'high',
'main',
'baseline',
],
},
],
},
'audio': {
'decoders': [
{
'codec': 'aac',
'profiles': [
'lc',
'he',
'hev2',
'xhe',
],
},
],
},
},
'devicePlatform': {
'network': {
'lastKnownStatus': {
'networkTransportType': 'unknown',
},
'capabilities': {
'protocols': {
'http': {
'byteRangeRequests': True,
},
},
},
},
'videoSink': {
'lastKnownStatus': {
'width': 1290,
'height': 2796,
},
'capabilities': {
'colorGamuts': [
'standard',
'wide',
],
'hdrFormats': [
'dolbyvision',
'hdr10plus',
'hdr10',
'hlg',
],
},
},
},
},
'gdpr': False,
'firstPlay': False,
'playbackSessionId': str(uuid.uuid4()),
'applicationSessionId': str(uuid.uuid4()),
'userPreferences': {},
'features': [],
}
)
response.raise_for_status()
playback_data = response.json()
# Get video info for language
video_info = next(x for x in playback_data['videos'] if x['type'] == 'main')
title.language = Language.get(video_info['defaultAudioSelection']['language'])
fallback_url = playback_data["fallback"]["manifest"]["url"]
fallback_url = fallback_url.replace('fly', 'akm').replace('gcp', 'akm')
try:
self.wv_license_url = playback_data["drm"]["schemes"]["widevine"]["licenseUrl"]
except (KeyError, IndexError):
self.wv_license_url = None
try:
self.pr_license_url = playback_data["drm"]["schemes"]["playready"]["licenseUrl"]
except (KeyError, IndexError):
self.pr_license_url = None
manifest_url = fallback_url.replace('_fallback', '')
self.log.debug(f"MPD URL: {manifest_url}")
self.log.debug(f"Fallback URL: {fallback_url}")
self.log.debug(f"Widevine License URL: {self.wv_license_url}")
self.log.debug(f"PlayReady License URL: {self.pr_license_url}")
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
self.log.debug(tracks)
tracks.videos = self._dedupe(tracks.videos)
tracks.audio = self._dedupe(tracks.audio)
# Remove partial subs and get VTT subtitles
tracks.subtitles.clear()
subtitles = self._get_subtitles(manifest_url, fallback_url)
for subtitle in subtitles:
tracks.add(
Subtitle(
id_=md5(subtitle["url"].encode()).hexdigest()[0:6],
url=subtitle["url"],
codec=Subtitle.Codec.from_mime(subtitle['format']),
language=Language.get(subtitle["language"]),
forced=subtitle['name'] == 'Forced',
sdh=subtitle['name'] == 'SDH'
)
)
# Apply codec filters
if self.vcodec:
tracks.videos = [x for x in tracks.videos if (x.codec or "")[:4] in self.VIDEO_CODEC_MAP[self.vcodec]]
if self.acodec:
tracks.audio = [x for x in tracks.audio if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]]
# Set track properties
for track in tracks:
if isinstance(track, Video):
codec = track.data.get("dash", {}).get("representation", {}).get("codecs", "")
track.hdr10 = track.range == Video.Range.HDR10
track.dv = codec[:4] in ("dvh1", "dvhe")
if isinstance(track, Subtitle) and not track.codec:
track.codec = Subtitle.Codec.WebVTT
# Store video info for chapters
title.data['info'] = video_info
# Mark descriptive audio tracks
for track in tracks.audio:
if hasattr(track, 'data') and track.data.get("dash", {}).get("adaptation_set"):
role = track.data["dash"]["adaptation_set"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
track.descriptive = True
self.log.debug(tracks)
return tracks
def get_chapters(self, title: Title_T) -> Chapters:
chapters = []
video_info = title.data.get('info', {})
if 'annotations' in video_info:
chapters.append(Chapter(timestamp=0.0, name='Chapter 1'))
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['start']), name='Credits'))
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['end']), name='Chapter 2'))
return Chapters(chapters)
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
if not self.wv_license_url:
return None
response = self.session.post(
url=self.wv_license_url,
data=challenge
)
response.raise_for_status()
return response.content
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
if not self.pr_license_url:
return None
# Handle both bytes and string challenge formats
if isinstance(challenge, bytes):
decoded_challenge = challenge.decode('utf-8')
else:
decoded_challenge = str(challenge)
response = self.session.post(
url=self.pr_license_url,
data=decoded_challenge,
headers={
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': 'http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense'
}
)
response.raise_for_status()
return response.content
def _get_device_token(self):
response = self.session.post(self.config['endpoints']['bootstrap'])
response.raise_for_status()
return response.headers.get('x-wbd-session-state')
@staticmethod
def _convert_timecode(time_seconds):
"""Convert seconds to timestamp."""
return float(time_seconds)
def _get_subtitles(self, mpd_url, fallback_url):
base_url = "/".join(fallback_url.split("/")[:-1]) + "/"
xml = xmltodict.parse(requests.get(mpd_url).text)
try:
tracks = xml["MPD"]["Period"][0]["AdaptationSet"]
except KeyError:
tracks = xml["MPD"]["Period"]["AdaptationSet"]
subs_tracks_js = []
for subs_tracks in tracks:
if subs_tracks.get('@contentType') == 'text':
for x in self._force_instance(subs_tracks, "Representation"):
try:
path = re.search(r'(t/\w+/)', x["SegmentTemplate"]["@media"])[1]
except (AttributeError, KeyError):
path = 't/sub/'
is_sdh = False
is_forced = False
role_value = subs_tracks.get("Role", {}).get("@value", "")
if role_value == "caption":
url = base_url + path + subs_tracks['@lang'] + ('_sdh.vtt' if 'sdh' in subs_tracks.get("Label", "").lower() else '_cc.vtt')
is_sdh = True
elif role_value == "forced-subtitle":
url = base_url + path + subs_tracks['@lang'] + '_forced.vtt'
is_forced = True
elif role_value == "subtitle":
url = base_url + path + subs_tracks['@lang'] + '_sub.vtt'
else:
continue
subs_tracks_js.append({
"url": url,
"format": "vtt",
"language": subs_tracks["@lang"],
"name": "SDH" if is_sdh else "Forced" if is_forced else "Full",
})
return self._remove_dupe(subs_tracks_js)
@staticmethod
def _force_instance(data, variable):
if isinstance(data[variable], list):
return data[variable]
else:
return [data[variable]]
@staticmethod
def _remove_dupe(items):
seen = set()
new_items = []
for item in items:
url = item['url']
if url not in seen:
new_items.append(item)
seen.add(url)
return new_items
@staticmethod
def _dedupe(items: list) -> list:
if not items:
return items
if isinstance(items[0].url, list):
return items
# Create a more specific key for deduplication that includes resolution/bitrate
seen = {}
for item in items:
# For video tracks, use codec + resolution + bitrate as key
if hasattr(item, 'width') and hasattr(item, 'height'):
key = f"{item.codec}_{item.width}x{item.height}_{item.bitrate}"
# For audio tracks, use codec + language + bitrate + channels as key
elif hasattr(item, 'channels'):
key = f"{item.codec}_{item.language}_{item.bitrate}_{item.channels}"
# Fallback to URL for other track types
else:
key = item.url
# Keep the item if we haven't seen this exact combination
if key not in seen:
seen[key] = item
return list(seen.values())