497 lines
19 KiB
Python
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
|