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

497 lines
19 KiB
Python

import json
import os.path
import re
import sys
import time
import uuid
from datetime import datetime, timedelta
from hashlib import md5
from collections.abc import Generator
from http.cookiejar import CookieJar
from typing import Any, Optional, Union
from urllib.parse import urljoin
import click
import requests
import xmltodict
from langcodes import Language
from devine.core.titles import Episode, Movie, Movies, Series
from devine.core.tracks.video import Video
from devine.core.credential import Credential
from devine.core.manifests import DASH, HLS
from devine.core.service import Service
from devine.core.tracks import Chapters, Tracks, Subtitle, Chapter
class MAX(Service):
"""
Service code for MAX's streaming service (https://max.com).
\b
Authorization: Cookies
Security: UHD@L1 FHD@L1 HD@L3
"""
ALIASES = ["MAX", "max"]
TITLE_RE = r"^(?:https?://(?:www\.|play\.)?max\.com/)?(?P<type>[^/]+)/(?P<id>[^/]+)"
VIDEO_CODEC_MAP = {
"H.264": ["avc1", "AVC", "Codec.AVC"],
"H.265": ["hvc1", "dvh1", "HEVC", "Codec.HEVC"]
}
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, required=False)
@click.pass_context
def cli(ctx, **kwargs):
return MAX(ctx, **kwargs)
def __init__(self, ctx, title):
super().__init__(ctx)
self.title = title
self.vcodec = ctx.parent.params.get("vcodec")
self.acodec = ctx.parent.params.get("acodec")
self.range = ctx.parent.params.get("range_")
self.alang = ctx.parent.params.get("lang")
if self.range == 'HDR10':
self.vcodec = "H.265"
def get_titles(self) -> Union[Movies, Series]:
try:
content_type, external_id = (re.match(self.TITLE_RE, self.title).group(i) for i in ("type", "id"))
except Exception:
raise ValueError("Could not parse ID from title - is the URL correct?")
response = self.session.get(
f"https://default.prd.api.max.com/cms/routes/{content_type}/{external_id}?include=default",
)
data = response.json()
title_id = data['data']['relationships']['target']['data']['id']
title_info = next(x['attributes'] for x in data['included'] if x['id'] == title_id)
content_title = title_info.get('title') or title_info['name'].split("-")[0]
if content_type == "movie" or content_type == "standalone":
metadata = self.session.get(
url=f"https://default.prd.api.max.com/content/videos/{external_id}/activeVideoForShow?&include=edit"
).json()['data']
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_=title_id,
service=self.__class__,
name=content_title.title(),
year=year,
data=metadata,
language="en"
)])
if content_type == "show" or content_type == "mini-series":
episodes = []
if content_type == "mini-series":
alias = "generic-miniseries-page-rail-episodes"
else:
alias = "generic-%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 d == alias][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["id"]) == int(season_number)]
if not season_parameters:
raise self.log.exit("season(s) %s not found")
data_paginas = self.session.get(url="https://default.prd.api.max.com/cms/collections/generic-show-page-rail-episodes-tabbed-content?include=default&pf[show.id]=%s" % (external_id)).json()
total_pages = data_paginas['data']['meta']['itemsTotalPages']
for pagina in range(1, total_pages + 1):
for (value, parameter) in season_parameters:
data = self.session.get(url="https://default.prd.api.max.com/cms/collections/generic-show-page-rail-episodes-tabbed-content?include=default&pf[show.id]=%s&%s&page[items.number]=%s" % (external_id, parameter, pagina)).json()
total_pages = data['data']['meta']['itemsTotalPages']
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(value)], key=lambda x: x["attributes"]["episodeNumber"])
except KeyError:
raise self.log.exit("season episodes were not found")
episodes.extend(episodes_dt)
titles = Series()
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
for episode in episodes:
titles.add(Episode(
id_=episode['id'],
service=self.__class__,
name=episode['attributes']['name'],
year=year,
season=episode['attributes']['seasonNumber'],
number=episode['attributes']['episodeNumber'],
title=content_title.title(),
data=episode,
language="en"
))
return titles
def get_tracks(self, title):
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': [],
}
)
playback_data = response.json()
# TEST
video_info = next(x for x in playback_data['videos'] if x['type'] == 'main')
title.is_original_lang = Language.get(video_info['defaultAudioSelection']['language'])
fallback_url = playback_data["fallback"]["manifest"]["url"]
try:
self.license_url = playback_data["drm"]["schemes"]["widevine"]["licenseUrl"]
drm_protection_enabled = True
except (KeyError, IndexError):
drm_protection_enabled = False
manifest_url = fallback_url.replace('_fallback', '')
tracks = DASH.from_url(url= manifest_url, session=self.session).to_tracks(language=title.language)
#tracks.subtitles.clear()
subtitles = self.get_subtitles(manifest_url, fallback_url)
subs = []
for subtitle in subtitles:
subs.append(
Subtitle(
id_=md5(subtitle["url"].encode()).hexdigest(),
url=subtitle["url"],
codec=Subtitle.Codec.from_codecs("vtt"),
language=subtitle["language"],
forced=subtitle['name'] == 'Forced',
sdh=subtitle['name'] == 'SDH'
)
)
tracks.add(subs)
if self.vcodec:
tracks.videos = [x for x in tracks.videos if (x.codec or "")[:5] == self.vcodec]
if self.acodec:
tracks.audios = [x for x in tracks.audio if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]]
for track in tracks:
# track.needs_proxy = True
#if isinstance(track, Video):
#print(dir(track))
#track.Codec = track.extra[0].get("codecs")
# track.hdr10 = codec[0:4] in ("hvc1", "hev1") and codec[5] == "2"
# track.dv = codec[0:4] in ("dvh1", "dvhe")
if isinstance(track, Subtitle) and track.codec == "":
track.codec = "webvtt"
title.data['info'] = video_info
return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]:
return []
#chapters = []
#video_info = title.data['info']
#if 'annotations' in video_info:
# chapters.append(Chapter(number=1, title='Chapter 1', timecode='00:00:00.0000'))
# chapters.append(Chapter(number=2, title='Credits', timecode=self.convert_timecode(video_info['annotations'][0]['start'])))
# chapters.append(Chapter(number=3, title='Chapter 2', timecode=self.convert_timecode(video_info['annotations'][0]['end'])))
return chapters
def get_widevine_service_certificate(self, **_: Any) -> str:
return None
def get_widevine_license(self, challenge, **_):
return self.session.post(
url=self.license_url,
data=challenge # expects bytes
).content
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
token = self.session.cookies.get("st")
self.session.cookies.update(cookies)
device_id = json.loads(self.session.cookies.get_dict()["session"])
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/113.0',
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'x-disco-client': 'WEB:NT 10.0:beam:0.0.0',
'x-disco-params': 'realm=bolt,bid=beam,features=ar',
'x-device-info': 'beam/0.0.0 (desktop/desktop; Windows/NT 10.0; b3950c49-ed17-49d0-beb2-11b1d61e5672/da0cdd94-5a39-42ef-aa68-54cbc1b852c3)',
'traceparent': '00-053c91686df1e7ee0b0b0f7fda45ee6a-f5a98d6877ba2515-01',
'tracestate': f'wbd=session:{device_id}',
'Origin': 'https://play.max.com',
'Referer': 'https://play.max.com/',
})
auth_token = self.get_device_token()
self.session.headers.update({
"x-wbd-session-state": auth_token
})
def get_device_token(self):
response = self.session.post(
'https://default.prd.api.max.com/session-context/headwaiter/v1/bootstrap',
)
response.raise_for_status()
return response.headers.get('x-wbd-session-state')
@staticmethod
def convert_timecode(time):
secs, ms = divmod(time, 1)
mins, secs = divmod(secs, 60)
hours, mins = divmod(mins, 60)
ms = ms * 10000
chapter_time = '%02d:%02d:%02d.%04d' % (hours, mins, secs, ms)
return chapter_time
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['@contentType'] == 'text':
for x in self.force_instance(subs_tracks, "Representation"):
try:
path = re.search(r'(t/\w+/)', x["SegmentTemplate"]["@media"])[1]
except AttributeError:
path = 't/sub/'
is_sdh = False
text = ""
if subs_tracks["Role"]["@value"] == "caption":
#url = base_url + path + subs_tracks['@lang'] + '_cc.vtt'
url = base_url + path + subs_tracks['@lang'] + ('_sdh.vtt' if 'sdh' in subs_tracks["Label"].lower() else '_cc.vtt')
is_sdh = True
text = " (SDH)"
is_forced = False
text = ""
if subs_tracks["Role"]["@value"] == "forced-subtitle":
url = base_url + path + subs_tracks['@lang'] + '_forced.vtt'
text = " (Forced)"
is_forced = True
if subs_tracks["Role"]["@value"] == "subtitle":
url = base_url + path + subs_tracks['@lang'] + '_sub.vtt'
subs_tracks_js.append({
"url": url,
"format": "vtt",
"language": subs_tracks["@lang"],
"languageDescription": Language.make(language=subs_tracks["@lang"].split('-')[0]).display_name() + text,
"name": "SDH" if is_sdh else "Forced" if is_forced else "Full",
})
subs_tracks_js = self.remove_dupe(subs_tracks_js)
return subs_tracks_js
@staticmethod
def force_instance(data, variable):
if isinstance(data[variable], list):
X = data[variable]
else:
X = [data[variable]]
return X
@staticmethod
def remove_dupe(items):
valores_chave = set()
new_items = []
for item in items:
valor = item['url']
if valor not in valores_chave:
new_items.append(item)
valores_chave.add(valor)
return new_items