diff --git a/README.md b/README.md index 9f61826..997fa9e 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ COMMAND :- | DSNP | DisneyPlus | https://disneyplus.com/ | | HS | Hotstar | https://www.hotstar.com/ | | HULU | Hulu | https://hulu.com | +| iT | iTunes | https://itunes.apple.com | | MAX | Max | https://max.com | | PCOK | Peacock | https://peacocktv.com/ | @@ -262,6 +263,15 @@ Newer titles only have 4k in ISM manifest. So you will need to use the `--ism` f - Authorization: cookies saved to `vinetrimmer/Cookies/Hulu/default.txt` - Windscribe VPN sometimes fails. Simply try again. +### iTunes + +``` +Authorization: Cookies saved to default.txt +Security: UHD@L1 FHD@L1 HD@L1 SD@L3 +``` + +This is iTunes via rential channel on AppleTVPlus + ### Example Command Amazon Example: diff --git a/vinetrimmer/config/Services/itunes.yml b/vinetrimmer/config/Services/itunes.yml new file mode 100644 index 0000000..b5a88f9 --- /dev/null +++ b/vinetrimmer/config/Services/itunes.yml @@ -0,0 +1,5 @@ +user_agent: 'ATVE/6.2.0 Android/10 build/6A226 maker/Google model/Chromecast FW/QTS2.200918.0337115981' # AppleTV6,2/11.1 | ATVE/1.1 FireOS/6.2.6.8 build/4A93 maker/Amazon model/FireTVStick4K FW/NS6268/2315 +user_agent_browser: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' + +endpoints: + license: 'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/fpsRequest' diff --git a/vinetrimmer/services/__init__.py b/vinetrimmer/services/__init__.py index 1066da0..f1e2e7b 100644 --- a/vinetrimmer/services/__init__.py +++ b/vinetrimmer/services/__init__.py @@ -19,8 +19,9 @@ from vinetrimmer.services.sunnxt import Sunnxt from vinetrimmer.services.disneyplus import DisneyPlus from vinetrimmer.services.hulu import Hulu from vinetrimmer.services.paramountplus import ParamountPlus +from vinetrimmer.services.itunes import iTunes -# Above is necessary since dynamic imports like below fuck up nuitak +# Above is necessary since dynamic imports like below fuck up Nuitka # Below dynamic imports fuck with compiling when using Nuitka - exec() call is the problem #for service in os.listdir(os.path.dirname(__file__)): diff --git a/vinetrimmer/services/itunes.py b/vinetrimmer/services/itunes.py new file mode 100644 index 0000000..124296c --- /dev/null +++ b/vinetrimmer/services/itunes.py @@ -0,0 +1,330 @@ +import base64 +import itertools +import json +import os +import re +import requests +from enum import Enum +from urllib.parse import unquote +from click import Context +import click +import m3u8 +from datetime import datetime +from vinetrimmer.objects import AudioTrack, TextTrack, Title, Tracks, VideoTrack +from vinetrimmer.services.BaseService import BaseService +from vinetrimmer.vendor.pymp4.parser import Box +import plistlib + +class iTunes(BaseService): + """ + Service code for Apple's VOD streaming service (https://tv.apple.com). + + \b + Authorization: Cookies + Security: UHD@L1 FHD@L1 HD@L1 SD@L3 + + This is iTunes via rential channel on AppleTVPlus + """ + + ALIASES = ["iT", "itunes"] + TITLE_RE = r"^(?:https?://tv\.apple\.com(?:/[a-z]{2})?/(?:movie|show|episode)/[a-z0-9-]+/)?(?Pumc\.cmc\.[a-z0-9]+)" + + VIDEO_CODEC_MAP = { + "H264": ["avc"], + "H265": ["hvc", "hev", "dvh"] + } + AUDIO_CODEC_MAP = { + "AAC": ["HE", "stereo"], + "AC3": ["ac3"], + "EC3": ["ec3", "atmos"] + } + + @staticmethod + @click.command(name="iTunes", short_help="https://itunes.apple.com") + @click.argument("title", type=str, required=False) + @click.option("-m", "--movie", is_flag=True, default=False, help="Title is a Movie.") + @click.option("-ca", "--checkall", is_flag=True, default=False, help="Check all storefront manifests for additional audios and subs.") + @click.option("-s", "--sf", type=int, default="143458", help="Define storefront int if needed.") + @click.pass_context + def cli(ctx, **kwargs): + return iTunes(ctx, **kwargs) + + def __init__(self, ctx, title: str, movie, checkall, sf: bool): + super().__init__(ctx) + self.parse_title(ctx, title) + + self.vcodec = ctx.parent.params["vcodec"] + self.acodec = ctx.parent.params["acodec"] + + self.profile = ctx.obj.profile + + self.extra_server_parameters = None + self.rental_id = None + self.rentals_supported = False + self.movie = movie + self.checkall = checkall + self.storefront = sf + + self.configure() + + def get_titles(self): + titles = [] + + contentId = re.findall('(umc.[a-z0-9]*.[a-z0-9]*)', self.title)[0] + self.params = { + 'utsk': '6e3013c6d6fae3c2::::::9318c17fb39d6b9c', + 'caller': 'web', + 'sf': self.storefront, + 'v': '46', + 'pfm': 'appletv', + 'mfr': 'Apple', + 'locale': 'pt-BR', + 'l': 'pt', + 'ctx_brand': 'tvs.sbd.4030', + 'count': '100', + 'skip': '0', + } + + if self.movie: + res = self.session.get( + url=f'https://tv.apple.com/api/uts/v2/view/product/{contentId}', + params=self.params + ) + information = res.json()['data']['content'] + titles.append(Title( + id_=self.title, + type_=Title.Types.MOVIE, + name=information['title'], + #year=datetime.fromtimestamp(information['releaseDate'] / 1000).strftime('%Y'), + # TODO: Find a way to get year + original_lang="en", # TODO: Don't assume + source=self.ALIASES[0], + service_data=information + )) + else: + res = self.session.get( + url=f'https://tv.apple.com/api/uts/v2/view/show/{contentId}/episodes', + params=self.params + ) + episodes = res.json()["data"]["episodes"] + for episode in episodes: + titles.append(Title( + id_=self.title, + type_=Title.Types.TV, + name=episode["showTitle"], + season=episode["seasonNumber"], + episode=episode["episodeNumber"], + episode_name=episode.get("title"), + original_lang="en", # TODO: Don't assume + source=self.ALIASES[0], + service_data=episode + )) + return titles + + def get_tracks(self, title: Title) -> Tracks: + res = self.session.get( + url=f'https://tv.apple.com/api/uts/v2/view/product/{title.service_data["id"]}/personalized', + params={ + 'utscf': 'OjAAAAAAAAA~', + 'utsk': '6e3013c6d6fae3c2::::::9318c17fb39d6b9c', + 'caller': 'web', + 'sf': self.storefront, + 'v': '46', + 'pfm': 'web', + 'locale': 'pt-BR' + } + ).json() + stream_data = res + master_hls_url = stream_data['data']['content']['playables'][0]['itunesMediaApiData']['offers'][0][ + 'hlsUrl'].replace("SD", "UHD").replace("HD", "UHD").replace("UUHD", "UHD") + r = self.session.get(master_hls_url) + if not r.ok: + self.log.exit(f" - HTTP Error {r.status_code}: {r.reason}") + raise + + master_hls_manifest = r.text + master_playlist = m3u8.loads(master_hls_manifest, master_hls_url) + if 'chapter' in master_hls_manifest: + chapterLink = master_hls_manifest.rsplit('chapters.plist"', 1)[0].rsplit(',URI="', 1)[1] + 'chapters.plist' + title.service_data['chapters'] = plistlib.loads(self.session.get(chapterLink).content)['chapters'][ + 'chapter-list'] + try: + self.rental_id = \ + stream_data['data']['content']['playables'][0]['itunesMediaApiData']['personalizedOffers'][0]['rentalId'] + except (IndexError, KeyError): + self.rental_id = None + + tracks = Tracks.from_m3u8( + master_playlist, + #lang=title.original_lang, + source=self.ALIASES[0] + ) + + # Function for grabbing additional audios from other storefronts + if self.checkall: + self.log.info(f"Checking extra storefronts") + storefronts = ["143563","143564","143538","143540","143505","143524","143460","143445","143568","143559","143490","143541","143565","143446","143555","143542","143556","143525","143503","143543","143560","143526","143455","143544","143483","143465","143501","143495","143527","143494","143557","143489","143458","143545","143508","143509","143516","143506","143518","143447","143442","143443","143573","143448","143546","143504","143553","143510","143463","143482","143558","143467","143476","143449","143491","143450","143511","143462","143528","143517","143529","143466","143493","143519","143497","143522","143520","143451","143515","143530","143531","143473","143488","143532","143521","143533","143468","143523","143547","143484","143452","143461","143512","143534","143561","143457","143562","143477","143485","143513","143507","143474","143478","143453","143498","143487","143469","143479","143535","143500","143464","143496","143499","143472","143454","143486","143548","143549","143550","143554","143456","143459","143470","143572","143475","143539","143551","143536","143480","143552","143537","143444","143492","143481","143514","143441","143566","143502","143471","143571"] + for extrastorefront in storefronts: + self.log.info(f"Checking storefront: {extrastorefront}") + try: + res = self.session.get( + url=f'https://tv.apple.com/api/uts/v2/view/product/{title.service_data["id"]}/personalized', + params={ + 'utscf': 'OjAAAAAAAAA~', + 'utsk': '6e3013c6d6fae3c2::::::9318c17fb39d6b9c', + 'caller': 'web', + 'sf': extrastorefront, + 'v': '46', + 'pfm': 'web', + 'locale': 'pt-BR' + } + ).json() + + stream_data = res + master_hls_url = stream_data['data']['content']['playables'][0]['itunesMediaApiData']['offers'][0][ + 'hlsUrl'].replace("SD", "UHD").replace("HD", "UHD").replace("UUHD", "UHD") + r = self.session.get(master_hls_url) + if not r.ok: + continue + + master_hls_manifest = r.text + master_playlist = m3u8.loads(master_hls_manifest, master_hls_url) + if 'chapter' in master_hls_manifest: + chapterLink = master_hls_manifest.rsplit('chapters.plist"', 1)[0].rsplit(',URI="', 1)[1] + 'chapters.plist' + title.service_data['chapters'] = plistlib.loads(self.session.get(chapterLink).content)['chapters'][ + 'chapter-list'] + try: + self.rental_id = \ + stream_data['data']['content']['playables'][0]['itunesMediaApiData']['personalizedOffers'][0]['rentalId'] + except (IndexError, KeyError): + self.rental_id = None + + extratracks = Tracks.from_m3u8( + master_playlist, + #lang=title.original_lang, + source=self.ALIASES[0] + ) + for extratrack in extratracks: + if isinstance(extratrack, AudioTrack) or isinstance(extratrack, TextTrack): + tracks.add(extratrack) + except: continue + for track in tracks: + if isinstance(track, AudioTrack): + listel = track.extra.uri.split("/") + for i in listel: + if 'gr' in i: + list2 = i.split("_") + for j in list2: + if 'gr' in j: + if "." in j: + b1 = (j.split(".")[0][2:]) + bitrate = int(re.findall("\d+", b1)[0]) + # print(bitrate) + else: + b1 = (j[2:]) + bitrate = int(re.findall("\d+", b1)[0]) + # print(bitrate) + if bitrate: + track.bitrate = bitrate * 1000 # e.g. 128->128,000, 2448->448,000 + else: + # continue + raise ValueError(f"Unable to get a bitrate value for Track {track.id}") + track.codec = track.codec.replace("_ak", "").replace("_ap3", "").replace("_vod", "") + track.encrypted = True + if isinstance(track, TextTrack): + track.codec = "vtt" + + tracks.videos = [ + x for x in tracks.videos + if x.codec[:3] in self.VIDEO_CODEC_MAP[self.vcodec] + ] + + if self.acodec: + tracks.audios = [ + x for x in tracks.audios + if x.codec.split("-")[0] in self.AUDIO_CODEC_MAP[self.acodec] + ] + + sdh_tracks = [x.language for x in tracks.subtitles if x.sdh] + tracks.subtitles = [x for x in tracks.subtitles if x.language not in sdh_tracks or x.sdh] + + return Tracks([ + # multiple CDNs, only want one + x for x in tracks if "ap-amt" in x.url or x.url == "" + ]) + + def get_chapters(self, title): + return [] + + def certificate(self, **_): + return None # will use common privacy cert + + def license(self, challenge, track, **_): + data = { + "streaming-request": { + "version": 1, + "streaming-keys": [ + { + "id": 1, + "uri": f"data:text/plain;charset=UTF-16;base64,{track.psshPR}", + "challenge": base64.b64encode(challenge.encode('utf-8')).decode('utf-8'), + "key-system": "com.microsoft.playready", + "lease-action": "start", + } + ] + } + } + + if self.rental_id: + data["streaming-request"]["streaming-keys"][0]["rental-id"] = self.rental_id + + res = self.session.post( + url=self.config["endpoints"]["license"], + json=data + ).json() + status = res["streaming-response"]["streaming-keys"][0]["status"] + if status != ResponseCode.OK.value: + self.log.debug(res) + try: + desc = ResponseCode(status).name + except ValueError: + desc = "UNKNOWN" + raise self.log.exit(f" - License request failed. Error: {status} ({desc})") + return base64.b64decode(res["streaming-response"]["streaming-keys"][0]["license"]) + + # Service specific functions + + def configure(self): + #if not re.match(r"https?://(?:geo\.)?itunes\.apple\.com/", self.title): + # raise ValueError("Url must be an iTunes URL...") + + environment = self.get_environment_config() + if not environment: + raise self.log.exit("Failed to get iTunes' WEB TV App Environment Configuration...") + try: + 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"] + }) + except KeyError: + raise self.log.exit(" - No media-user-token cookie found, cannot log in.") + + + + + def get_environment_config(self): + """Loads environment config data from WEB App's 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])) + + +class ResponseCode(Enum): + OK = 0 + INVALID_PSSH = -1001 + NOT_OWNED = -1002 # Title not owned in the requested quality + INSUFFICIENT_SECURITY = -1021 # L1 required or the key used is revoked diff --git a/vinetrimmer/vinetrimmer.yml b/vinetrimmer/vinetrimmer.yml index 6ce06aa..aea2709 100644 --- a/vinetrimmer/vinetrimmer.yml +++ b/vinetrimmer/vinetrimmer.yml @@ -13,6 +13,7 @@ cdm: Jio: 'xiaomi_mi_a1_15.0.0_60ceee88_8159_l3' DisneyPlus: 'mtc_mtc_atv_atv_sl3000' Sunnxt: 'xiaomi_mi_a1_15.0.0_60ceee88_8159_l3' + iTunes: 'mtc_mtc_atv_atv_sl3000' cdm_api: - name: 'playready' @@ -26,7 +27,7 @@ cdm_api: credentials: iTunes: 'username:password' Hotstar: 'username:password' - DisneyPlus: 'tjp4252@gmail.com:Tjcooke@121382' + DisneyPlus: 'username:password' Sunnxt: '9860835343:Welcome@123' directories: