From e6d2e8891e712d79cfeaa7efc035cd85d648fd9d Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 3 Jan 2026 17:53:34 +0200 Subject: [PATCH] update --- ALL4/__init__.py | 419 ++++++ ALL4/config.yaml | 27 + AMZN/__init__.py | 1174 +++++++++++++++++ AMZN/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 34255 bytes AMZN/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 33147 bytes AMZN/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 61622 bytes AMZN/config.yaml | 162 +++ ATVP/__init__.py | 354 +++++ ATVP/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 10127 bytes ATVP/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 17892 bytes ATVP/config.yaml | 38 + AUBC/__init__.py | 257 ++++ AUBC/config.yaml | 9 + CBC/__init__.py | 319 +++++ CBC/config.yaml | 7 + CBS/__init__.py | 242 ++++ CBS/config.yaml | 10 + CR/__init__.py | 777 +++++++++++ CR/config.yaml | 47 + CTV/__init__.py | 372 ++++++ CTV/config.yaml | 6 + CWTV/__init__.py | 266 ++++ CWTV/config.yaml | 9 + DSCP/__init__.py | 584 ++++++++ DSCP/config.yaml | 5 + DSNP/__init__.py | 972 ++++++++++++++ DSNP/config.yaml | 52 + DSNP/queries.py | 13 + HIDI/__init__.py | 334 +++++ HIDI/config.yaml | 10 + ITV/__init__.py | 361 +++++ ITV/config.yaml | 7 + KNPY/__init__.py | 407 ++++++ KNPY/config.yaml | 15 + KOWP/__init__.py | 297 +++++ KOWP/config.yaml | 5 + MAX/__init__.py | 650 +++++++++ MAX/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 16667 bytes MAX/config.yaml | 6 + MUBI/__init__.py | 396 ++++++ MUBI/config.yaml | 12 + MY5/__init__.py | 208 +++ MY5/config.yaml | 38 + NF/MSL/MSLKeys.py | 10 + NF/MSL/MSLObject.py | 6 + NF/MSL/__init__.py | 408 ++++++ NF/MSL/__pycache__/MSLKeys.cpython-310.pyc | Bin 0 -> 605 bytes NF/MSL/__pycache__/MSLObject.cpython-310.pyc | Bin 0 -> 562 bytes NF/MSL/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 10192 bytes NF/MSL/schemes/EntityAuthentication.py | 59 + NF/MSL/schemes/KeyExchangeRequest.py | 80 ++ NF/MSL/schemes/UserAuthentication.py | 59 + NF/MSL/schemes/__init__.py | 24 + .../EntityAuthentication.cpython-310.pyc | Bin 0 -> 2961 bytes .../KeyExchangeRequest.cpython-310.pyc | Bin 0 -> 3624 bytes .../UserAuthentication.cpython-310.pyc | Bin 0 -> 2655 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 1349 bytes NF/__init__.py | 978 ++++++++++++++ NF/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 25142 bytes NF/config.yaml | 216 +++ NPO/__init__.py | 311 +++++ NPO/config.yaml | 10 + PCOK/__init__.py | 454 +++++++ PCOK/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 12061 bytes PCOK/config.yaml | 27 + PLEX/__init__.py | 323 +++++ PLEX/config.yaml | 12 + PLUTO/__init__.py | 294 +++++ PLUTO/config.yaml | 7 + PTHS/__init__.py | 149 +++ PTHS/config.yaml | 3 + README.md | 22 +- RKTN/__init__.py | 791 +++++++++++ RKTN/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 19614 bytes RKTN/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 36935 bytes RKTN/config.yaml | 71 + ROKU/__init__.py | 261 ++++ ROKU/config.yaml | 5 + RTE/__init__.py | 285 ++++ RTE/config.yaml | 7 + SBS/__init__.py | 224 ++++ SBS/config.yaml | 7 + SEVEN/__init__.py | 439 ++++++ SEVEN/config.yaml | 7 + STV/__init__.py | 232 ++++ STV/config.yaml | 20 + TEN/__init__.py | 565 ++++++++ TEN/config.yaml | 9 + TUBI/__init__.py | 305 +++++ TUBI/config.yaml | 5 + TVNZ/__init__.py | 308 +++++ TVNZ/config.yaml | 9 + UKTV/__init__.py | 194 +++ UKTV/config.yaml | 9 + VIDO/__init__.py | 452 +++++++ VIDO/config.yaml | 5 + VIKI/__init__.py | 328 +++++ VIKI/config.yaml | 8 + VRT/__init__.py | 264 ++++ VRT/config.yaml | 18 + iP/__init__.py | 452 +++++++ iP/config.yaml | 56 + 102 files changed, 17654 insertions(+), 1 deletion(-) create mode 100644 ALL4/__init__.py create mode 100644 ALL4/config.yaml create mode 100644 AMZN/__init__.py create mode 100644 AMZN/__pycache__/__init__.cpython-310.pyc create mode 100644 AMZN/__pycache__/__init__.cpython-311.pyc create mode 100644 AMZN/__pycache__/__init__.cpython-312.pyc create mode 100644 AMZN/config.yaml create mode 100644 ATVP/__init__.py create mode 100644 ATVP/__pycache__/__init__.cpython-310.pyc create mode 100644 ATVP/__pycache__/__init__.cpython-312.pyc create mode 100644 ATVP/config.yaml create mode 100644 AUBC/__init__.py create mode 100644 AUBC/config.yaml create mode 100644 CBC/__init__.py create mode 100644 CBC/config.yaml create mode 100644 CBS/__init__.py create mode 100644 CBS/config.yaml create mode 100644 CR/__init__.py create mode 100644 CR/config.yaml create mode 100644 CTV/__init__.py create mode 100644 CTV/config.yaml create mode 100644 CWTV/__init__.py create mode 100644 CWTV/config.yaml create mode 100644 DSCP/__init__.py create mode 100644 DSCP/config.yaml create mode 100644 DSNP/__init__.py create mode 100644 DSNP/config.yaml create mode 100644 DSNP/queries.py create mode 100644 HIDI/__init__.py create mode 100644 HIDI/config.yaml create mode 100644 ITV/__init__.py create mode 100644 ITV/config.yaml create mode 100644 KNPY/__init__.py create mode 100644 KNPY/config.yaml create mode 100644 KOWP/__init__.py create mode 100644 KOWP/config.yaml create mode 100644 MAX/__init__.py create mode 100644 MAX/__pycache__/__init__.cpython-310.pyc create mode 100644 MAX/config.yaml create mode 100644 MUBI/__init__.py create mode 100644 MUBI/config.yaml create mode 100644 MY5/__init__.py create mode 100644 MY5/config.yaml create mode 100644 NF/MSL/MSLKeys.py create mode 100644 NF/MSL/MSLObject.py create mode 100644 NF/MSL/__init__.py create mode 100644 NF/MSL/__pycache__/MSLKeys.cpython-310.pyc create mode 100644 NF/MSL/__pycache__/MSLObject.cpython-310.pyc create mode 100644 NF/MSL/__pycache__/__init__.cpython-310.pyc create mode 100644 NF/MSL/schemes/EntityAuthentication.py create mode 100644 NF/MSL/schemes/KeyExchangeRequest.py create mode 100644 NF/MSL/schemes/UserAuthentication.py create mode 100644 NF/MSL/schemes/__init__.py create mode 100644 NF/MSL/schemes/__pycache__/EntityAuthentication.cpython-310.pyc create mode 100644 NF/MSL/schemes/__pycache__/KeyExchangeRequest.cpython-310.pyc create mode 100644 NF/MSL/schemes/__pycache__/UserAuthentication.cpython-310.pyc create mode 100644 NF/MSL/schemes/__pycache__/__init__.cpython-310.pyc create mode 100644 NF/__init__.py create mode 100644 NF/__pycache__/__init__.cpython-310.pyc create mode 100644 NF/config.yaml create mode 100644 NPO/__init__.py create mode 100644 NPO/config.yaml create mode 100644 PCOK/__init__.py create mode 100644 PCOK/__pycache__/__init__.cpython-310.pyc create mode 100644 PCOK/config.yaml create mode 100644 PLEX/__init__.py create mode 100644 PLEX/config.yaml create mode 100644 PLUTO/__init__.py create mode 100644 PLUTO/config.yaml create mode 100644 PTHS/__init__.py create mode 100644 PTHS/config.yaml create mode 100644 RKTN/__init__.py create mode 100644 RKTN/__pycache__/__init__.cpython-310.pyc create mode 100644 RKTN/__pycache__/__init__.cpython-312.pyc create mode 100644 RKTN/config.yaml create mode 100644 ROKU/__init__.py create mode 100644 ROKU/config.yaml create mode 100644 RTE/__init__.py create mode 100644 RTE/config.yaml create mode 100644 SBS/__init__.py create mode 100644 SBS/config.yaml create mode 100644 SEVEN/__init__.py create mode 100644 SEVEN/config.yaml create mode 100644 STV/__init__.py create mode 100644 STV/config.yaml create mode 100644 TEN/__init__.py create mode 100644 TEN/config.yaml create mode 100644 TUBI/__init__.py create mode 100644 TUBI/config.yaml create mode 100644 TVNZ/__init__.py create mode 100644 TVNZ/config.yaml create mode 100644 UKTV/__init__.py create mode 100644 UKTV/config.yaml create mode 100644 VIDO/__init__.py create mode 100644 VIDO/config.yaml create mode 100644 VIKI/__init__.py create mode 100644 VIKI/config.yaml create mode 100644 VRT/__init__.py create mode 100644 VRT/config.yaml create mode 100644 iP/__init__.py create mode 100644 iP/config.yaml diff --git a/ALL4/__init__.py b/ALL4/__init__.py new file mode 100644 index 0000000..5ed1197 --- /dev/null +++ b/ALL4/__init__.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import sys +from collections.abc import Generator +from datetime import datetime, timezone +from http.cookiejar import MozillaCookieJar +from typing import Any, Optional, Union + +import click +from click import Context +from Crypto.Util.Padding import unpad +from Cryptodome.Cipher import AES +from pywidevine.cdm import Cdm as WidevineCdm +from unshackle.core.credential import Credential +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Subtitle, Tracks + + +class ALL4(Service): + """ + Service code for Channel 4's All4 streaming service (https://channel4.com). + + \b + Version: 1.0.1 + Author: stabbedbybrick + Authorization: Credentials + Robustness: + L3: 1080p, AAC2.0 + + \b + Tips: + - Use complete title URL or slug as input: + https://www.channel4.com/programmes/taskmaster OR taskmaster + - Use on demand URL for directly downloading episodes: + https://www.channel4.com/programmes/taskmaster/on-demand/75588-002 + - Both android and web/pc endpoints are checked for quality profiles. + If android is missing 1080p, it automatically falls back to web. + """ + + GEOFENCE = ("gb", "ie") + TITLE_RE = r"^(?:https?://(?:www\.)?channel4\.com/programmes/)?(?P[a-z0-9-]+)(?:/on-demand/(?P[0-9-]+))?" + + @staticmethod + @click.command(name="ALL4", short_help="https://channel4.com", help=__doc__) + @click.argument("title", type=str) + @click.pass_context + def cli(ctx: Context, **kwargs: Any) -> ALL4: + return ALL4(ctx, **kwargs) + + def __init__(self, ctx: Context, title: str): + self.title = title + super().__init__(ctx) + + self.authorization: str + self.asset_id: int + self.license_token: str + self.manifest: str + + self.session.headers.update( + { + "X-C4-Platform-Name": self.config["device"]["platform_name"], + "X-C4-Device-Type": self.config["device"]["device_type"], + "X-C4-Device-Name": self.config["device"]["device_name"], + "X-C4-App-Version": self.config["device"]["app_version"], + "X-C4-Optimizely-Datafile": self.config["device"]["optimizely_datafile"], + } + ) + + def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + if not credential: + raise EnvironmentError("Service requires Credentials for Authentication.") + + cache = self.cache.get(f"tokens_{credential.sha1}") + + if cache and not cache.expired: + # cached + self.log.info(" + Using cached Tokens...") + tokens = cache.data + elif cache and cache.expired: + # expired, refresh + self.log.info("Refreshing cached Tokens") + r = self.session.post( + self.config["endpoints"]["login"], + headers={"authorization": f"Basic {self.config['android']['auth']}"}, + data={ + "grant_type": "refresh_token", + "username": credential.username, + "password": credential.password, + "refresh_token": cache.data["refreshToken"], + }, + ) + try: + res = r.json() + except json.JSONDecodeError: + raise ValueError(f"Failed to refresh tokens: {r.text}") + + if "error" in res: + self.log.error(f"Failed to refresh tokens: {res['errorMessage']}") + sys.exit(1) + + tokens = res + self.log.info(" + Refreshed") + else: + # new + headers = {"authorization": f"Basic {self.config['android']['auth']}"} + data = { + "grant_type": "password", + "username": credential.username, + "password": credential.password, + } + r = self.session.post(self.config["endpoints"]["login"], headers=headers, data=data) + try: + res = r.json() + except json.JSONDecodeError: + raise ValueError(f"Failed to log in: {r.text}") + + if "error" in res: + self.log.error(f"Failed to log in: {res['errorMessage']}") + sys.exit(1) + + tokens = res + self.log.info(" + Acquired tokens...") + + cache.set(tokens, expiration=tokens["expiresIn"]) + + self.authorization = f"Bearer {tokens['accessToken']}" + + def search(self) -> Generator[SearchResult, None, None]: + params = { + "expand": "default", + "q": self.title, + "limit": "100", + "offset": "0", + } + + r = self.session.get(self.config["endpoints"]["search"], params=params) + r.raise_for_status() + + results = r.json() + if isinstance(results["results"], list): + for result in results["results"]: + yield SearchResult( + id_=result["brand"].get("websafeTitle"), + title=result["brand"].get("title"), + description=result["brand"].get("description"), + label=result.get("label"), + url=result["brand"].get("href"), + ) + + def get_titles(self) -> Union[Movies, Series]: + title, on_demand = (re.match(self.TITLE_RE, self.title).group(i) for i in ("id", "vid")) + + r = self.session.get( + self.config["endpoints"]["title"].format(title=title), + params={"client": "android-mod", "deviceGroup": "mobile", "include": "extended-restart"}, + headers={"Authorization": self.authorization}, + ) + if not r.ok: + self.log.error(r.text) + sys.exit(1) + + data = r.json() + + if on_demand is not None: + episodes = [ + Episode( + id_=episode["programmeId"], + service=self.__class__, + title=data["brand"]["title"], + season=episode["seriesNumber"], + number=episode["episodeNumber"], + name=episode["originalTitle"], + language="en", + data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"), + ) + for episode in data["brand"]["episodes"] + if episode.get("assetInfo") and episode["programmeId"] == on_demand + ] + if not episodes: + # Parse HTML of episode page to find title + data = self.get_html(self.title) + episodes = [ + Episode( + id_=data["selectedEpisode"]["programmeId"], + service=self.__class__, + title=data["brand"]["title"], + season=data["selectedEpisode"]["seriesNumber"] or 0, + number=data["selectedEpisode"]["episodeNumber"] or 0, + name=data["selectedEpisode"]["originalTitle"], + language="en", + data=data["selectedEpisode"], + ) + ] + + return Series(episodes) + + elif data["brand"]["programmeType"] == "FM": + return Movies( + [ + Movie( + id_=movie["programmeId"], + service=self.__class__, + name=data["brand"]["title"], + year=int(data["brand"]["summary"].split(" ")[0].strip().strip("()")), + language="en", + data=movie["assetInfo"].get("streaming") or movie["assetInfo"].get("download"), + ) + for movie in data["brand"]["episodes"] + ] + ) + else: + return Series( + [ + Episode( + id_=episode["programmeId"], + service=self.__class__, + title=data["brand"]["title"], + season=episode["seriesNumber"], + number=episode["episodeNumber"], + name=episode["originalTitle"], + language="en", + data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"), + ) + for episode in data["brand"]["episodes"] + if episode.get("assetInfo") + ] + ) + + def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: + android_assets: tuple = self.android_playlist(title.id) + web_assets: tuple = self.web_playlist(title.id) + self.manifest, self.license_token, subtitle, data = self.sort_assets(title, android_assets, web_assets) + self.asset_id = int(title.data["assetId"]) + + tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language) + tracks.videos[0].data = data + + # manifest subtitles are sometimes empty even if they exist + # so we clear them and add the subtitles manually + tracks.subtitles.clear() + if subtitle is not None: + tracks.add( + Subtitle( + id_=hashlib.md5(subtitle.encode()).hexdigest()[0:6], + url=subtitle, + codec=Subtitle.Codec.from_mime(subtitle[-3:]), + language=title.language, + is_original_lang=True, + forced=False, + sdh=False, + ) + ) + else: + self.log.warning("- Subtitles are either missing or empty") + + for track in tracks.audio: + role = track.data["dash"]["representation"].find("Role") + if role is not None and role.get("value") in ["description", "alternative", "alternate"]: + track.descriptive = True + + return tracks + + def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]: + track = title.tracks.videos[0] + + chapters = [ + Chapter( + name=f"Chapter {i + 1:02}", + timestamp=datetime.fromtimestamp((ms / 1000), tz=timezone.utc).strftime("%H:%M:%S.%f")[:-3], + ) + for i, ms in enumerate(x["breakOffset"] for x in track.data["adverts"]["breaks"]) + ] + + if track.data.get("endCredits", {}).get("squeezeIn"): + chapters.append( + Chapter( + name="Credits", + timestamp=datetime.fromtimestamp( + (track.data["endCredits"]["squeezeIn"] / 1000), tz=timezone.utc + ).strftime("%H:%M:%S.%f")[:-3], + ) + ) + + return chapters + + def get_widevine_service_certificate(self, **_: Any) -> str: + return WidevineCdm.common_privacy_cert + + def get_widevine_license(self, challenge: bytes, **_: Any) -> str: + payload = { + "message": base64.b64encode(challenge).decode("utf8"), + "token": self.license_token, + "request_id": self.asset_id, + "video": {"type": "ondemand", "url": self.manifest}, + } + + r = self.session.post(self.config["endpoints"]["license"], json=payload) + if not r.ok: + raise ConnectionError(f"License request failed: {r.json()['status']['type']}") + + return r.json()["license"] + + # Service specific functions + + def sort_assets(self, title: Union[Movie, Episode], android_assets: tuple, web_assets: tuple) -> tuple: + android_heights = None + web_heights = None + + if android_assets is not None: + try: + a_manifest, a_token, a_subtitle, data = android_assets + android_tracks = DASH.from_url(a_manifest, self.session).to_tracks(title.language) + android_heights = sorted([int(track.height) for track in android_tracks.videos], reverse=True) + except Exception: + android_heights = None + + if web_assets is not None: + try: + b_manifest, b_token, b_subtitle, data = web_assets + session = self.session + session.headers.update(self.config["headers"]) + web_tracks = DASH.from_url(b_manifest, session).to_tracks(title.language) + web_heights = sorted([int(track.height) for track in web_tracks.videos], reverse=True) + except Exception: + web_heights = None + + if not android_heights and not web_heights: + self.log.error("Failed to request manifest data. If you're behind a VPN/proxy, you might be blocked") + sys.exit(1) + + if not android_heights or android_heights[0] < 1080: + lic_token = self.decrypt_token(b_token, client="WEB") + return b_manifest, lic_token, b_subtitle, data + else: + lic_token = self.decrypt_token(a_token, client="ANDROID") + return a_manifest, lic_token, a_subtitle, data + + def android_playlist(self, video_id: str) -> tuple: + url = self.config["android"]["vod"].format(video_id=video_id) + headers = {"authorization": self.authorization} + + r = self.session.get(url=url, headers=headers) + if not r.ok: + self.log.warning("Request for Android endpoint returned %s", r) + return None + + data = json.loads(r.content) + manifest = data["videoProfiles"][0]["streams"][0]["uri"] + token = data["videoProfiles"][0]["streams"][0]["token"] + subtitle = next( + (x["url"] for x in data["subtitlesAssets"] if x["url"].endswith(".vtt")), + None, + ) + + return manifest, token, subtitle, data + + def web_playlist(self, video_id: str) -> tuple: + url = self.config["web"]["vod"].format(programmeId=video_id) + r = self.session.get(url, headers=self.config["headers"]) + if not r.ok: + self.log.warning("Request for WEB endpoint returned %s", r) + return None + + data = json.loads(r.content) + + for item in data["videoProfiles"]: + if item["name"] == "dashwv-dyn-stream-1": + token = item["streams"][0]["token"] + manifest = item["streams"][0]["uri"] + + subtitle = next( + (x["url"] for x in data["subtitlesAssets"] if x["url"].endswith(".vtt")), + None, + ) + + return manifest, token, subtitle, data + + def decrypt_token(self, token: str, client: str) -> tuple: + if client == "ANDROID": + key = self.config["android"]["key"] + iv = self.config["android"]["iv"] + + if client == "WEB": + key = self.config["web"]["key"] + iv = self.config["web"]["iv"] + + if isinstance(token, str): + token = base64.b64decode(token) + cipher = AES.new( + key=base64.b64decode(key), + iv=base64.b64decode(iv), + mode=AES.MODE_CBC, + ) + data = unpad(cipher.decrypt(token), AES.block_size) + dec_token = data.decode().split("|")[1] + return dec_token.strip() + + def get_html(self, url: str) -> dict: + r = self.session.get(url=url, headers=self.config["headers"]) + r.raise_for_status() + + init_data = re.search( + "", + "".join(r.content.decode().replace("\u200c", "").replace("\r\n", "").replace("undefined", "null")), + ) + try: + data = json.loads(init_data.group(1)) + return data["initialData"] + except Exception: + self.log.error(f"Failed to get episode for {url}") + sys.exit(1) diff --git a/ALL4/config.yaml b/ALL4/config.yaml new file mode 100644 index 0000000..1468e01 --- /dev/null +++ b/ALL4/config.yaml @@ -0,0 +1,27 @@ +headers: + Accept-Language: en-US,en;q=0.8 + User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36 + +endpoints: + login: https://api.channel4.com/online/v2/auth/token + title: https://api.channel4.com/online/v1/views/content-hubs/{title}.json + license: https://c4.eme.lp.aws.redbeemedia.com/wvlicenceproxy-service/widevine/acquire + search: https://all4nav.channel4.com/v1/api/search + +android: + key: QVlESUQ4U0RGQlA0TThESA==" + iv: MURDRDAzODNES0RGU0w4Mg==" + auth: MzZVVUN0OThWTVF2QkFnUTI3QXU4ekdIbDMxTjlMUTE6Sllzd3lIdkdlNjJWbGlrVw== + vod: https://api.channel4.com/online/v1/vod/stream/{video_id}?client=android-mod + +web: + key: bjljTGllWWtxd3pOQ3F2aQ== + iv: b2R6Y1UzV2RVaVhMdWNWZA== + vod: https://www.channel4.com/vod/stream/{programmeId} + +device: + platform_name: android + device_type: mobile + device_name: "Sony C6903 (C6903)" + app_version: "android_app:9.4.2" + optimizely_datafile: "2908" diff --git a/AMZN/__init__.py b/AMZN/__init__.py new file mode 100644 index 0000000..83050b4 --- /dev/null +++ b/AMZN/__init__.py @@ -0,0 +1,1174 @@ +import base64 +import hashlib +import json +from logging import Logger +import os +from pathlib import Path +import re +import sys +from collections import defaultdict +from http.cookiejar import CookieJar +import time +from typing import Any, Optional, Literal, Union +from urllib.parse import quote, urlencode, urlparse, urlunparse +from uuid import uuid4 + +import requests + +import click +import jsonpickle +from langcodes import Language +from click.core import ParameterSource + +from unshackle.core.cacher import Cacher +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH, ISM +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T +from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks, Track, Video +from unshackle.core.tracks.audio import Audio +from unshackle.core.utilities import is_close_match +from unshackle.core.utils.collections import as_list + + +class AMZN(Service): + + """ + Service code for Amazon VOD (https://amazon.com) and Amazon Prime Video (https://primevideo.com). + + \b + + Authorization: Cookies + + Security: + + UHD@L1/SL3000 + FHD@L3(ChromeCDM)/SL2000 + SD@L3 + Certain SL2000 can do UHD + + \b + + Maintains their own license server like Netflix, be cautious. + + Region is chosen automatically based on domain extension found in cookies. + Prime Video specific code will be run if the ASIN is detected to be a prime video variant. + Use 'Amazon Video ASIN Display' for Tampermonkey addon for ASIN + https://greasyfork.org/en/scripts/496577-amazon-video-asin-display + + vt dl --list -z uk -q 1080 Amazon B09SLGYLK8 + """ + # GEOFENCE = ("",) + ALIASES = ("Amazon", "prime", 'amazon') + TITLE_RE = r"^(?:https?://(?:www\.)?(?Pamazon\.(?Pcom|co\.uk|de|co\.jp)|primevideo\.com)(?:/.+)?/)?(?P[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)" # noqa: E501 + + REGION_TLD_MAP = { + "au": "com.au", + "br": "com.br", + "jp": "co.jp", + "mx": "com.mx", + "tr": "com.tr", + "gb": "co.uk", + "us": "com", + } + VIDEO_RANGE_MAP = { + "SDR": "None", + "HDR10": "Hdr10", + "DV": "DolbyVision", + } + VIDEO_CODEC_MAP = { + "H264": ["avc1"], + "H265": ["hvc1", "dvh1"] + } + @staticmethod + @click.command(name="AMZN", short_help="https://amazon.com, https://primevideo.com", help=__doc__) + @click.argument("title", type=str, required=False) + @click.option("-b", "--bitrate", default="CBR", + type=click.Choice(["CVBR", "CBR", "CVBR+CBR"], case_sensitive=False), + help="Video Bitrate Mode to download in. CVBR=Constrained Variable Bitrate, CBR=Constant Bitrate.") + @click.option("-c", "--cdn", default=None, type=str, + help="CDN to download from, defaults to the CDN with the highest weight set by Amazon.") + # UHD, HD, SD. UHD only returns HEVC, ever, even for <=HD only content + @click.option("-vq", "--vquality", default="HD", + type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False), + help="Manifest quality to request.") + @click.option("-s", "--single", is_flag=True, default=False, + help="Force single episode/season instead of getting series ASIN.") + @click.option("-am", "--amanifest", default="H265", + type=click.Choice(["CVBR", "CBR", "H265"], case_sensitive=False), + help="Manifest to use for audio. Defaults to H265 if the video manifest is missing 640k audio.") + @click.option("-aq", "--aquality", default="SD", + type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False), + help="Manifest quality to request for audio. Defaults to the same as --quality.") + # @click.option("-ism", "--ism", is_flag=True, default=False, + # help="Set manifest override to SmoothStreaming. Defaults to DASH w/o this flag.") ## DPRECATED + @click.option("-aa", "--atmos", is_flag=True, default=False, + help="Prefer Atmos audio if available, otherwise defaults to 640k audio.") + @click.option("-drm", "--drm-system", type=click.Choice(["widevine", "playready"], case_sensitive=False), + default="playready", + help="which drm system to use") + + @click.pass_context + def cli(ctx, **kwargs): + return AMZN(ctx, **kwargs) + + def __init__(self, ctx, title, bitrate: str, cdn: str, vquality: str, single: bool, amanifest: str, aquality: str, drm_system: Literal["widevine", "playready"], atmos: bool) -> None: + m = self.parse_title(ctx, title) + self.domain = m.get("domain") + self.domain_region = m.get("region") + self.drm_system = drm_system + self.bitrate = bitrate + self.bitrate_source = ctx.get_parameter_source("bitrate") + self.vquality = vquality + self.vquality_source = ctx.get_parameter_source("vquality") + self.cdn = cdn + self.single = single + self.amanifest = amanifest + self.aquality = aquality + self.atmos = atmos + super().__init__(ctx) + + assert ctx.parent is not None + + + self.chapters_only = ctx.parent.params.get("chapters_only") + self.quality = ctx.parent.params.get("quality") + + self.cdm = ctx.obj.cdm + self.profile = ctx.obj.profile + self.region: dict[str, str] = {} + self.endpoints: dict[str, str] = {} + self.device: dict[str, str] = {} + + self.pv = self.domain == "primevideo.com" + self.device_token = None + self.device_id = None + self.customer_id = None + self.client_id = "f22dbddb-ef2c-48c5-8876-bed0d47594fd" # browser client id + + vcodec = ctx.parent.params.get("vcodec") + range = ctx.parent.params.get("range_") + + self.range = range[0].name if range else "SDR" + self.vcodec = "H265" if vcodec and vcodec == Video.Codec.HEVC else "H264" + + if self.vquality_source != ParameterSource.COMMANDLINE: + if any(q <= 576 for q in self.quality) and "SDR" == self.range: + self.log.info(" + Setting manifest quality to SD") + self.vquality = "SD" + + if any(q > 1080 for q in self.quality): + self.log.info(" + Setting manifest quality to UHD and vcodec to H265 to be able to get 2160p video track") + self.vquality = "UHD" + self.vcodec = "H265" + + self.vquality = self.vquality or "HD" + + if self.bitrate_source != ParameterSource.COMMANDLINE: + if self.vcodec == "H265" and self.range == "SDR" and self.bitrate != "CVBR+CBR": + self.bitrate = "CVBR+CBR" + self.log.info(" + Changed bitrate mode to CVBR+CBR to be able to get H.265 SDR video track") + + if self.vquality == "UHD" and self.range != "SDR" and self.bitrate != "CBR": + self.bitrate = "CBR" + self.log.info(f" + Changed bitrate mode to CBR to be able to get highest quality UHD {self.range} video track") + + self.orig_bitrate = self.bitrate + + + self.manifestTypeTry = "DASH" + self.log.info("Getting tracks from MPD manifest") + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + if not cookies: + raise EnvironmentError("Service requires Cookies for Authentication.") + + self.session.cookies.update(cookies) + self.configure() + + # Abstracted functions + + def get_titles(self) -> Titles_T: + res = self.session.get( + url=self.endpoints["details"], + params={"titleID": self.title, "isElcano": "1", "sections": "Atf"}, + headers={"Accept": "application/json"}, + ).json()["widgets"] + + entity = res["header"]["detail"].get("entityType") + if not entity: + self.log.error(" - Failed to get entity type") + sys.exit(1) + + if entity == "Movie": + metadata = res["header"]["detail"] + return Movies( + [ + Movie( + id_=metadata.get("catalogId"), + year=metadata.get("releaseYear"), + name=metadata.get("title"), + service=self.__class__, + data=metadata, + ) + ] + ) + elif entity == "TV Show": + seasons = [x.get("titleID") for x in res["seasonSelector"]] + + episodes = [] + for season in seasons: + res = self.session.get( + url=self.endpoints["detail"], + params={"titleID": season, "isElcano": "1", "sections": "Btf"}, + headers={"Accept": "application/json"}, + ).json()["widgets"] + + # cards = [x["detail"] for x in as_list(res["titleContent"][0]["cards"])] + cards = [ + {**x["detail"], "sequenceNumber": x["self"]["sequenceNumber"]} + for x in res["episodeList"]["episodes"] + ] + + product_details = res["productDetails"]["detail"] + + episodes.extend( + Episode( + id_=title.get("titleId") or title["catalogId"], + title=product_details.get("parentTitle") or product_details["title"], + year=title.get("releaseYear") or product_details.get("releaseYear"), + season=product_details.get("seasonNumber"), + number=title.get("sequenceNumber"), + name=title.get("title"), + service=self.__class__, + data=title, + ) + for title in cards + if title["entityType"] == "TV Show" + ) + + return Series(episodes) + + def get_tracks(self, title: Title_T) -> Tracks: + tracks = Tracks() + if self.chapters_only: + return [] + + #manifest, chosen_manifest, tracks = self.get_best_quality(title) + + manifest = self.get_manifest( + title, + video_codec=self.vcodec, + bitrate_mode=self.bitrate, + quality=self.vquality, + hdr=self.range, + ignore_errors=False + + ) + + # Move rightsException termination here so that script can attempt continuing + if "rightsException" in manifest["returnedTitleRendition"]["selectedEntitlement"]: + self.log.error(" - The profile used does not have the rights to this title.") + return + + self.customer_id = manifest["returnedTitleRendition"]["selectedEntitlement"]["grantedByCustomerId"] + + default_url_set = manifest["playbackUrls"]["urlSets"][manifest["playbackUrls"]["defaultUrlSetId"]] + encoding_version = default_url_set["urls"]["manifest"]["encodingVersion"] + self.log.info(f" + Detected encodingVersion={encoding_version}") + + #print(manifest) + chosen_manifest = self.choose_manifest(manifest, self.cdn) + + if not chosen_manifest: + raise self.log.exit(f"No manifests available") + + manifest_url = self.clean_mpd_url(chosen_manifest["avUrlInfoList"][0]["url"], False) + self.log.debug(manifest_url) + # if self.event: + # devicetype = self.device["device_type"] + # manifest_url = chosen_manifest["avUrlInfoList"][0]["url"] + # manifest_url = f"{manifest_url}?amznDtid={devicetype}&encoding=segmentBase" + self.log.info(" + Downloading Manifest") + + if chosen_manifest["streamingTechnology"] == "DASH": + tracks = Tracks([ + x for x in iter(DASH.from_url(url=manifest_url, session=self.session).to_tracks(language="es")) + ]) + elif chosen_manifest["streamingTechnology"] == "SmoothStreaming": + tracks = Tracks([ + x for x in iter(ISM.from_url(url=manifest_url, session=self.session).to_tracks(language="es")) + ]) + else: + raise self.log.exit(f"Unsupported manifest type: {chosen_manifest['streamingTechnology']}") + + need_separate_audio = ((self.aquality or self.vquality) != self.vquality + or self.amanifest == "CVBR" and (self.vcodec, self.bitrate) != ("H264", "CVBR") + or self.amanifest == "CBR" and (self.vcodec, self.bitrate) != ("H264", "CBR") + or self.amanifest == "H265" and self.vcodec != "H265" + or self.amanifest != "H265" and self.vcodec == "H265") + + if not need_separate_audio: + audios = defaultdict(list) + for audio in tracks.audio: + audios[audio.language].append(audio) + + for lang in audios: + if not any((x.bitrate or 0) >= 640000 for x in audios[lang]): + need_separate_audio = True + break + + if need_separate_audio: # and not self.atmos: + tracks.audio.clear() + manifest_type = self.amanifest or "H265" + self.log.info(f"Getting audio from {manifest_type} manifest for potential higher bitrate or better codec") + audio_manifest = self.get_manifest( + title=title, + video_codec="H265" if manifest_type == "H265" else "H264", + bitrate_mode="CVBR" if manifest_type != "CBR" else "CBR", + quality=self.aquality or self.vquality, + hdr=None, + ignore_errors=True + ) + if not audio_manifest: + self.log.warning(f" - Unable to get {manifest_type} audio manifests, skipping") + elif not (chosen_audio_manifest := self.choose_manifest(audio_manifest, self.cdn)): + self.log.warning(f" - No {manifest_type} audio manifests available, skipping") + else: + audio_mpd_url = self.clean_mpd_url(chosen_audio_manifest["avUrlInfoList"][0]["url"], optimise=False) + self.log.debug(audio_mpd_url) + # if self.event: + # devicetype = self.device["device_type"] + # audio_mpd_url = chosen_audio_manifest["avUrlInfoList"][0]["url"] + # audio_mpd_url = f"{audio_mpd_url}?amznDtid={devicetype}&encoding=segmentBase" + self.log.info(" + Downloading HEVC manifest") + + try: + audio_mpd = Tracks([ + x for x in iter(DASH.from_url(url=audio_mpd_url, session=self.session).to_tracks(language="en")) + ]) + except KeyError: + self.log.warning(f" - Title has no {self.amanifest} stream, cannot get higher quality audio") + else: + tracks.audio = audio_mpd.audio # expecting possible dupes, ignore + + need_uhd_audio = self.atmos + + if not self.amanifest and ((self.aquality == "UHD" and self.vquality != "UHD") or not self.aquality): + audios = defaultdict(list) + for audio in tracks.audio: + audios[audio.language].append(audio) + for lang in audios: + if not any((x.bitrate or 0) >= 640000 for x in audios[lang]): + need_uhd_audio = True + break + + if need_uhd_audio and (self.config.get("device") or {}).get(self.profile, None): + self.log.info("Getting audio from UHD manifest for potential higher bitrate or better codec") + temp_device = self.device + temp_device_token = self.device_token + temp_device_id = self.device_id + uhd_audio_manifest = None + + try: + if self.cdm.device.type in ["CHROME", "PLAYREADY"] and self.quality < 2160: + self.log.info(f" + Switching to device to get UHD manifest") + self.register_device() + + uhd_audio_manifest = self.get_manifest( + title=title, + video_codec="H265", + bitrate_mode="CVBR+CBR", + quality="UHD", + hdr="DV", # Needed for 576kbps Atmos sometimes + ignore_errors=True + ) + except: + pass + + self.device = temp_device + self.device_token = temp_device_token + self.device_id = temp_device_id + + if not uhd_audio_manifest: + self.log.warning(f" - Unable to get UHD manifests, skipping") + elif not (chosen_uhd_audio_manifest := self.choose_manifest(uhd_audio_manifest, self.cdn)): + self.log.warning(f" - No UHD manifests available, skipping") + else: + tracks.audio.clear() + uhd_audio_mpd_url = self.clean_mpd_url(chosen_uhd_audio_manifest["avUrlInfoList"][0]["url"], optimise=False) + self.log.debug(uhd_audio_mpd_url) + # if self.event: + # devicetype = self.device["device_type"] + # uhd_audio_mpd_url = chosen_uhd_audio_manifest["avUrlInfoList"][0]["url"] + # uhd_audio_mpd_url = f"{uhd_audio_mpd_url}?amznDtid={devicetype}&encoding=segmentBase" + self.log.info(" + Downloading UHD manifest") + + try: + uhd_audio_mpd = Tracks([ + x for x in iter(DASH.from_url(url=uhd_audio_mpd_url, session=self.session).to_tracks(language="en")) + ]) + except KeyError: + self.log.warning(f" - Title has no UHD stream, cannot get higher quality audio") + else: + # replace the audio tracks with DV manifest version if atmos is present + if any(x for x in uhd_audio_mpd.audio if x.atmos): + tracks.audio = uhd_audio_mpd.audio + + for video in tracks.videos: + video.hdr10 = chosen_manifest["hdrFormat"] == "Hdr10" + video.dv = chosen_manifest["hdrFormat"] == "DolbyVision" + + for audio in tracks.audio: + audio.descriptive = audio.data["dash"]["adaptation_set"].get("audioTrackSubtype") == "descriptive" + # Amazon @lang is just the lang code, no dialect, @audioTrackId has it. + audio_track_id = audio.data["dash"]["adaptation_set"].get("audioTrackId") + if audio_track_id: + audio.language = Language.get(audio_track_id.split("_")[0]) # e.g. es-419_ec3_blabla + # Remove any audio tracks with dialog boost! + if audio.data["dash"]["adaptation_set"] is not None and "boosteddialog" in audio.data["dash"]["adaptation_set"].get("audioTrackSubtype", ""): + audio.bitrate = 1 + + for sub in manifest.get("subtitleUrls", []) + manifest.get("forcedNarratives", []): + try: + tracks.add(Subtitle( + id_=sub.get( + "timedTextTrackId", + f"{sub['languageCode']}_{sub['type']}_{sub['subtype']}_{sub.get('index', 'default')}" + ), + url=os.path.splitext(sub["url"])[0] + ".srt", # DFXP -> SRT forcefully seems to work fine + # metadata + codec=Subtitle.Codec.from_codecs("srt"), # sub["format"].lower(), + language=sub["languageCode"], + #is_original_lang=title.original_lang and is_close_match(sub["languageCode"], [title.original_lang]), + forced="forced" in sub["displayName"], + sdh=sub["type"].lower() == "sdh" # TODO: what other sub types? cc? forced? + ), warn_only=True) # expecting possible dupes, ignore + except KeyError: + # Log the KeyError Exception but continue (as only the subtitles will be missing) + self.log.error("Unexpected subtitle track id data format, subtitles will be missing", exc_info=True) + + for track in tracks: + track.needs_proxy = False + + tracks.audio = self._dedupe(tracks.audio) + + return tracks + + @staticmethod + def _dedupe(items: list) -> list: + if not items: + return items + if isinstance(items[0].url, list): + return items + + # Create a more specific key for deduplication that includes resolution/bitrate + seen = {} + for item in items: + # For video tracks, use codec + resolution + bitrate as key + if hasattr(item, 'width') and hasattr(item, 'height'): + key = f"{item.codec}_{item.width}x{item.height}_{item.bitrate}" + # For audio tracks, use codec + language + bitrate + channels as key + elif hasattr(item, 'channels'): + key = f"{item.codec}_{item.language}_{item.bitrate}_{item.channels}" + # Fallback to URL for other track types + else: + key = item.url + + # Keep the item if we haven't seen this exact combination + if key not in seen: + seen[key] = item + + return list(seen.values()) + + def get_chapters(self, title: Title_T) -> Chapters: + """Get chapters from Amazon's XRay Scenes API.""" + manifest = self.get_manifest( + title, + video_codec=self.vcodec, + bitrate_mode=self.bitrate, + quality="UHD", + hdr=self.range + ) + + if "xrayMetadata" in manifest: + xray_params = manifest["xrayMetadata"]["parameters"] + elif self.chapters_only: + xray_params = { + "pageId": "fullScreen", + "pageType": "xray", + "serviceToken": json.dumps({ + "consumptionType": "Streaming", + "deviceClass": "normal", + "playbackMode": "playback", + "vcid": manifest["returnedTitleRendition"]["contentId"], + }) + } + else: + return [] + + xray_params.update({ + "deviceID": self.device_id, + "deviceTypeID": self.config["device_types"]["browser"], # must be browser device type + "marketplaceID": self.region["marketplace_id"], + "gascEnabled": str(self.pv).lower(), + "decorationScheme": "none", + "version": "inception-v2", + "uxLocale": "en-US", + "featureScheme": "XRAY_WEB_2020_V1" + }) + + xray = self.session.get( + url=self.endpoints["xray"], + params=xray_params + ).json().get("page") + + if not xray: + return [] + + widgets = xray["sections"]["center"]["widgets"]["widgetList"] + + scenes = next((x for x in widgets if x["tabType"] == "scenesTab"), None) + if not scenes: + return [] + scenes = scenes["widgets"]["widgetList"][0]["items"]["itemList"] + + chapters = [] + + for scene in scenes: + chapter_title = scene["textMap"]["PRIMARY"] + match = re.search(r"(\d+\. |)(.+)", chapter_title) + if match: + chapter_title = match.group(2) + chapters.append(Chapter( + name=chapter_title, + timestamp=scene["textMap"]["TERTIARY"].replace("Starts at ", "") + )) + + return chapters + + def get_widevine_service_certificate(self, **_: Any) -> str: + return self.config["certificate"] + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track) -> None: + response = self.session.post( + url=self.endpoints["license"], + params={ + "asin": title.id, + "consumptionType": "Streaming", + "desiredResources": "Widevine2License", + "deviceTypeID": self.device["device_type"], + "deviceID": self.device_id, + "firmware": 1, + "gascEnabled": str(self.pv).lower(), + "marketplaceID": self.region["marketplace_id"], + "resourceUsage": "ImmediateConsumption", + "videoMaterialType": "Feature", + "operatingSystemName": "Linux" if any(q <= 576 for q in self.quality) else "Windows", + "operatingSystemVersion": "unknown" if any(q <= 576 for q in self.quality) else "10.0", + "customerID": self.customer_id, + "deviceDrmOverride": "CENC", + "deviceStreamingTechnologyOverride": "DASH", + "deviceVideoQualityOverride": "HD", + "deviceHdrFormatsOverride": "None", + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Bearer {self.device_token}", + }, + data={ + "widevine2Challenge": base64.b64encode(challenge).decode(), + "includeHdcpTestKeyInLicense": "false", + }, + ).json() + if "errorsByResource" in response: + error_code = response["errorsByResource"]["Widevine2License"] + if "errorCode" in error_code: + error_code = error_code["errorCode"] + elif "type" in error_code: + error_code = error_code["type"] + + if error_code in ["PRS.NoRights.AnonymizerIP", "PRS.NoRights.NotOwned"]: + self.log.error("Proxy detected, Unable to License") + elif error_code == "PRS.Dependency.DRM.Widevine.UnsupportedCdmVersion": + self.log.error("Cdm version not supported") + else: + self.log.error(f" x Error from Amazon's License Server: [{error_code}]") + sys.exit(1) + + return response["widevine2License"]["license"] + + def get_playready_license(self, challenge: Union[bytes, str], title: Title_T, **_): + lic_list = [] + lic_challenge = base64.b64encode(challenge).decode("utf-8") if isinstance(challenge, bytes) else base64.b64encode(challenge.encode("utf-8")).decode("utf-8") + self.log.debug(f"Challenge - {lic_challenge}") + params = { + "asin": title.id, + "consumptionType": "Streaming", # Streaming or Download both work + "desiredResources": "PlayReadyLicense", + "deviceTypeID": self.device["device_type"], + "deviceID": self.device_id, + "firmware": 1, + "gascEnabled": str(self.pv).lower(), + "marketplaceID": self.region["marketplace_id"], + "resourceUsage": "ImmediateConsumption", + "videoMaterialType": "Feature", + "operatingSystemName": "Windows", + "operatingSystemVersion": "10.0", + "customerID": self.customer_id, + "deviceDrmOverride": "CENC", #CENC or Playready both work + "deviceStreamingTechnologyOverride": "DASH", # or SmoothStreaming + "deviceVideoQualityOverride": self.vquality, + "deviceHdrFormatsOverride": self.VIDEO_RANGE_MAP.get(self.range, "None"), + } + headers = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Bearer {self.device_token}" + } + data = { + "playReadyChallenge": lic_challenge, # expects base64 + "includeHdcpTestKeyInLicense": "true" + } + lic = self.session.post( + url=self.endpoints["licence"], + params=params, + headers=headers, + data=data + ).json() + lic_list.append(lic) + # params["deviceStreamingTechnologyOverride"] = "SmoothStreaming" + params["deviceDrmOverride"] = "Playready" + lic = self.session.post( + url=self.endpoints["licence"], + params=params, + headers=headers, + data=data + ).json() + lic_list.append(lic) + + for lic in lic_list: + if "errorsByResource" in lic: + error_code = lic["errorsByResource"]["PlayReadyLicense"] + self.log.debug(error_code) + if "errorCode" in error_code: + error_code = error_code["errorCode"] + elif "type" in error_code: + error_code = error_code["type"] + if error_code == "PRS.NoRights.AnonymizerIP": + self.log.error(" - Amazon detected a Proxy/VPN and refused to return a license!") + continue + message = lic["errorsByResource"]["PlayReadyLicense"]["message"] + self.log.error(f" - Amazon reported an error during the License request: {message} [{error_code}]") + continue + elif "error" in lic: + error_code = lic["error"] + if "errorCode" in error_code: + error_code = error_code["errorCode"] + elif "type" in error_code: + error_code = error_code["type"] + if error_code == "PRS.NoRights.AnonymizerIP": + self.log.error(" - Amazon detected a Proxy/VPN and refused to return a license!") + continue + message = lic["error"]["message"] + self.log.error(f" - Amazon reported an error during the License request: {message} [{error_code}]") + continue + else: + xmrlic = base64.b64decode(lic["playReadyLicense"]["encodedLicenseResponse"].encode("utf-8")).decode("utf-8") + self.log.debug(xmrlic) + return xmrlic # Return Xml licence + + # Service specific functions + + def configure(self): + if len(self.title) > 10 and not (self.domain or "").startswith("amazon."): + self.pv = True + + self.log.info("Getting account region") + self.region = self.get_region() + if not self.region: + self.log.error(" - Failed to get Amazon account region") + sys.exit(1) + # self.GEOFENCE.append(self.region["code"]) + self.log.info(f" + Region: {self.region['code'].upper()}") + + # endpoints must be prepared AFTER region data is retrieved + self.endpoints = self.prepare_endpoints(self.config["endpoints"], self.region) + + self.session.headers.update({"Origin": f"https://{self.region['base']}"}) + + self.device = (self.config.get("device") or {}).get(self.profile, "default") + + if (int(self.quality[0]) > 1080 or self.range != "SDR" or self.atmos): + self.log.info(f"Using device to get UHD manifests") + self.register_device() + + elif not self.device or self.vquality != "UHD" or self.drm_system == "widevine": + # falling back to browser-based device ID + if not self.device: + self.log.warning( + "No Device information was provided for %s, using browser device...", + self.profile + ) + self.device_id = hashlib.sha224( + ("CustomerID" + self.session.headers["User-Agent"]).encode("utf-8") + ).hexdigest() + self.device = {"device_type": self.config["device_types"]["browser"]} + else: + self.register_device() + + def register_device(self) -> None: + self.device = (self.config.get("device") or {}).get(self.profile, "default") + device_cache_path = f"device_tokens_{self.profile}_{hashlib.md5(json.dumps(self.device).encode()).hexdigest()[0:6]}" + self.device_token = self.DeviceRegistration( + device=self.device, + endpoints=self.endpoints, + log=self.log, + cache_path=device_cache_path, + session=self.session + ).bearer + self.device_id = self.device.get("device_serial") + if not self.device_id: + raise self.log.error(f" - A device serial is required in the config, perhaps use: {os.urandom(8).hex()}") + + def get_region(self): + domain_region = self.get_domain_region() + if not domain_region: + return {} + + region = self.config["regions"].get(domain_region) + if not region: + raise self.log.error(f" - There's no region configuration data for the region: {domain_region}") + + region["code"] = domain_region + + if self.pv: + res = self.session.get("https://www.primevideo.com").text + match = re.search(r'ue_furl *= *([\'"])fls-(na|eu|fe)\.amazon\.[a-z.]+\1', res) + if match: + pv_region = match.group(2).lower() + else: + raise self.log.error(" - Failed to get PrimeVideo region") + pv_region = {"na": "atv-ps"}.get(pv_region, f"atv-ps-{pv_region}") + region["base_manifest"] = f"{pv_region}.primevideo.com" + region["base"] = "www.primevideo.com" + + return region + + def get_domain_region(self): + """Get the region of the cookies from the domain.""" + tld = (self.domain_region or "").split(".")[-1] + if not tld: + domains = [x.domain for x in self.session.cookies if x.domain_specified] + tld = next((x.split(".")[-1] for x in domains if x.startswith((".amazon.", ".primevideo."))), None) + return {"com": "us", "uk": "gb"}.get(tld, tld) + + def prepare_endpoint(self, name: str, uri: str, region: dict) -> str: + if name in ("browse", "playback", "licence", "xray"): + return f"https://{(region['base_manifest'])}{uri}" + if name in ("ontv", "ontvold", "mytv", "devicelink", "details", "getDetailWidgets"): + if self.pv: + host = "www.primevideo.com" + else: + host = region["base"] + return f"https://{host}{uri}" + if name in ("codepair", "register", "token"): + return f"https://{self.config['regions']['us']['base_api']}{uri}" + raise ValueError(f"Unknown endpoint: {name}") + + def prepare_endpoints(self, endpoints: dict, region: dict) -> dict: + return {k: self.prepare_endpoint(k, v, region) for k, v in endpoints.items()} + + def choose_manifest(self, manifest: dict, cdn=None): + """Get manifest URL for the title based on CDN weight (or specified CDN).""" + if cdn: + cdn = cdn.lower() + manifest = next((x for x in manifest["audioVideoUrls"]["avCdnUrlSets"] if x["cdn"].lower() == cdn), {}) + if not manifest: + raise self.log.exit(f" - There isn't any DASH manifests available on the CDN \"{cdn}\" for this title") + else: + manifest = next((x for x in sorted([x for x in manifest["audioVideoUrls"]["avCdnUrlSets"]], key=lambda x: int(x["cdnWeightsRank"]))), {}) + + return manifest + + def get_manifest( + self, title, video_codec: str, bitrate_mode: str, quality: str, hdr=None, + ignore_errors: bool = False + ) -> dict: + res = self.session.get( + url=self.endpoints["playback"], + params={ + "asin": title.id, + "consumptionType": "Streaming", + "desiredResources": ",".join([ + "PlaybackUrls", + "AudioVideoUrls", + "CatalogMetadata", + "ForcedNarratives", + "SubtitlePresets", + "SubtitleUrls", + "TransitionTimecodes", + "TrickplayUrls", + "CuepointPlaylist", + "XRayMetadata", + "PlaybackSettings", + ]), + "deviceID": self.device_id, + "deviceTypeID": self.device["device_type"], + "firmware": 1, + "gascEnabled": str(self.pv).lower(), + "marketplaceID": self.region["marketplace_id"], + "resourceUsage": "CacheResources", + "videoMaterialType": "Feature", + "playerType": "html5", + "clientId": self.client_id, + **({ + "operatingSystemName": "Linux" if quality == "SD" else "Windows", + "operatingSystemVersion": "unknown" if quality == "SD" else "10.0", + } if not self.device_token else {}), + "deviceDrmOverride": "CENC", + "deviceStreamingTechnologyOverride": "DASH", + "deviceProtocolOverride": "Https", + "deviceVideoCodecOverride": video_codec, + "deviceBitrateAdaptationsOverride": bitrate_mode.replace("+", ","), + "deviceVideoQualityOverride": quality, + "deviceHdrFormatsOverride": self.VIDEO_RANGE_MAP.get(hdr, "None"), + "supportedDRMKeyScheme": "DUAL_KEY", # ? + "liveManifestType": "live,accumulating", # ? + "titleDecorationScheme": "primary-content", + "subtitleFormat": "TTMLv2", + "languageFeature": "MLFv2", # ? + "uxLocale": "en_US", + "xrayDeviceClass": "normal", + "xrayPlaybackMode": "playback", + "xrayToken": "XRAY_WEB_2020_V1", + "playbackSettingsFormatVersion": "1.0.0", + "playerAttributes": json.dumps({"frameRate": "HFR"}), + # possibly old/unused/does nothing: + "audioTrackId": "all", + }, + headers={ + "Authorization": f"Bearer {self.device_token}" if self.device_token else None, + }, + ) + try: + manifest = res.json() + except json.JSONDecodeError: + if ignore_errors: + return {} + + raise self.log.exit(" - Amazon didn't return JSON data when obtaining the Playback Manifest.") + + if "error" in manifest: + if ignore_errors: + return {} + raise self.log.exit(" - Amazon reported an error when obtaining the Playback Manifest.") + + # Commented out as we move the rights exception check elsewhere + # if "rightsException" in manifest["returnedTitleRendition"]["selectedEntitlement"]: + # if ignore_errors: + # return {} + # raise self.log.exit(" - The profile used does not have the rights to this title.") + + # Below checks ignore NoRights errors + + if ( + manifest.get("errorsByResource", {}).get("PlaybackUrls") and + manifest["errorsByResource"]["PlaybackUrls"].get("errorCode") != "PRS.NoRights.NotOwned" + ): + if ignore_errors: + return {} + error = manifest["errorsByResource"]["PlaybackUrls"] + raise self.log.exit(f" - Amazon had an error with the Playback Urls: {error['message']} [{error['errorCode']}]") + + if ( + manifest.get("errorsByResource", {}).get("AudioVideoUrls") and + manifest["errorsByResource"]["AudioVideoUrls"].get("errorCode") != "PRS.NoRights.NotOwned" + ): + if ignore_errors: + return {} + error = manifest["errorsByResource"]["AudioVideoUrls"] + raise self.log.exit(f" - Amazon had an error with the A/V Urls: {error['message']} [{error['errorCode']}]") + + return manifest + + @staticmethod + def get_original_language(manifest): + """Get a title's original language from manifest data.""" + try: + return next( + x["language"].replace("_", "-") + for x in manifest["catalogMetadata"]["playback"]["audioTracks"] + if x["isOriginalLanguage"] + ) + except (KeyError, StopIteration): + pass + + if "defaultAudioTrackId" in manifest.get("playbackUrls", {}): + try: + return manifest["playbackUrls"]["defaultAudioTrackId"].split("_")[0] + except IndexError: + pass + + try: + return sorted( + manifest["audioVideoUrls"]["audioTrackMetadata"], + key=lambda x: x["index"] + )[0]["languageCode"] + except (KeyError, IndexError): + pass + + return None + + @staticmethod + def clean_mpd_url(mpd_url, optimise=True): + print(f"MPD URL: {mpd_url}, optimise: {optimise}") + """Clean up an Amazon MPD manifest url.""" + if 'akamaihd.net' in mpd_url: + match = re.search(r'[^/]*\$[^/]*/', mpd_url) + if match: + dollar_sign_part = match.group(0) + mpd_url = mpd_url.replace(dollar_sign_part, '', 1) + return mpd_url + + if optimise: + return mpd_url.replace("~", "") + "?encoding=segmentBase" + else: + if match := re.match(r"(https?://.*/)d.?/.*~/(.*)", mpd_url): + print(f"returned: {''.join(match.groups())}") + return "".join(match.groups()) + elif match := re.match(r"(https?://.*/)d.?/.*\$.*?/(.*)", mpd_url): + print(f"returned: {''.join(match.groups())}") + return "".join(match.groups()) + elif match := re.match(r"(https?://.*/).*\$.*?/(.*)", mpd_url): + print(f"returned: {''.join(match.groups())}") + return "".join(match.groups()) + raise ValueError("Unable to parse MPD URL") + + def parse_title(self, ctx, title): + title = title or ctx.parent.params.get("title") + if not title: + self.log.error(" - No title ID specified") + if not getattr(self, "TITLE_RE"): + self.title = title + return {} + for regex in as_list(self.TITLE_RE): + m = re.search(regex, title) + if m: + self.title = m.group("id") + return m.groupdict() + self.log.warning(f" - Unable to parse title ID {title!r}, using as-is") + self.title = title + + # Service specific classes + + class DeviceRegistration: + def __init__(self, device: dict, endpoints: dict, cache_path: str, session: requests.Session, log: Logger): + self.session = session + self.device = device + self.endpoints = endpoints + self.cache_path = cache_path + self.log = log + self.cache = Cacher('AMZN') + # self.device = {k: str(v) if not isinstance(v, str) else v for k, v in self.device.items()} + + self.bearer = None + + self._cache = self.cache.get(self.cache_path) + if self._cache: + if self._cache.data.get("expires_in", 0) > int(time.time()): + self.log.info(" + Using cached device bearer") + self.bearer = self._cache["access_token"] + else: + self.log.info("Refreshing cached device bearer...") + refreshed_tokens = self.refresh(self.device, self._cache.data["refresh_token"], self._cache.data["access_token"]) + refreshed_tokens["refresh_token"] = self._cache.data["refresh_token"] + # expires_in seems to be in minutes, create a unix timestamp and add the minutes in seconds + refreshed_tokens["expires_in"] = int(time.time()) + int(refreshed_tokens["expires_in"]) + self._cache.data = refreshed_tokens + self.bearer = refreshed_tokens["access_token"] + else: + self.log.info(" + Registering new device bearer") + self.bearer = self.register(self.device) + + def register(self, device: dict) -> dict: + """ + Register device to the account + :param device: Device data to register + :return: Device bearer tokens + """ + # OnTV csrf + csrf_token, referer = self.get_csrf_token() + + # Code pair + code_pair = self.get_code_pair(device) + + # Device link + response = self.session.post( + url=self.endpoints["devicelink"], + headers={ + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9,es-US;q=0.8,es;q=0.7", # needed? + "Content-Type": "application/x-www-form-urlencoded", + "Referer": referer + }, + params=urlencode({ + # any reason it urlencodes here? requests can take a param dict... + "ref_": "atv_set_rd_reg", + "publicCode": code_pair["public_code"], # public code pair + "token": csrf_token # csrf token + }) + ) + if response.status_code != 200: + raise self.log.error(f"Unexpected response with the codeBasedLinking request: {response.text} [{response.status_code}]") + + # Register + response = self.session.post( + url=self.endpoints["register"], + headers={ + "Content-Type": "application/json", + "Accept-Language": "en-US", + }, + json={ + "auth_data": { + "code_pair": code_pair + }, + "registration_data": device, + "requested_token_type": ["bearer"], + "requested_extensions": ["device_info", "customer_info"] + }, + cookies=None # for some reason, may fail if cookies are present. Odd. + ) + if response.status_code != 200: + raise self.log.error(f"Unable to register: {response.text} [{response.status_code}]") + bearer = response.json()["response"]["success"]["tokens"]["bearer"] + bearer["expires_in"] = int(time.time()) + int(bearer["expires_in"]) + + # Cache bearer + self._cache.set(bearer) + # os.makedirs(os.path.dirname(self.cache_path), exist_ok=True) + # with open(self.cache_path, "w", encoding="utf-8") as fd: + # fd.write(jsonpickle.encode(bearer)) + + return bearer["access_token"] + + def refresh(self, device: dict, refresh_token: str, access_token: str) -> dict: + # using the refresh token get the cookies needed for making calls to *.amazon.com + response = requests.post( + url=self.endpoints["token"], + headers={ + 'User-Agent': 'AmazonWebView/Amazon Alexa/2.2.223830.0/iOS/11.4.1/iPhone', # https://gitlab.com/keatontaylor/alexapy/-/commit/540b6333d973177bbc98e6ef39b00134f80ef0bb + 'Accept-Language': 'en-US', + 'Accept-Charset': 'utf-8', + 'Connection': 'keep-alive', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': '*/*' + }, + cookies={ + 'at-main': access_token, + }, + data={ + **device, + 'domain': '.' + self.endpoints["token"].split("/")[-3], + 'source_token': str(refresh_token), + 'requested_token_type': 'auth_cookies', + 'source_token_type': 'refresh_token', + } + ) + response_json = response.json() + cookies = {} + self.log.debug(response_json) + if response.status_code == 200: + # Extract the cookies from the response + raw_cookies = response_json['response']['tokens']['cookies']['.amazon.com'] + for cookie in raw_cookies: + cookies[cookie['Name']] = cookie['Value'] + else: + error = response_json['response']["error"] + self.cache_path.unlink(missing_ok=True) + raise self.log.error(f"Error when refreshing cookies: {error['message']} [{error['code']}]") + + response = requests.post( + url=self.endpoints["token"], + headers={ + 'Content-Type': 'application/json; charset=utf-8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Accept': 'application/json; charset=utf-8', + 'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + 'Accept-Language': 'en-US,en-US;q=1.0', + 'x-amzn-identity-auth-domain': self.endpoints["token"].split("/")[-3], + 'x-amzn-requestid': str(uuid4()).replace('-', '') + }, + json={ + **device, + 'requested_token_type': 'access_token', + 'source_token_type': 'refresh_token', + 'source_token': str(refresh_token), + }, # https://github.com/Sandmann79/xbmc/blob/dab17d913ee877d96115e6f799623bca158f3f24/plugin.video.amazon-test/resources/lib/login.py#L593 + cookies=cookies + ) + response_json = response.json() + + if response.status_code != 200 or "error" in response_json: + self.cache_path.unlink(missing_ok=True) # Remove the cached device as its tokens have expired + raise self.log.error(f"Failed to refresh device token -> {response_json['error_description']} [{response_json['error']}]") + self.log.debug(response_json) + if response_json["token_type"] != "bearer": + raise self.log.error("Unexpected returned refreshed token type") + + return response_json + + def get_csrf_token(self) -> str: + """ + On the amazon website, you need a token that is in the html page, + this token is used to register the device + :return: OnTV Page's CSRF Token + """ + try: + res = self.session.get(self.endpoints["ontv"]) + response = res.text + if 'input type="hidden" name="appAction" value="SIGNIN"' in response: + raise self.log.error( + "Cookies are signed out, cannot get ontv CSRF token. " + f"Expecting profile to have cookies for: {self.endpoints['ontv']}" + ) + for match in re.finditer(r"", response): + prop = json.loads(match.group(1)) + prop = prop.get("props", {}).get("codeEntry", {}).get("token") + if prop: + return prop, self.endpoints["ontv"] + raise self.log.error(f"Unable to get ontv CSRF token - Navigate to {self.endpoints['mytv']}, login and save cookies from code pair page to default.txt") + except: + res = self.session.get(self.endpoints["ontvold"]) + response = res.text + if 'input type="hidden" name="appAction" value="SIGNIN"' in response: + raise self.log.error( + "Cookies are signed out, cannot get ontv CSRF token. " + f"Expecting profile to have cookies for: {self.endpoints['ontvold']}" + ) + for match in re.finditer(r"", response): + prop = json.loads(match.group(1)) + prop = prop.get("props", {}).get("codeEntry", {}).get("token") + if prop: + return prop, self.endpoints["ontvold"] + raise self.log.error(f"Unable to get ontv CSRF token - Navigate to {self.endpoints['mytv']}, login and save cookies from code pair page to default.txt") + + def get_code_pair(self, device: dict) -> dict: + """ + Getting code pairs based on the device that you are using + :return: public and private code pairs + """ + res = self.session.post( + url=self.endpoints["codepair"], + headers={ + "Content-Type": "application/json", + "Accept-Language": "en-US", + }, + json={"code_data": device} + ).json() + if "error" in res: + raise self.log.error(f"Unable to get code pair: {res['error_description']} [{res['error']}]") + return res diff --git a/AMZN/__pycache__/__init__.cpython-310.pyc b/AMZN/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b84f6b430d33f5901e1ee87c0bff722d6db1eb4 GIT binary patch literal 34255 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 JpqzE;{{T2pumc\.cmc\.[a-z0-9]+)" # noqa: E501 + ) + + VIDEO_CODEC_MAP = {"H264": ["avc"], "H265": ["hvc", "hev", "dvh"]} + AUDIO_CODEC_MAP = {"AAC": ["HE", "stereo"], "AC3": ["ac3"], "EC3": ["ec3", "atmos"]} + + @staticmethod + @click.command(name="ATVP", short_help="https://tv.apple.com") + @click.argument("title", type=str, required=False) + @click.pass_context + def cli(ctx, **kwargs): + return ATVP(ctx, **kwargs) + + def __init__(self, ctx, title): + super().__init__(ctx) + self.title = title + self.cdm = ctx.obj.cdm + if not isinstance(self.cdm, PlayReadyCdm): + self.log.warning("PlayReady CDM not provided, exiting") + raise SystemExit(1) + self.vcodec = ctx.parent.params["vcodec"] + self.acodec = ctx.parent.params["acodec"] + self.alang = ctx.parent.params["lang"] + self.subs_only = ctx.parent.params["subs_only"] + self.quality = ctx.parent.params["quality"] + + self.extra_server_parameters = None + # initialize storefront with a default value. + self.storefront = 'us' # or any default value + + def get_titles(self): + self.configure() + r = None + for i in range(2): + try: + self.params = { + "utsk": "6e3013c6d6fae3c2::::::9318c17fb39d6b9c", + "caller": "web", + "sf": self.storefront, + "v": "46", + "pfm": "appletv", + "mfr": "Apple", + "locale": "en-US", + "l": "en", + "ctx_brand": "tvs.sbd.4000", + "count": "100", + "skip": "0", + } + r = self.session.get( + url=self.config["endpoints"]["title"].format(type={0: "shows", 1: "movies"}[i], id=self.title), + params=self.params, + ) + 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}") + + if title_information["type"] == "Movie": + movie = Movie( + id_=self.title, + service=self.__class__, + name=title_information["title"], + year=datetime.fromtimestamp(title_information["releaseDate"] / 1000).year, + language=title_information["originalSpokenLanguages"][0]["locale"], + data=title_information, + ) + return Movies([movie]) + else: + r = self.session.get( + url=self.config["endpoints"]["tv_episodes"].format(id=self.title), + params=self.params, + ) + try: + episodes = r.json()["data"]["episodes"] + except json.JSONDecodeError: + raise ValueError(f"Failed to load episodes list: {r.text}") + + episodes_list = [ + Episode( + id_=episode["id"], + service=self.__class__, + title=episode["showTitle"], + season=episode["seasonNumber"], + number=episode["episodeNumber"], + name=episode.get("title"), + year=datetime.fromtimestamp(title_information["releaseDate"] / 1000).year, + language=title_information["originalSpokenLanguages"][0]["locale"], + data={**episode, "originalSpokenLanguages": title_information["originalSpokenLanguages"]}, + ) + for episode in episodes + ] + return Series(episodes_list) + + def get_tracks(self, title): + # call configure() before using self.storefront + self.configure() + + self.params = { + "utsk": "6e3013c6d6fae3c2::::::9318c17fb39d6b9c", + "caller": "web", + "sf": self.storefront, + "v": "46", + "pfm": "appletv", + "mfr": "Apple", + "locale": "en-US", + "l": "en", + "ctx_brand": "tvs.sbd.4000", + "count": "100", + "skip": "0", + } + r = self.session.get( + url=self.config["endpoints"]["manifest"].format(id=title.data["id"]), + params=self.params, + ) + try: + stream_data = r.json() + except json.JSONDecodeError: + raise ValueError(f"Failed to load stream data: {r.text}") + stream_data = stream_data["data"]["content"]["playables"][0] + + if not stream_data["isEntitledToPlay"]: + raise self.log.exit(" - User is not entitled to play this title") + + self.extra_server_parameters = stream_data["assets"]["fpsKeyServerQueryParameters"] + r = requests.get( + url=stream_data["assets"]["hlsUrl"], + headers={"User-Agent": "AppleTV6,2/11.1"}, + ) + res = r.text + + master = m3u8.loads(res, r.url) + tracks = m3u8_parser.parse( + master=master, + language=title.data["originalSpokenLanguages"][0]["locale"] or "en", + session=self.session, + ) + + # Set track properties based on type + for track in tracks: + if isinstance(track, Video): + # Convert codec string to proper Video.Codec enum if needed + if isinstance(track.codec, str): + codec_str = track.codec.lower() + if codec_str in ["avc", "h264", "h.264"]: + track.codec = Video.Codec.AVC + elif codec_str in ["hvc", "hev", "hevc", "h265", "h.265", "dvh"]: + track.codec = Video.Codec.HEVC + else: + print(f"Unknown video codec '{track.codec}', keeping as string") + + # Set pr_pssh for PlayReady license requests + if track.drm: + for drm in track.drm: + if hasattr(drm, 'data') and 'pssh_b64' in drm.data: + track.pr_pssh = drm.data['pssh_b64'] + elif isinstance(track, Audio): + # Extract bitrate from URL + bitrate = re.search(r"&g=(\d+?)&", track.url) + if not bitrate: + bitrate = re.search(r"_gr(\d+)_", track.url) # alternative pattern + if bitrate: + track.bitrate = int(bitrate.group(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}") + codec_str = track.codec.replace("_vod", "") if track.codec else "" + if codec_str == "DD+": + track.codec = Audio.Codec.EC3 + elif codec_str == "DD": + track.codec = Audio.Codec.AC3 + elif codec_str in ["HE", "stereo", "AAC"]: + track.codec = Audio.Codec.AAC + elif codec_str == "atmos": + track.codec = Audio.Codec.EC3 + else: + if not hasattr(track.codec, "value"): + print(f"Unknown audio codec '{codec_str}', defaulting to AAC") + track.codec = Audio.Codec.AAC + + # Set pr_pssh for PlayReady license requests + if track.drm: + for drm in track.drm: + if hasattr(drm, 'data') and 'pssh_b64' in drm.data: + track.pr_pssh = drm.data['pssh_b64'] + elif isinstance(track, Subtitle): + codec_str = track.codec if track.codec else "" + if codec_str.lower() in ["vtt", "webvtt"]: + track.codec = Subtitle.Codec.WebVTT + elif codec_str.lower() in ["srt", "subrip"]: + track.codec = Subtitle.Codec.SubRip + elif codec_str.lower() in ["ttml", "dfxp"]: + track.codec = Subtitle.Codec.TimedTextMarkupLang + elif codec_str.lower() in ["ass", "ssa"]: + track.codec = Subtitle.Codec.SubStationAlphav4 + else: + if not hasattr(track.codec, "value"): + print(f"Unknown subtitle codec '{codec_str}', defaulting to WebVTT") + track.codec = Subtitle.Codec.WebVTT + + # Set pr_pssh for PlayReady license requests + if track.drm: + for drm in track.drm: + if hasattr(drm, 'data') and 'pssh_b64' in drm.data: + track.pr_pssh = drm.data['pssh_b64'] + + # Try to filter by CDN, but fallback to all tracks if filtering fails + try: + filtered_tracks = [ + x + for x in tracks + if any( + param.startswith("cdn=vod-ap") or param == "cdn=ap" + for param in as_list(x.url)[0].split("?")[1].split("&") + ) + ] + + for track in tracks: + if track not in tracks.attachments: + track.downloader = n_m3u8dl_re + if isinstance(track, (Video, Audio)): + track.needs_repack = True + + if filtered_tracks: + return Tracks(filtered_tracks) + else: + return Tracks(tracks) + + except Exception: + return Tracks(tracks) + + def get_chapters(self, title): + return [] + + def certificate(self, **_): + return None # will use common privacy cert + + def get_pssh(self, track) -> None: + res = self.session.get(as_list(track.url)[0]) + playlist = m3u8.loads(res.text, uri=res.url) + keys = list(filter(None, (playlist.session_keys or []) + (playlist.keys or []))) + for key in keys: + if key.keyformat and "playready" in key.keyformat.lower(): + track.pr_pssh = key.uri.split(",")[-1] + return + + def get_playready_license(self, *, challenge: bytes, title, track) -> str: + if isinstance(challenge, str): + challenge = challenge.encode() + + self.get_pssh(track) + + res = self.session.post( + url=self.config["endpoints"]["license"], + json={ + "streaming-request": { + "version": 1, + "streaming-keys": [ + { + "challenge": base64.b64encode(challenge).decode("utf-8"), + "key-system": "com.microsoft.playready", + "uri": f"data:text/plain;charset=UTF-16;base64,{track.pr_pssh}", + "id": 0, + "lease-action": "start", + "adamId": self.extra_server_parameters["adamId"], + "isExternal": True, + "svcId": self.extra_server_parameters["svcId"], + }, + ], + }, + }, + ).json() + return res["streaming-response"]["streaming-keys"][0]["license"] + + # Service specific functions + + def configure(self): + cc = self.session.cookies.get_dict()["itua"] + r = self.session.get( + "https://gist.githubusercontent.com/BrychanOdlum/2208578ba151d1d7c4edeeda15b4e9b1/raw/8f01e4a4cb02cf97a48aba4665286b0e8de14b8e/storefrontmappings.json" + ).json() + for g in r: + if g["code"] == cc: + self.storefront = g["storefrontId"] + + 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['developerToken']}", + "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 serialized server data.""" + res = self.session.get("https://tv.apple.com").text + + script_match = re.search( + r']*id=["\']serialized-server-data["\'][^>]*>(.*?)', + res, + re.DOTALL, + ) + if script_match: + try: + script_content = script_match.group(1).strip() + data = json.loads(script_content) + if data and len(data) > 0 and "data" in data[0] and "configureParams" in data[0]["data"]: + return data[0]["data"]["configureParams"] + except (json.JSONDecodeError, KeyError, IndexError) as e: + print(f"Failed to parse serialized server data: {e}") + + return None diff --git a/ATVP/__pycache__/__init__.cpython-310.pyc b/ATVP/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d99dee87284e91361106d63e8a21efdc0646f4f3 GIT binary patch literal 10127 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 Y3(qss>VpFUG02{T9Y>a2Qti_JAC(S#6#xJL literal 0 HcmV?d00001 diff --git a/ATVP/__pycache__/__init__.cpython-312.pyc b/ATVP/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7010a7abaf4c64b5fbe0c7cb5cdd7134afea1372 GIT binary patch literal 17892 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 D;75Hx literal 0 HcmV?d00001 diff --git a/ATVP/config.yaml b/ATVP/config.yaml new file mode 100644 index 0000000..cc4bdd9 --- /dev/null +++ b/ATVP/config.yaml @@ -0,0 +1,38 @@ +user_agent: 'ATVE/6.2.0 Android/10 build/6A226 maker/Google model/Chromecast FW/QTS2.200918.0337115981' +storefront_mapping_url: 'https://gist.githubusercontent.com/BrychanOdlum/2208578ba151d1d7c4edeeda15b4e9b1/raw/8f01e4a4cb02cf97a48aba4665286b0e8de14b8e/storefrontmappings.json' + +endpoints: + title: 'https://tv.apple.com/api/uts/v3/{type}/{id}' + tv_episodes: 'https://tv.apple.com/api/uts/v2/view/show/{id}/episodes' + manifest: 'https://tv.apple.com/api/uts/v2/view/product/{id}/personalized' + license: 'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/fpsRequest' + environment: 'https://tv.apple.com' + +params: + utsk: '6e3013c6d6fae3c2::::::9318c17fb39d6b9c' + caller: 'web' + v: '46' + pfm: 'appletv' + mfr: 'Apple' + locale: 'en-US' + l: 'en' + ctx_brand: 'tvs.sbd.4000' + count: '100' + skip: '0' + +headers: + Accept: 'application/json' + Accept-Language: 'en-US,en;q=0.9' + Connection: 'keep-alive' + DNT: '1' + Origin: 'https://tv.apple.com' + Referer: 'https://tv.apple.com/' + Sec-Fetch-Dest: 'empty' + Sec-Fetch-Mode: 'cors' + Sec-Fetch-Site: 'same-origin' + +quality_map: + SD: 480 + HD720: 720 + HD: 1080 + UHD: 2160 \ No newline at end of file diff --git a/AUBC/__init__.py b/AUBC/__init__.py new file mode 100644 index 0000000..da73806 --- /dev/null +++ b/AUBC/__init__.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Generator +from typing import Any, Optional, Union +from urllib.parse import urljoin + +import click +from click import Context +from requests import Request +from unshackle.core.constants import AnyTrack +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks + + +class AUBC(Service): + """ + \b + Service code for ABC iView streaming service (https://iview.abc.net.au/). + + \b + Version: 1.0.3 + Author: stabbedbybrick + Authorization: None + Robustness: + L3: 1080p, AAC2.0 + + \b + Tips: + - Input should be complete URL: + SHOW: https://iview.abc.net.au/show/return-to-paradise + EPISODE: https://iview.abc.net.au/video/DR2314H001S00 + MOVIE: https://iview.abc.net.au/show/way-back / https://iview.abc.net.au/show/way-back/video/ZW3981A001S00 + + """ + + GEOFENCE = ("au",) + ALIASES = ("iview", "abciview", "iv",) + + @staticmethod + @click.command(name="AUBC", short_help="https://iview.abc.net.au/", help=__doc__) + @click.argument("title", type=str) + @click.pass_context + def cli(ctx: Context, **kwargs: Any) -> AUBC: + return AUBC(ctx, **kwargs) + + def __init__(self, ctx: Context, title: str): + self.title = title + super().__init__(ctx) + + self.session.headers.update(self.config["headers"]) + + def search(self) -> Generator[SearchResult, None, None]: + url = ( + "https://y63q32nvdl-1.algolianet.com/1/indexes/*/queries?x-algolia-agent=Algolia" + "%20for%20JavaScript%20(4.9.1)%3B%20Browser%20(lite)%3B%20react%20(17.0.2)%3B%20" + "react-instantsearch%20(6.30.2)%3B%20JS%20Helper%20(3.10.0)&x-" + "algolia-api-key=bcdf11ba901b780dc3c0a3ca677fbefc&x-algolia-application-id=Y63Q32NVDL" + ) + payload = { + "requests": [ + { + "indexName": "ABC_production_iview_web", + "params": f"query={self.title}&tagFilters=&userToken=anonymous-74be3cf1-1dc7-4fa1-9cff-19592162db1c", + } + ], + } + + results = self._request("POST", url, payload=payload)["results"] + hits = [x for x in results[0]["hits"] if x["docType"] == "Program"] + + for result in hits: + yield SearchResult( + id_="https://iview.abc.net.au/show/{}".format(result.get("slug")), + title=result.get("title"), + description=result.get("synopsis"), + label=result.get("subType"), + url="https://iview.abc.net.au/show/{}".format(result.get("slug")), + ) + + def get_titles(self) -> Union[Movies, Series]: + title_re = r"^(?:https?://(?:www.)?iview.abc.net.au/(?Pshow|video)/)?(?P[a-zA-Z0-9_-]+)" + try: + kind, title_id = (re.match(title_re, self.title).group(i) for i in ("type", "id")) + except Exception: + raise ValueError("- Could not parse ID from title") + + if kind == "show": + data = self._request("GET", "/v3/show/{}".format(title_id)) + label = data.get("type") + + if label.lower() in ("series", "program"): + episodes = self._series(title_id) + return Series(episodes) + + elif label.lower() in ("feature", "movie"): + movie = self._movie(data) + return Movies(movie) + + elif kind == "video": + episode = self._episode(title_id) + return Series([episode]) + + def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: + video = self._request("GET", "/v3/video/{}".format(title.id)) + if not video.get("playable"): + raise ConnectionError(video.get("unavailableMessage")) + + playlist = video.get("_embedded", {}).get("playlist", {}) + if not playlist: + raise ConnectionError("Could not find a playlist for this title") + + streams = next(x["streams"]["mpegdash"] for x in playlist if x["type"] == "program") + captions = next((x.get("captions") for x in playlist if x["type"] == "program"), None) + title.data["protected"] = streams.get("protected", False) + + if "720" in streams: + streams["1080"] = streams["720"].replace("720", "1080") + + manifest = next( + (url for key in ["1080", "720", "sd", "sd-low"] if key in streams + for url in [streams[key]] + if self.session.head(url).status_code == 200), + None + ) + if not manifest: + raise ValueError("Could not find a manifest for this title") + + tracks = DASH.from_url(manifest, self.session).to_tracks(title.language) + + for track in tracks.audio: + role = track.data["dash"]["adaptation_set"].find("Role") + if role is not None and role.get("value") in ["description", "alternative", "alternate"]: + track.descriptive = True + + if captions: + subtitles = captions.get("src-vtt") + tracks.add( + Subtitle( + id_=hashlib.md5(subtitles.encode()).hexdigest()[0:6], + url=subtitles, + codec=Subtitle.Codec.from_mime(subtitles[-3:]), + language=title.language, + forced=False, + ) + ) + + return tracks + + def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: + if not title.data.get("cuePoints"): + return Chapters() + + credits = next((x.get("start") for x in title.data["cuePoints"] if x["type"] == "end-credits"), None) + if credits: + return Chapters([Chapter(name="Credits", timestamp=credits * 1000)]) + + return Chapters() + + def get_widevine_service_certificate(self, **_: Any) -> str: + return None + + def get_widevine_license(self, *, challenge: bytes, title: Union[Movies, Series], track: AnyTrack) -> Optional[Union[bytes, str]]: + if not title.data.get("protected"): + return None + + customdata = self._license(title.id) + headers = {"customdata": customdata} + + r = self.session.post(self.config["endpoints"]["license"], headers=headers, data=challenge) + r.raise_for_status() + return r.content + + # Service specific + + def _series(self, title: str) -> Episode: + data = self._request("GET", "/v3/series/{}".format(title)) + + seasons = data if isinstance(data, list) else [data] + + episodes = [ + self.create_episode(episode) + for season in seasons + for episode in season.get("_embedded", {}).get("videoEpisodes", {}).get("items", []) + ] + + return Series(episodes) + + def _movie(self, data: dict) -> Movie: + return [ + Movie( + id_=data["_embedded"]["highlightVideo"]["id"], + service=self.__class__, + name=data.get("title"), + year=data.get("productionYear"), + data=data, + language=data.get("analytics", {}).get("dataLayer", {}).get("d_language", "en"), + ) + ] + + def _episode(self, video_id: str) -> Episode: + data = self._request("GET", "/v3/video/{}".format(video_id)) + return self.create_episode(data) + + def _license(self, video_id: str): + token = self._request("POST", "/v3/token/jwt", data={"clientId": self.config["client"]})["token"] + response = self._request("GET", "/v3/token/drm/{}".format(video_id), headers={"bearer": token}) + + return response["license"] + + def create_episode(self, episode: dict) -> Episode: + title = episode["showTitle"] + episode_id = episode.get("id", "") + series_id = episode.get("analytics", {}).get("dataLayer", {}).get("d_series_id", "") + episode_name = episode.get("analytics", {}).get("dataLayer", {}).get("d_episode_name", "") + episode_number = re.search(r"Episode (\d+)", episode.get("displaySubtitle", "")) + name = re.search(r"S\d+\sEpisode\s\d+\s(.*)", episode_name) + language = episode.get("analytics", {}).get("dataLayer", {}).get("d_language", "en") + + season = int(series_id.split("-")[-1]) if series_id else 0 + number = int(episode_number.group(1)) if episode_number else 0 + + if not number: + if match := re.search(r"[A-Z](\d{3})(?=S\d{2})", episode_id): + number = int(match.group(1)) + + return Episode( + id_=episode["id"], + service=self.__class__, + title=title, + season=season, + number=number, + name=name.group(1) if name else episode_name, + data=episode, + language=language, + ) + + def _request(self, method: str, api: str, **kwargs: Any) -> Any[dict | str]: + url = urljoin(self.config["endpoints"]["base_url"], api) + + prep = self.session.prepare_request(Request(method, url, **kwargs)) + + response = self.session.send(prep) + if response.status_code != 200: + raise ConnectionError(f"{response.text}") + + try: + return json.loads(response.content) + + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse JSON: {response.text}") from e + diff --git a/AUBC/config.yaml b/AUBC/config.yaml new file mode 100644 index 0000000..604b739 --- /dev/null +++ b/AUBC/config.yaml @@ -0,0 +1,9 @@ +headers: + User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0 + accept-language: en-US,en;q=0.8 + +endpoints: + base_url: https://api.iview.abc.net.au + license: https://wv-keyos.licensekeyserver.com/ + +client: "1d4b5cba-42d2-403e-80e7-34565cdf772d" \ No newline at end of file diff --git a/CBC/__init__.py b/CBC/__init__.py new file mode 100644 index 0000000..c3ab426 --- /dev/null +++ b/CBC/__init__.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Generator +from http.cookiejar import CookieJar +from typing import Any, Optional, Union +from urllib.parse import urljoin + +import click +from click import Context +from requests import Request +from unshackle.core.constants import AnyTrack +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH, HLS +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks + + +class CBC(Service): + """ + \b + Service code for CBC Gem streaming service (https://gem.cbc.ca/). + + \b + Version: 1.0.1 + Author: stabbedbybrick + Authorization: Credentials + Robustness: + AES-128: 1080p, DDP5.1 + Widevine L3: 720p, DDP5.1 + + \b + Tips: + - Input can be complete title URL or just the slug: + SHOW: https://gem.cbc.ca/murdoch-mysteries OR murdoch-mysteries + MOVIE: https://gem.cbc.ca/the-babadook OR the-babadook + + \b + Notes: + - DRM encrypted titles max out at 720p. + - CCExtrator v0.94 will likely fail to extract subtitles. It's recommended to downgrade to v0.93. + - Some audio tracks contain invalid data, causing warning messages from mkvmerge during muxing + These can be ignored. + + """ + + GEOFENCE = ("ca",) + ALIASES = ("gem", "cbcgem",) + + @staticmethod + @click.command(name="CBC", short_help="https://gem.cbc.ca/", help=__doc__) + @click.argument("title", type=str) + @click.pass_context + def cli(ctx: Context, **kwargs: Any) -> CBC: + return CBC(ctx, **kwargs) + + def __init__(self, ctx: Context, title: str): + self.title: str = title + super().__init__(ctx) + + self.base_url: str = self.config["endpoints"]["base_url"] + + def search(self) -> Generator[SearchResult, None, None]: + params = { + "device": "web", + "pageNumber": "1", + "pageSize": "20", + "term": self.title, + } + response: dict = self._request("GET", "/ott/catalog/v1/gem/search", params=params) + + for result in response.get("result", []): + yield SearchResult( + id_="https://gem.cbc.ca/{}".format(result.get("url")), + title=result.get("title"), + description=result.get("synopsis"), + label=result.get("type"), + url="https://gem.cbc.ca/{}".format(result.get("url")), + ) + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + if not credential: + raise EnvironmentError("Service requires Credentials for Authentication.") + + tokens: Optional[Any] = self.cache.get(f"tokens_{credential.sha1}") + + """ + All grant types for future reference: + PASSWORD("password"), + ACCESS_TOKEN("access_token"), + REFRESH_TOKEN("refresh_token"), + CLIENT_CREDENTIALS("client_credentials"), + AUTHORIZATION_CODE("authorization_code"), + CODE("code"); + """ + + if tokens and not tokens.expired: + # cached + self.log.info(" + Using cached tokens") + auth_token: str = tokens.data["access_token"] + + elif tokens and tokens.expired: + # expired, refresh + self.log.info("Refreshing cached tokens...") + auth_url, scopes = self.settings() + params = { + "client_id": self.config["client"]["id"], + "grant_type": "refresh_token", + "refresh_token": tokens.data["refresh_token"], + "scope": scopes, + } + + access: dict = self._request("POST", auth_url, params=params) + + # Shorten expiration by one hour to account for clock skew + tokens.set(access, expiration=int(access["expires_in"]) - 3600) + auth_token: str = access["access_token"] + + else: + # new + self.log.info("Requesting new tokens...") + auth_url, scopes = self.settings() + params = { + "client_id": self.config["client"]["id"], + "grant_type": "password", + "username": credential.username, + "password": credential.password, + "scope": scopes, + } + + access: dict = self._request("POST", auth_url, params=params) + + # Shorten expiration by one hour to account for clock skew + tokens.set(access, expiration=int(access["expires_in"]) - 3600) + auth_token: str = access["access_token"] + + claims_token: str = self.claims_token(auth_token) + self.session.headers.update({"x-claims-token": claims_token}) + + def get_titles(self) -> Union[Movies, Series]: + title_re: str = r"^(?:https?://(?:www.)?gem.cbc.ca/)?(?P[a-zA-Z0-9_-]+)" + try: + title_id: str = re.match(title_re, self.title).group("id") + except Exception: + raise ValueError("- Could not parse ID from title") + + params = {"device": "web"} + data: dict = self._request("GET", "/ott/catalog/v2/gem/show/{}".format(title_id), params=params) + label: str = data.get("contentType", "").lower() + + if label in ("film", "movie", "standalone"): + movies: list[Movie] = self._movie(data) + return Movies(movies) + + else: + episodes: list[Episode] = self._show(data) + return Series(episodes) + + def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: + index: dict = self._request( + "GET", "/media/meta/v1/index.ashx", params={"appCode": "gem", "idMedia": title.id, "output": "jsonObject"} + ) + + title.data["extra"] = { + "chapters": index["Metas"].get("Chapitres"), + "credits": index["Metas"].get("CreditStartTime"), + } + + self.drm: bool = index["Metas"].get("isDrmActive") == "true" + if self.drm: + tech: str = next(tech["name"] for tech in index["availableTechs"] if "widevine" in tech["drm"]) + else: + tech: str = next(tech["name"] for tech in index["availableTechs"] if not tech["drm"]) + + response: dict = self._request( + "GET", self.config["endpoints"]["validation"].format("android", title.id, "smart-tv", tech) + ) + + manifest = response.get("url") + self.license = next((x["value"] for x in response["params"] if "widevineLicenseUrl" in x["name"]), None) + self.token = next((x["value"] for x in response["params"] if "widevineAuthToken" in x["name"]), None) + + stream_type: Union[HLS, DASH] = HLS if tech == "hls" else DASH + tracks: Tracks = stream_type.from_url(manifest, self.session).to_tracks(language=title.language) + + if stream_type == DASH: + for track in tracks.audio: + label = track.data["dash"]["adaptation_set"].find("Label") + if label is not None and "descriptive" in label.text.lower(): + track.descriptive = True + + for track in tracks: + track.language = title.language + + return tracks + + def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: + extra: dict = title.data["extra"] + + chapters = [] + if extra.get("chapters"): + chapters = [Chapter(timestamp=x) for x in set(extra["chapters"].split(","))] + + if extra.get("credits"): + chapters.append(Chapter(name="Credits", timestamp=float(extra["credits"]))) + + return Chapters(chapters) + + def get_widevine_service_certificate(self, **_: Any) -> str: + return None + + def get_widevine_license( + self, *, challenge: bytes, title: Union[Movies, Series], track: AnyTrack + ) -> Optional[Union[bytes, str]]: + if not self.license or not self.token: + return None + + headers = {"x-dt-auth-token": self.token} + r = self.session.post(self.license, headers=headers, data=challenge) + r.raise_for_status() + return r.content + + # Service specific + + def _show(self, data: dict) -> list[Episode]: + lineups: list = next((x["lineups"] for x in data["content"] if x.get("title", "").lower() == "episodes"), None) + if not lineups: + self.log.warning("No episodes found for: {}".format(data.get("title"))) + return + + titles = [] + for season in lineups: + for episode in season["items"]: + if episode.get("mediaType", "").lower() == "episode": + parts = episode.get("title", "").split(".", 1) + episode_name = parts[1].strip() if len(parts) > 1 else parts[0].strip() + titles.append( + Episode( + id_=episode["idMedia"], + service=self.__class__, + title=data.get("title"), + season=int(season.get("seasonNumber", 0)), + number=int(episode.get("episodeNumber", 0)), + name=episode_name, + year=episode.get("metadata", {}).get("productionYear"), + language=data["structuredMetadata"].get("inLanguage", "en-CA"), + data=episode, + ) + ) + + return titles + + def _movie(self, data: dict) -> list[Movie]: + unwanted: tuple = ("episodes", "trailers", "extras") + lineups: list = next((x["lineups"] for x in data["content"] if x.get("title", "").lower() not in unwanted), None) + if not lineups: + self.log.warning("No movies found for: {}".format(data.get("title"))) + return + + titles = [] + for season in lineups: + for movie in season["items"]: + if movie.get("mediaType", "").lower() == "episode": + parts = movie.get("title", "").split(".", 1) + movie_name = parts[1].strip() if len(parts) > 1 else parts[0].strip() + titles.append( + Movie( + id_=movie.get("idMedia"), + service=self.__class__, + name=movie_name, + year=movie.get("metadata", {}).get("productionYear"), + language=data["structuredMetadata"].get("inLanguage", "en-CA"), + data=movie, + ) + ) + + return titles + + def settings(self) -> tuple: + settings = self._request("GET", "/ott/catalog/v1/gem/settings", params={"device": "web"}) + auth_url: str = settings["identityManagement"]["ropc"]["url"] + scopes: str = settings["identityManagement"]["ropc"]["scopes"] + return auth_url, scopes + + def claims_token(self, token: str) -> str: + headers = { + "Authorization": "Bearer " + token, + } + params = {"device": "web"} + response: dict = self._request( + "GET", "/ott/subscription/v2/gem/Subscriber/profile", headers=headers, params=params + ) + return response["claimsToken"] + + def _request(self, method: str, api: str, **kwargs: Any) -> Any[dict | str]: + url: str = urljoin(self.base_url, api) + + prep: Request = self.session.prepare_request(Request(method, url, **kwargs)) + response = self.session.send(prep) + if response.status_code not in (200, 426): + raise ConnectionError(f"{response.status_code} - {response.text}") + + try: + data = json.loads(response.content) + error_keys = ["errorMessage", "ErrorMessage", "ErrorCode", "errorCode", "error"] + error_message = next((data.get(key) for key in error_keys if key in data), None) + if error_message: + self.log.error(f"\n - Error: {error_message}\n") + sys.exit(1) + + return data + + except json.JSONDecodeError: + raise ConnectionError("Request for {} failed: {}".format(response.url, response.text)) diff --git a/CBC/config.yaml b/CBC/config.yaml new file mode 100644 index 0000000..31bd19a --- /dev/null +++ b/CBC/config.yaml @@ -0,0 +1,7 @@ +endpoints: + base_url: "https://services.radio-canada.ca" + validation: "/media/validation/v2?appCode=gem&&deviceType={}&idMedia={}&manifestType={}&output=json&tech={}" + api_key: "3f4beddd-2061-49b0-ae80-6f1f2ed65b37" + +client: + id: "fc05b0ee-3865-4400-a3cc-3da82c330c23" \ No newline at end of file diff --git a/CBS/__init__.py b/CBS/__init__.py new file mode 100644 index 0000000..a28c287 --- /dev/null +++ b/CBS/__init__.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Generator +from typing import Any, Optional, Union +from urllib.parse import urljoin + +import click +from requests import Request +from unshackle.core.constants import AnyTrack +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Series, Title_T, Titles_T +from unshackle.core.tracks import Chapter, Chapters, Tracks +from unshackle.core.utils.sslciphers import SSLCiphers +from unshackle.core.utils.xml import load_xml + + +class CBS(Service): + """ + \b + Service code for CBS.com streaming service (https://cbs.com). + Credit to @srpen6 for the tip on anonymous session + + \b + Version: 1.0.1 + Author: stabbedbybrick + Authorization: None + Robustness: + Widevine: + L3: 2160p, DDP5.1 + + \b + Tips: + - Input should be complete URLs: + SERIES: https://www.cbs.com/shows/tracker/ + EPISODE: https://www.cbs.com/shows/video/E0wG_ovVMkLlHOzv7KDpUV9bjeKFFG2v/ + + \b + Common VPN/proxy errors: + - SSLError(SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING]')) + - ConnectionError: 406 Not Acceptable, 403 Forbidden + + """ + + GEOFENCE = ("us",) + + @staticmethod + @click.command(name="CBS", short_help="https://cbs.com", help=__doc__) + @click.argument("title", type=str, required=False) + @click.pass_context + def cli(ctx, **kwargs) -> CBS: + return CBS(ctx, **kwargs) + + def __init__(self, ctx, title): + self.title = title + super().__init__(ctx) + + def search(self) -> Generator[SearchResult, None, None]: + params = { + "term": self.title, + "termCount": 50, + "showCanVids": "true", + } + results = self._request("GET", "/apps-api/v3.1/androidphone/contentsearch/search.json", params=params)["terms"] + + for result in results: + yield SearchResult( + id_=result.get("path"), + title=result.get("title"), + description=None, + label=result.get("term_type"), + url=result.get("path"), + ) + + def get_titles(self) -> Titles_T: + title_re = r"https://www\.cbs\.com/shows/(?P