VT-PR/vinetrimmer/services/moviesanywhere.py
2025-05-12 14:41:21 +05:30

352 lines
10 KiB
Python

import base64
import json
import click
import re
import requests
from requests import JSONDecodeError
from httpx import URL
import uuid
import xmltodict
import struct
import binascii
import os
import yt_dlp
from pathlib import Path
import uuid
import xml.etree.ElementTree as ET
import time
from datetime import datetime
from langcodes import Language
from vinetrimmer.objects import AudioTrack, TextTrack, Title, Tracks, VideoTrack
from vinetrimmer.services.BaseService import BaseService
from vinetrimmer.vendor.pymp4.parser import Box
class MoviesAnywhere(BaseService):
"""
Service code for US' streaming service MoviesAnywhere (https://moviesanywhere.com).
\b
Authorization: Cookies
Security: SD-HD@L3, FHD SDR@L1 (any active device), FHD-UHD HDR-DV@L1 (whitelisted devices).
NOTE: Can be accessed from any region, it does not seem to care.
Accounts can only mount services when its US based though.
"""
ALIASES = ["MA", "MoviesAnywhere"]
TITLE_RE = r"https://moviesanywhere\.com(?P<id>.+)"
VIDEO_CODEC_MAP = {
"H264": ["avc"],
"H265": ["hvc", "hev", "dvh"]
}
AUDIO_CODEC_MAP = {
"AAC": ["mp4a", "HE", "stereo"],
"AC3": ["ac3"],
"EC3": ["ec3", "atmos"]
}
@staticmethod
@click.command(name="MoviesAnywhere", short_help="https://moviesanywhere.com")
@click.argument("title", type=str)
@click.pass_context
def cli(ctx, **kwargs):
return MoviesAnywhere(ctx, **kwargs)
def __init__(self, ctx, title):
super().__init__(ctx)
self.parse_title(ctx, title)
self.configure()
self.playready = True if "certificate_chain" in dir(ctx.obj.cdm) else False #ctx.obj.cdm.device.type == LocalDevice.Types.PLAYREADY
self.atmos = ctx.parent.params["atmos"]
self.vcodec = ctx.parent.params["vcodec"]
self.acodec = ctx.parent.params["acodec"]
self.range = ctx.parent.params["range_"]
self.quality = ctx.parent.params["quality"] or 1080
if self.range != "SDR" or self.quality > 1080:
self.log.info(" + Setting VideoCodec to H265")
self.vcodec = "H265"
def get_titles(self):
self.headers={
"authorization": f"Bearer {self.access_token}",
"install-id": self.install_id,
}
res = self.session.post(
url="https://gateway.moviesanywhere.com/graphql",
json={
"platform": "web",
"variables": {"slug": self.title}, # Does not seem to care which platform will be used to give the best tracks available
"extensions": '{"persistedQuery":{"sha256Hash":"5cb001491262214406acf8237ea2b8b46ca6dbcf37e70e791761402f4f74336e","version":1}}', # ONE_GRAPH_PERSIST_QUERY_TOKEN
},
headers={
"authorization": f"Bearer {self.access_token}",
"install-id": self.install_id,
}
)
try:
self.content = res.json()
except JSONDecodeError:
self.log.exit(" - Not able to return title information")
title_data = self.content["data"]["page"]
title_info = [
x
for x in title_data["components"]
if x["__typename"] == "MovieMarqueeComponent"
][0]
title_info["title"] = re.sub(r" \(.+?\)", "", title_info["title"])
title_data = self.content["data"]["page"]
try:
Id = title_data["components"][0]["mainAction"]["playerData"]["playable"]["id"]
except KeyError:
self.log.exit(" - Account does not seem to own this title")
return Title(
id_=Id,
type_=Title.Types.MOVIE,
name=title_info["title"],
year=title_info["year"],
original_lang="en",
source=self.ALIASES[0],
service_data=title_data,
)
def get_pssh_init(self, url):
init = 'init.mp4'
files_to_delete = [init]
for file_name in files_to_delete:
if os.path.exists(file_name):
os.remove(file_name)
ydl_opts = {
'format': 'bestvideo[ext=mp4]/bestaudio[ext=m4a]/best',
'allow_unplayable_formats': True,
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3',
'no_warnings': True,
'quiet': True,
'outtmpl': init,
'no_merge': True,
'test': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info_dict = ydl.extract_info(url, download=True)
url = info_dict.get("url", None)
if url is None:
raise ValueError("Failed to download the video")
video_file_name = ydl.prepare_filename(info_dict)
raw = Path(init).read_bytes()
wv = raw.rfind(bytes.fromhex('edef8ba979d64acea3c827dcd51d21ed'))
if wv != -1:
psshWV = base64.b64encode(raw[wv-12:wv-12+raw[wv-9]]).decode('utf-8')
playready_system_id = binascii.unhexlify("9A04F07998404286AB92E65BE0885F95")
pssh_boxes = []
mp4_file = "init.mp4"
with open(mp4_file, "rb") as f:
data = f.read()
index = 0
while index < len(data):
if index + 8 > len(data):
break
box_size, box_type = struct.unpack_from(">I4s", data, index)
if box_size < 8 or index + box_size > len(data):
break
if box_type == b'moov' or box_type == b'moof':
sub_index = index + 8
while sub_index < index + box_size:
sub_size, sub_type = struct.unpack_from(">I4s", data, sub_index)
if sub_type == b'pssh':
system_id = data[sub_index + 12: sub_index + 28]
if system_id == playready_system_id:
pssh_data_size = struct.unpack_from(">I", data, sub_index + 28)[0]
pssh_data = data[sub_index + 32: sub_index + 32 + pssh_data_size]
pssh_boxes.append(pssh_data)
sub_index += sub_size
if box_type == b'pssh':
system_id = data[index + 12: index + 28]
if system_id == playready_system_id:
pssh_data_size = struct.unpack_from(">I", data, index + 28)[0]
pssh_data = data[index + 32: index + 32 + pssh_data_size]
pssh_boxes.append(pssh_data)
index += box_size
if pssh_boxes:
for i, pssh_data in enumerate(pssh_boxes):
pssh_box = (
struct.pack(">I", len(pssh_data) + 32) +
b"pssh" +
struct.pack(">I", 0) +
playready_system_id +
struct.pack(">I", len(pssh_data)) +
pssh_data
)
base64_pssh = base64.b64encode(pssh_box).decode()
#print(base64_pssh)
psshPR = base64_pssh
header_offset = 6
xml_data = pssh_data[header_offset:].decode("utf-16le", errors='ignore')
xml_start = xml_data.find("<WRMHEADER")
xml_end = xml_data.find("</WRMHEADER>")
if xml_start != -1 and xml_end != -1:
xml_content = xml_data[xml_start:xml_end + len("</WRMHEADER>")]
xml_root = ET.fromstring(xml_content)
#print(ET.tostring(xml_root, encoding="utf-8").decode())
else:
raise Exception("Failed to locate XML content in PSSH.")
else:
raise Exception("No PlayReady PSSH boxes found.")
for file_name in files_to_delete:
if os.path.exists(file_name):
os.remove(file_name)
return psshWV, psshPR
def get_tracks(self, title):
player_data = self.content["data"]["page"]["components"][0]["mainAction"]["playerData"]["playable"]
videos = []
audios = []
for cr in player_data["videoAssets"]["dash"].values():
if not cr:
continue
for manifest in cr:
tracks = Tracks.from_mpd(
url=manifest["url"],
source=self.ALIASES[0],
session=self.session,
)
for video in tracks.videos:
psshWV, psshPR = self.get_pssh_init(manifest["url"])
video.psshWV = psshWV
video.psshPR = psshPR
video.license_url = manifest["playreadyLaUrl"] if self.playready else manifest["widevineLaUrl"]
video.contentId = URL(video.license_url).params._dict["ContentId"][
0
]
videos += [video]
# Extract Atmos audio track if available.
for audio in tracks.audios:
audio.psshWV = psshWV
audio.psshPR = psshPR
audio.license_url = manifest["playreadyLaUrl"] if self.playready else manifest["widevineLaUrl"]
audio.contentId = URL(audio.license_url).params._dict["ContentId"][
0
]
if "atmos" in audio.url:
audio.atmos = True
audios += [audio]
corrected_video_list = []
for res in ("uhd", "hdp", "hd", "sd"):
for video in videos:
if f"_{res}_video" not in video.url or not video.url.endswith(
f"&r={res}"
):
continue
if corrected_video_list and any(
video.id == vid.id for vid in corrected_video_list
):
continue
if "dash_hevc_hdr" in video.url:
video.hdr10 = True
if "dash_hevc_dolbyvision" in video.url:
video.dv = True
corrected_video_list += [video]
tracks.add(corrected_video_list)
tracks.audios = audios
tracks.videos = [x for x in tracks.videos if (x.codec or "")[:3] in self.VIDEO_CODEC_MAP[self.vcodec]]
return tracks
def get_chapters(self, title):
return []
def certificate(self, **_):
return None # will use common privacy cert
def license(self, challenge: bytes, track: Tracks, **_) -> bytes:
if not isinstance(challenge, bytes):
challenge = bytes(challenge, 'utf-8')
playback_session_id = str(uuid.uuid4())
license_message = requests.post(
headers = {
'accept': '*/*',
'accept-language': 'en-US,en;q=0.9,en-IN;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/octet-stream',
'dnt': '1',
'origin': 'https://moviesanywhere.com',
'pragma': 'no-cache',
'priority': 'u=1, i',
'referer': 'https://moviesanywhere.com/',
'sec-ch-ua-mobile': '?0',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'soapaction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"',
'user_agent': 'Dalvik/2.1.0 (Linux; U; Android 14; SM-S911B Build/UP1A.231005.007)',
},
params = {
"authorization": self.access_token,
"playbackSessionId": playback_session_id
},
url=track.license_url,
data=challenge, # expects bytes
)
self.log.debug(license_message.text)
if "errorCode" in license_message.text:
self.log.exit(f" - Cannot complete license request: {license_message.text}")
return license_message.content
def configure(self):
access_token = None
install_id = None
for cookie in self.cookies:
if cookie.name == "secure_access_token":
access_token = cookie.value
elif cookie.name == "install_id":
install_id = cookie.value
self.access_token = access_token
self.install_id = install_id
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Origin": "https://moviesanywhere.com",
"Authorization": f"Bearer {self.access_token}",
}
)