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
|