VT-PR/vinetrimmer/services/appletvplus.py

379 lines
15 KiB
Python
Raw Normal View History

2025-03-18 00:17:27 +05:30
import base64
import json
import re
from datetime import datetime
from urllib.parse import unquote
2025-03-18 00:23:51 +05:30
from typing import Dict, List
2025-03-18 00:17:27 +05:30
import click
import m3u8
import requests
from vinetrimmer.objects import AudioTrack, TextTrack, Title, Tracks, VideoTrack
from vinetrimmer.services.BaseService import BaseService
from vinetrimmer.utils.collections import as_list
from vinetrimmer.vendor.pymp4.parser import Box
class AppleTVPlus(BaseService):
"""
Service code for Apple's TV Plus streaming service (https://tv.apple.com).
\b
WIP: decrypt and removal of bumper/dub cards
\b
Authorization: Cookies
Security: UHD@L1 FHD@L1 HD@L3
"""
ALIASES = ["ATVP", "appletvplus", "appletv+"]
2025-03-18 00:23:51 +05:30
TITLE_RE = r"^(?:https?://tv\.apple\.com(?:/[a-z]{2})?/(?:movie|show|episode)/[a-z0-9-]+/)?(?P<id>umc\.cmc\.[a-z0-9]+)" # noqa: E501
2025-03-18 00:17:27 +05:30
VIDEO_CODEC_MAP = {
"H264": ["avc"],
"H265": ["hvc", "hev", "dvh"]
}
AUDIO_CODEC_MAP = {
"AAC": ["HE", "stereo"],
"AC3": ["ac3"],
"EC3": ["ec3", "atmos"]
}
@staticmethod
@click.command(name="AppleTVPlus", short_help="https://tv.apple.com")
@click.argument("title", type=str, required=False)
@click.pass_context
def cli(ctx, **kwargs):
return AppleTVPlus(ctx, **kwargs)
2025-03-18 00:23:51 +05:30
def __init__(self, ctx, title):
2025-03-18 00:17:27 +05:30
super().__init__(ctx)
self.parse_title(ctx, title)
2025-03-18 00:23:51 +05:30
self.quality = ctx.parent.params["quality"]
2025-03-18 00:17:27 +05:30
self.vcodec = ctx.parent.params["vcodec"]
self.acodec = ctx.parent.params["acodec"]
self.alang = ctx.parent.params["alang"]
2025-03-18 00:23:51 +05:30
self.range = ctx.parent.params["range_"]
2025-03-18 00:17:27 +05:30
self.subs_only = ctx.parent.params["subs_only"]
self.extra_server_parameters = None
2025-03-18 00:23:51 +05:30
if ("HDR" in self.range) or (self.range == "DV") or ((self.quality << 1080) if self.quality else False):
self.log.info(" - Setting Video codec to H265 to get UHD")
self.vcodec = "H265"
2025-03-18 00:17:27 +05:30
self.configure()
def get_titles(self):
r = None
2025-03-18 00:23:51 +05:30
for i in range(2):
2025-03-18 00:17:27 +05:30
try:
r = self.session.get(
2025-03-18 00:23:51 +05:30
url=self.config["endpoints"]["title"].format(type={0: "shows", 1: "movies"}[i], id=self.title),
params=self.config["device"]
2025-03-18 00:17:27 +05:30
)
except requests.HTTPError as e:
if e.response.status_code != 404:
raise
else:
if r.ok:
break
if not r:
raise self.log.exit(f" - Title ID {self.title!r} could not be found.")
try:
title_information = r.json()["data"]["content"]
except json.JSONDecodeError:
raise ValueError(f"Failed to load title manifest: {r.text}")
2025-03-18 00:23:51 +05:30
self.log.debug(title_information)
2025-03-18 00:17:27 +05:30
if title_information["type"] == "Movie":
return Title(
id_=self.title,
type_=Title.Types.MOVIE,
name=title_information["title"],
2025-03-18 00:23:51 +05:30
year=datetime.utcfromtimestamp(title_information["releaseDate"] / 1000).year,
original_lang=title_information["originalSpokenLanguages"][0]["locale"] if "originalSpokenLanguages" in title_information.keys() else "und",
2025-03-18 00:17:27 +05:30
source=self.ALIASES[0],
service_data=title_information
)
else:
r = self.session.get(
url=self.config["endpoints"]["tv_episodes"].format(id=self.title),
2025-03-18 00:23:51 +05:30
params=self.config["device"]
2025-03-18 00:17:27 +05:30
)
try:
episodes = r.json()["data"]["episodes"]
except json.JSONDecodeError:
raise ValueError(f"Failed to load episodes list: {r.text}")
return [Title(
id_=self.title,
type_=Title.Types.TV,
name=episode["showTitle"],
season=episode["seasonNumber"],
episode=episode["episodeNumber"],
episode_name=episode.get("title"),
2025-03-18 00:23:51 +05:30
original_lang=title_information["originalSpokenLanguages"][0]["locale"] if "originalSpokenLanguages" in title_information.keys() else "und",
2025-03-18 00:17:27 +05:30
source=self.ALIASES[0],
service_data=episode
) for episode in episodes]
def get_tracks(self, title):
2025-03-18 00:23:51 +05:30
r = self.session.get(
url=self.config["endpoints"]["manifest"].format(id=title.service_data["id"]),
params=self.config["device"]
2025-03-18 00:17:27 +05:30
)
try:
stream_data = r.json()
except json.JSONDecodeError:
raise ValueError(f"Failed to load stream data: {r.text}")
2025-03-18 00:23:51 +05:30
stream_data = stream_data["data"]["content"]["playables"][0]
2025-03-18 00:17:27 +05:30
if not stream_data["isEntitledToPlay"]:
2025-03-18 00:23:51 +05:30
self.log.debug(stream_data)
2025-03-18 00:17:27 +05:30
raise self.log.exit(" - User is not entitled to play this title")
self.extra_server_parameters = stream_data["assets"]["fpsKeyServerQueryParameters"]
2025-03-18 00:23:51 +05:30
self.log.debug(self.extra_server_parameters)
self.log.debug(stream_data["assets"]["hlsUrl"])
r = requests.get(url=stream_data["assets"]["hlsUrl"], headers={'User-Agent': 'ATVE/1.1 FireOS/6.2.6.8 build/4A93 maker/Amazon model/FireTVStick4K FW/NS6268/2315'})
2025-03-18 00:17:27 +05:30
res = r.text
tracks = Tracks.from_m3u8(
master=m3u8.loads(res, r.url),
source=self.ALIASES[0]
)
for track in tracks:
track.extra = {"manifest": track.extra}
quality = None
for line in res.splitlines():
if line.startswith("#--"):
quality = {"SD": 480, "HD720": 720, "HD": 1080, "UHD": 2160}.get(line.split()[2])
elif not line.startswith("#"):
track = next((x for x in tracks.videos if x.extra["manifest"].uri == line), None)
if track:
track.extra["quality"] = quality
for track in tracks:
track_data = track.extra["manifest"]
#if isinstance(track, VideoTrack) and not tracks.subtitles:
# track.needs_ccextractor_first = True
if isinstance(track, VideoTrack):
2025-03-18 00:23:51 +05:30
track.needs_proxy = False
2025-03-18 00:17:27 +05:30
track.encrypted = True
if isinstance(track, AudioTrack):
2025-03-18 00:23:51 +05:30
track.needs_proxy = False
2025-03-18 00:17:27 +05:30
track.encrypted = True
bitrate = re.search(r"&g=(\d+?)&", track_data.uri)
if not bitrate:
bitrate = re.search(r"_gr(\d+)_", track_data.uri) # new
if bitrate:
track.bitrate = int(bitrate[1][-3::]) * 1000 # e.g. 128->128,000, 2448->448,000
else:
raise ValueError(f"Unable to get a bitrate value for Track {track.id}")
track.codec = track.codec.replace("_vod", "")
if isinstance(track, TextTrack):
2025-03-18 00:23:51 +05:30
track.needs_proxy = True
2025-03-18 00:17:27 +05:30
track.codec = "vtt"
tracks.videos = [x for x in tracks.videos if (x.codec or "")[:3] in self.VIDEO_CODEC_MAP[self.vcodec]]
if self.acodec:
tracks.audios = [
x for x in tracks.audios if (x.codec or "").split("-")[0] in self.AUDIO_CODEC_MAP[self.acodec]
]
tracks.subtitles = [
x for x in tracks.subtitles
if (x.language in self.alang or (x.is_original_lang and "orig" in self.alang) or "all" in self.alang)
or self.subs_only
or not x.sdh
]
try:
return Tracks([
# multiple CDNs, only want one
x for x in tracks
if any(
cdn in as_list(x.url)[0].split("?")[1].split("&") for cdn in ["cdn=ak", "cdn=vod-ak-aoc.tv.apple.com"]
)
])
except:
2025-03-18 00:23:51 +05:30
return Tracks([x for x in tracks])
2025-03-18 00:17:27 +05:30
def get_chapters(self, title):
return []
def certificate(self, **_):
return None # will use common privacy cert
2025-03-18 00:23:51 +05:30
2025-03-18 00:17:27 +05:30
def license(self, challenge, track, **_):
2025-03-18 00:23:51 +05:30
if (isinstance(challenge, bytes) and challenge.startswith(b'<?xml')) or isinstance(challenge, str) and challenge.startswith('<?xml'):
try:
res = self.session.post(
url=self.config["endpoints"]["license"],
json={
'streaming-request': {
'version': 1,
'streaming-keys': [
{
#"extra-server-parameters": self.extra_server_parameters,
"challenge": base64.b64encode(challenge.encode('utf-8')).decode('utf-8'),
"key-system": "com.microsoft.playready",
"uri": f"data:text/plain;charset=UTF-16;base64,{track.pssh}",
"id": 1,
"lease-action": 'start',
"adamId": self.extra_server_parameters['adamId'],
"isExternal": True,
"svcId": self.extra_server_parameters['svcId'],
},
],
},
},
params=self.config["device"]
).json()
except requests.HTTPError as e:
print(e)
if not e.response.text:
raise self.log.exit(" - No license returned!")
raise self.log.exit(f" - Unable to obtain license (error code: {e.response.json()['errorCode']})")
try:
return res['streaming-response']['streaming-keys'][0]["license"]
except KeyError:
raise self.log.exit(res)
else:
try:
res = self.session.post(
url=self.config["endpoints"]["license"],
json={
'streaming-request': {
'version': 1,
'streaming-keys': [
2025-03-18 00:17:27 +05:30
{
2025-03-18 00:23:51 +05:30
#"extra-server-parameters": self.extra_server_parameters,
"challenge": base64.b64encode(challenge.encode()).decode(),
"key-system": "com.widevine.alpha",
"uri": f"data:text/plain;base64,{base64.b64encode(Box.build(track.pssh)).decode()}",
2025-03-18 00:17:27 +05:30
"id": 1,
"lease-action": 'start',
"adamId": self.extra_server_parameters['adamId'],
"isExternal": True,
2025-03-18 00:23:51 +05:30
"svcId": self.extra_server_parameters['svcId'],
2025-03-18 00:17:27 +05:30
},
],
},
2025-03-18 00:23:51 +05:30
},
params=self.config["device"]
).json()
except requests.HTTPError as e:
print(e)
if not e.response.text:
raise self.log.exit(" - No license returned!")
raise self.log.exit(f" - Unable to obtain license (error code: {e.response.json()['errorCode']})")
try:
return res['streaming-response']['streaming-keys'][0]["license"]
except KeyError:
raise self.log.exit(res)
2025-03-18 00:17:27 +05:30
# Service specific functions
def configure(self):
environment = self.get_environment_config()
if not environment:
raise ValueError("Failed to get AppleTV+ WEB TV App Environment Configuration...")
self.session.headers.update({
"User-Agent": self.config["user_agent"],
"Authorization": f"Bearer {environment['MEDIA_API']['token']}",
"media-user-token": self.session.cookies.get_dict()["media-user-token"],
"x-apple-music-user-token": self.session.cookies.get_dict()["media-user-token"]
})
def get_environment_config(self):
"""Loads environment config data from WEB App's <meta> tag."""
res = self.session.get("https://tv.apple.com").text
env = re.search(r'web-tv-app/config/environment"[\s\S]*?content="([^"]+)', res)
if not env:
return None
return json.loads(unquote(env[1]))
2025-03-18 00:23:51 +05:30
def scan(self, start: int, length: int) -> List:
# poetry run vt dl -al en -sl en --selected --proxy http://192.168.0.99:9766 --keys -q 2160 -v H265 ATVP
# poetry run vt dl -al en -sl en --selected --proxy http://192.168.0.99:9766 --keys -q 2160 -v H265 -r DV ATVP
urls = []
params = self.config["device"]
params["utscf"] = "OjAAAAEAAAAAAAAAEAAAACMA"
params["nextToken"] = str(start)
r = None
try:
r = self.session.get(
url=self.config["endpoints"]["homecanvas"],
params=params
)
except requests.HTTPError as e:
if e.response.status_code != 404:
raise
if not r:
raise self.log.exit(f" - Canvas endpoint errored out")
try:
shelves = r.json()["data"]["canvas"]["shelves"]
except json.JSONDecodeError:
raise ValueError(f"Failed to load title manifest: {r.text}")
# TODO - Add check userisentitledtoplay before appending url
for shelf in shelves:
items = shelf["items"]
for item in items:
urls.append(item["url"])
url_regex = re.compile(r"^(?:https?://tv\.apple\.com(?:/[a-z]{2})?/(?P<type>movie|show|episode)/[a-z0-9-]+/)?(?P<id>umc\.cmc\.[a-z0-9]+)")
for url in urls:
match = url_regex.match(url)
if match:
# Extract the title type and ID
title_type = match.group("type") + "s" # None if not present
title_id = match.group("id")
else:
continue
r = None
try:
r = self.session.get(
url=self.config["endpoints"]["title"].format(type=title_type, id=title_id),
params=self.config["device"]
)
except requests.HTTPError as e:
if e.response.status_code != 404:
raise
if not r:
raise self.log.exit(f" - Title ID {self.title!r} could not be found.")
try:
shelves = r.json()["data"]["canvas"]["shelves"]
except json.JSONDecodeError:
raise ValueError(f"Failed to load title manifest: {r.text}")
for shelf in shelves:
if "uts.col.ContentRelated" in shelf["id"]:
items = shelf["items"]
for item in items:
if item["url"] not in urls:
# TODO - Add check userisentitledtoplay before appending url
urls.append(item["url"])
if len(urls) >= length:
break
return urls