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[^/]+)/(?P[^/]+)" 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