From 1afec7e7a8f11204026e93b17e115a0123809a25 Mon Sep 17 00:00:00 2001 From: n0stal6ic Date: Wed, 8 Jul 2026 18:56:50 -0500 Subject: [PATCH] Add files via upload --- AMZN/__init__.py | 1911 ++++++++++++++++++++++++++++++++++++++++++++++ AMZN/config.yaml | 149 ++++ 2 files changed, 2060 insertions(+) create mode 100644 AMZN/__init__.py create mode 100644 AMZN/config.yaml diff --git a/AMZN/__init__.py b/AMZN/__init__.py new file mode 100644 index 0000000..6e99a00 --- /dev/null +++ b/AMZN/__init__.py @@ -0,0 +1,1911 @@ +import base64 +import hashlib +import json +import os +import random +import re +import secrets +import string +import time +import uuid +from collections import defaultdict +from datetime import datetime, timedelta +from http.cookiejar import CookieJar +from typing import Any, Optional +from urllib.parse import quote +import click +import requests +from click.core import ParameterSource +from langcodes import Language +from tldextract import tldextract +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 Attachment, Chapter, Chapters, Subtitle, Tracks +from unshackle.core.utils.collections import as_list + + +def _build_ordered_lang_map_from_mpd(mpd_text: str) -> dict: + import xml.etree.ElementTree as ET + import re as _re + ns_strip = _re.compile(r"\{[^}]*\}") + rid_lang_re = _re.compile(r"^audio_([a-zA-Z]{2,3}-[a-zA-Z0-9]{2,5})") + result: dict = {} + try: + root = ET.fromstring(mpd_text) + for elem in root.iter(): + if ns_strip.sub("", elem.tag) != "AdaptationSet": + continue + content_type = elem.get("contentType", "") or elem.get("mimeType", "") + if "audio" not in content_type.lower(): + if not any("audio" in (r.get("mimeType", "")).lower() for r in elem): + continue + base_lang = elem.get("lang") or elem.get("language") or "" + if not base_lang: + continue + for r in elem: + if ns_strip.sub("", r.tag) != "Representation": + continue + rid = r.get("id") or "" + m = rid_lang_re.match(rid) + precise = m.group(1) if m else base_lang + result.setdefault(base_lang, []).append(precise) + except Exception: + pass + return result + + +def _apply_ordered_lang_map(audio_tracks, lang_map: dict) -> None: + from langcodes import Language as _Lang + counters: dict = {} + for track in audio_tracks: + base = str(track.language) + if base not in lang_map: + continue + ordered = lang_map[base] + if not any("-" in p for p in ordered): + continue + idx = counters.get(base, 0) + if idx < len(ordered): + track.language = _Lang.get(ordered[idx]) + counters[base] = idx + 1 + + +def _resolve_subtitle_language(language_code: str, url: str) -> str: + if "-" in language_code: + return language_code + import re as _re + m = _re.search( + r"(?:^|[/_-])(" + _re.escape(language_code) + r"[-_][A-Za-z0-9]{2,5})(?:[/_.-]|$)", + url, _re.IGNORECASE + ) + if m: + return m.group(1).replace("_", "-") + return language_code + + +class AMZN(Service): + """ + Service code for Amazon VOD (https://amazon.com) & Amazon Prime Video (https://primevideo.com). + + Author: n0stal6ic + Authorization: Cookies + Security: UHD@L1 FHD@Chrome SD@L3 + """ + + ALIASES = ["AMZN", "amazon", "prime"] + 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-]+)", + r"^(?:https?://(?:www\.)?(?Pamazon\.(?Pcom|co\.uk|de|co\.jp)|primevideo\.com)(?:/[^?]*)?(?:\?gti=)?)(?P[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)" + ] + + 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", + } + + @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("-p", "--player", default="html5", + type=click.Choice(["html5", "xp"], case_sensitive=False), + help="Video playerType to download in. html5, xp.") + @click.option("-c", "--cdn", default=None, type=str, + help="CDN to download from, defaults to a random CDN from the available set.") + @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="CVBR", + 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("-mt", "--manifest", "manifest_type", default="DASH", + type=click.Choice(["DASH", "ISM"], case_sensitive=False), + help="Manifest protocol for the video stream. ISM (SmoothStreaming) can carry a " + "higher-bitrate HDR10 video track than DASH, but requires a registered device.") + @click.option("-drm", "--drm-system", type=click.Choice(["widevine", "playready"], case_sensitive=False), + default="playready", + help="which drm system to use") + @click.option("-nr", "--no-true-region", "no_true_region", is_flag=True, default=False, + help="Skip the 'true region' playback session (keep-alive) and don't adopt the IP-geo " + "marketplace from the configuration endpoint. Session keep-alive is ON by default.") + @click.option("-pl", "--playlisted", is_flag=True, default=False, + help="Use Amazon's newer LivingRoomPlayer 'vodPlaylistedPlaybackUrls' request path " + "(requires a registered device). Off by default; the classic path is used first and " + "this is also tried automatically as a fallback when a device is registered.") + @click.pass_context + def cli(ctx, **kwargs): + return AMZN(ctx, **kwargs) + + def __init__(self, ctx, title, bitrate: str, player: str, cdn: str, vquality: str, single: bool, + amanifest: str, aquality: str, manifest_type: str, drm_system: str, + no_true_region: bool, playlisted: bool): + super().__init__(ctx) + self.parse_title(ctx, title) + self.bitrate = bitrate + self.player = player + self.bitrate_source = ctx.get_parameter_source("bitrate") + self.cdn = cdn + self.vquality = vquality + self.vquality_source = ctx.get_parameter_source("vquality") + self.single = single + self.amanifest = amanifest + self.aquality = aquality + self.manifest_type = (manifest_type or "DASH").upper() + self.drm_system = drm_system + self.no_true_region = no_true_region + self.playlisted = playlisted + + assert ctx.parent is not None + + self.chapters_only = ctx.parent.params.get("chapters_only") + self.quality = ctx.parent.params.get("quality") or 1080 + + vcodec = ctx.parent.params.get("vcodec") + range_ = ctx.parent.params.get("range_") + + self.range = range_[0].name if range_ else "SDR" + if vcodec and "AV1" in str(vcodec).upper(): + self.vcodec = "AV1" + elif vcodec and "HEVC" in str(vcodec).upper(): + self.vcodec = "H265" + else: + self.vcodec = "H264" + + self.cdm = ctx.obj.cdm + self.profile = ctx.obj.profile + self.playready = self.drm_system == "playready" + + self.region: dict[str, str] = {} + self.endpoints: dict[str, str] = {} + self.device: dict[str, str] = {} + + self.pv = False + self.event = False + self.device_token = None + self.device_id = None + self.customer_id = None + self.client_id = "f22dbddb-ef2c-48c5-8876-bed0d47594fd" + self.playbackEnvelope = None + self.playbackInfo = None + self.session_handoff_token = None + + if self.vquality_source != ParameterSource.COMMANDLINE: + q_check = self.quality[0] if isinstance(self.quality, list) else self.quality + + if 0 < q_check <= 576 and self.range == "SDR": + self.log.info(" + Setting manifest quality to SD") + self.vquality = "SD" + + if q_check > 1080: + self.log.info(" + Setting manifest quality to UHD to be able to get 2160p video track") + self.vquality = "UHD" + + self.vquality = self.vquality or "HD" + + if self.vquality == "UHD": + self.vcodec = "H265" + + if self.bitrate_source != ParameterSource.COMMANDLINE: + if self.manifest_type == "ISM": + self.bitrate = "CBR" + self.log.info(" + Forcing bitrate mode to CBR for ISM (SmoothStreaming)") + elif self.vcodec == "AV1": + self.bitrate = "CVBR" + self.log.info(" + Forcing bitrate mode to CVBR for AV1 codec") + elif 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.manifest_type != "ISM" and 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 + + 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() + + def configure(self) -> None: + if len(self.title) > 10: + self.pv = True + 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"); raise SystemExit(1) + + self.log.info(f" + Region: {self.region['code']}") + + self.endpoints = self.prepare_endpoints(self.config["endpoints"], self.region) + + self.session.headers.update({ + "Origin": f"https://{self.region['base']}" + }) + + _profile = self.profile or "default" + self.device = (self.config.get("device") or {}).get(_profile, {}) + + dtid_dict = self.config.get("dtid_dict", []) + if self.device and dtid_dict: + if self.device.get("device_type") not in set(dtid_dict): + self.log.error( + f" - Device type '{self.device.get('device_type')}' is NOT in the approved " + "dtid_dict. Using it could result in an Amazon account ban. Update your config." + ) + raise SystemExit(1) + + need_device = False + if (isinstance(self.quality, list) and self.quality[0] > 1080) or self.vquality == "UHD" or self.range != "SDR": + need_device = True + if self.manifest_type == "ISM": + need_device = True + if self.playlisted: + need_device = True + + if self.manifest_type == "ISM" and not self.device: + self.log.error( + " - ISM/SmoothStreaming requires a configured device in config.yaml. " + "Add one or use --manifest DASH." + ) + raise SystemExit(1) + + if self.playlisted and not self.device: + self.log.warning( + " - --playlisted (LivingRoomPlayer) needs a configured device in config.yaml; " + "falling back to the classic browser path." + ) + self.playlisted = False + + if self.device: + if need_device and self.vcodec == "H265": + self.log.info("Using device to get UHD manifests") + else: + self.log.info(f"Using configured device for profile: {_profile}") + res_cfg = self.session.get( + url=self.endpoints["configuration"], + params={"deviceTypeID": self.device["device_type"], "deviceID": "Tv"} + ) + self._apply_verified_territory(res_cfg) + self.register_device() + else: + 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"]} + res_cfg = self.session.get( + url=self.endpoints["configuration"], + params={"deviceTypeID": self.device["device_type"], "deviceID": "Web"} + ) + self._apply_verified_territory(res_cfg) + + def _apply_verified_territory(self, res_cfg) -> None: + if res_cfg.status_code != 200: + self.log.warning(f" - Configuration endpoint returned {res_cfg.status_code}, using config values") + return + try: + ctx = res_cfg.json().get("requestContext", {}) + except ValueError: + return + territory = ctx.get("currentTerritory") + marketplace = ctx.get("marketplaceID") + account_region = self.region.get("code") + if territory and account_region and territory.lower() != account_region.lower(): + self.log.warning( + f" - IP region '{territory}' does not match account region '{account_region}'. " + f"Amazon geo-locates by IP. Licensing and/or title lookup may be blocked. " + f"If the license step fails, use a {account_region} IP (e.g. --proxy {account_region} or a VPN)." + ) + return + if territory: + self.log.info(f" + Region (verified): {territory}") + if marketplace and not self.no_true_region: + self.region["marketplace_id"] = marketplace + elif marketplace and self.no_true_region: + self.log.debug(" + --no-true-region set; keeping marketplace from config, ignoring IP-geo value") + + def get_titles(self) -> Titles_T: + res = self.session.get( + url=self.endpoints["details"], + params={ + "titleID": self.title, + "isElcano": "1", + "sections": ["Atf", "Btf"] + }, + headers={"Accept": "application/json"} + ) + + if not res.ok: + self.log.error(f"Unable to get title: {res.text} [{res.status_code}]"); raise SystemExit(1) + + data = res.json()["widgets"] + product_details = data.get("productDetails", {}).get("detail") + + if not product_details: + error = res.json()["degradations"][0] + self.log.error(f"Unable to get title: {error['message']} [{error['code']}]"); raise SystemExit(1) + + if data["pageContext"]["subPageType"] == "Event": + self.event = True + + if data["pageContext"]["subPageType"] in ["Movie", "Event"]: + card = data["productDetails"]["detail"] + return Movies([Movie( + id_=card["catalogId"], + name=product_details["title"], + year=card.get("releaseYear", ""), + service=self.__class__, + data=card + )]) + + episodes_list = [] + seasons = [] + for season in (data.get("seasonSelector") or []): + sid = season.get("titleID") + if sid and sid not in seasons: + seasons.append(sid) + if self.single: + break + if not seasons and not self.single: + seasons = self._discover_seasons_via_spa() + if not seasons: + seasons = [self.title] + + for season in seasons: + res = self.session.get( + url=self.endpoints["details"], + params={"titleID": season, "isElcano": "1", "sections": "Btf"}, + headers={"Accept": "application/json"}, + ).json()["widgets"] + + try: + episode_data_list = res["episodeList"]["episodes"] + except KeyError: + continue + + product_details_season = res["productDetails"]["detail"] + + for episode in episode_data_list: + details = episode["detail"] + episodes_list.append(Episode( + id_=details["catalogId"], + title=product_details["title"], + name=details["title"], + season=product_details_season["seasonNumber"], + number=episode["self"]["sequenceNumber"], + service=self.__class__, + data=episode + )) + + pagination_data = res.get("episodeList", {}).get("actions", {}).get("pagination", []) + token = next((quote(item.get("token")) for item in pagination_data if item.get("tokenType") == "NextPage"), None) + + while token: + res_page = self.session.get( + url=self.endpoints["getDetailWidgets"], + params={ + "titleID": self.title, + "isTvodOnRow": "1", + "widgets": f'[{{"widgetType":"EpisodeList","widgetToken":"{token}"}}]' + }, + headers={"Accept": "application/json"} + ).json() + + episodeListWidget = res_page["widgets"].get("episodeList", {}) + for item in episodeListWidget.get("episodes", []): + ep_num = int(item.get("self", {}).get("sequenceNumber", 0)) + episodes_list.append(Episode( + id_=item["detail"]["catalogId"], + title=product_details["title"], + name=item["detail"]["title"], + season=product_details_season["seasonNumber"], + number=ep_num, + service=self.__class__, + data=item + )) + + pagination_data = res_page["widgets"].get("episodeList", {}).get("actions", {}).get("pagination", []) + token = next((quote(item.get("token")) for item in pagination_data if item.get("tokenType") == "NextPage"), None) + + return Series(episodes_list) + + def _discover_seasons_via_spa(self) -> list: + try: + headers = { + "accept": "application/json", + "device-memory": "8", "downlink": "10", "dpr": "2", "ect": "4g", "rtt": "50", + "viewport-width": "604", "x-amzn-client-ttl-seconds": "58.999", + "x-purpose": "navigation", "x-requested-with": "WebSPA", + } + base = self.region["base"] + if self.pv: + res_spa = self.session.get(f"https://{base}/detail/{self.title}", headers=headers) + url = ( + f"https://{base}{res_spa.json()['redirect']}" + if "redirect" in res_spa.text else res_spa.url + ) + else: + url = f"https://{base}/dp/{self.title}" + + headers["referer"] = url + response = self.session.get( + url=url, params={"dvWebSPAClientVersion": "1.0.106799.0"}, headers=headers + ) + if response.status_code != 200 or not response.text: + return [] + + spa_data = response.json() + seasons_data = None + for page in spa_data.get("page", []) or []: + assembly = page.get("assembly") or {} + for body in assembly.get("body", []) or []: + seasons_data = body.get("props", {}).get("atf", {}).get("state", {}).get("seasons") + if seasons_data: + break + if seasons_data: + break + + seasons: list = [] + if seasons_data: + first_group = next(iter(seasons_data.values()), []) + for season in first_group or []: + match = re.search(r"/detail/([A-Z0-9]{10,})/", season.get("seasonLink", "")) + if match and match.group(1) not in seasons: + seasons.append(match.group(1)) + if seasons: + self.log.info(f" + Discovered {len(seasons)} season(s) via SPA detail page") + return seasons + except Exception as e: + self.log.debug(f"SPA season discovery failed: {e}") + return [] + + def get_tracks(self, title: Title_T) -> Tracks: + if self.chapters_only: + return Tracks([]) + + is_hybrid = self.range == "HYBRID" + effective_vcodec = self.vcodec + effective_range = "DV" if is_hybrid else self.range + video_protocol = "SmoothStreaming" if self.manifest_type == "ISM" else "DASH" + manifest = self.get_manifest( + title, + video_codec=effective_vcodec, + bitrate_mode=self.bitrate, + quality=self.vquality, + hdr=effective_range, + ignore_errors=(self.range == "DV" or is_hybrid), + protocol=video_protocol, + use_playlisted=self.playlisted + ) + if (self.range == "DV" or is_hybrid) and not manifest.get("vodPlaybackUrls"): + self.log.warning(" - Dolby Vision request rejected by server, retrying with HDR10...") + effective_range = "HDR10" + manifest = self.get_manifest( + title, + video_codec=effective_vcodec, + bitrate_mode=self.bitrate, + quality=self.vquality, + hdr=effective_range, + ignore_errors=False, + protocol=video_protocol, + use_playlisted=self.playlisted + ) + if not self._usable_manifest(manifest) and self.device_token and not self.playlisted: + self.log.info(" + Classic manifest empty; trying newer playlisted (LivingRoomPlayer) path...") + fallback = self.get_manifest( + title, + video_codec=effective_vcodec, + bitrate_mode=self.bitrate, + quality=self.vquality, + hdr=effective_range, + ignore_errors=True, + protocol=video_protocol, + use_playlisted=True + ) + if self._usable_manifest(fallback): + manifest = fallback + self.log.info(" + Using playlisted (LivingRoomPlayer) manifest") + + if "rightsException" in manifest.get("returnedTitleRendition", {}).get("selectedEntitlement", {}): + self.log.error(" - The profile used does not have the rights to this title.") + return Tracks([]) + + self.session_handoff_token = (manifest.get("sessionization") or {}).get("sessionHandoffToken") + if isinstance(getattr(title, "data", None), dict): + title.data["_amzn_manifest"] = manifest + + chosen_manifest = self.choose_manifest(manifest, self.cdn) + if not chosen_manifest: + self.log.error("No manifests available"); raise SystemExit(1) + + streamingProtocol = ( + manifest.get("vodPlaybackUrls", {}).get("result", {}).get("playbackUrls", {}) + .get("urlMetadata", {}).get("streamingProtocol", "DASH") + ) + if self.event: + devicetype = self.device.get("device_type") + manifest_url = f"{chosen_manifest['url']}?amznDtid={devicetype}&encoding=segmentBase" + elif streamingProtocol == "DASH": + manifest_url = self.clean_mpd_url(chosen_manifest["url"], False) + else: + manifest_url = chosen_manifest["url"] + + self.log.info(f" + Manifest: {manifest_url}") + + if streamingProtocol == "DASH": + _mpd_raw = self.session.get(manifest_url).text + _lang_order_map = _build_ordered_lang_map_from_mpd(_mpd_raw) + self.log.info(f" + MPD language map: {sum(len(v) for v in _lang_order_map.values())} representations indexed") + else: + _lang_order_map = {} + + tracks = Tracks() + + if streamingProtocol == "DASH": + tracks = Tracks([ + x for x in iter(DASH.from_text(_mpd_raw, manifest_url).to_tracks(language="en")) + ]) + elif streamingProtocol == "SmoothStreaming": + _ism_tracks = Tracks() + for _t in iter(ISM.from_url(url=manifest_url, session=self.session).to_tracks(language="en")): + _ism_tracks.add(_t, warn_only=True) + tracks = _ism_tracks + else: + self.log.error(f"Unsupported manifest type: {streamingProtocol}"); raise SystemExit(1) + + if _lang_order_map: + _apply_ordered_lang_map(tracks.audio, _lang_order_map) + self._tag_amzn_tracks(tracks, manifest) + + 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: + manifest_type = self.amanifest + 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="H264", + bitrate_mode="CVBR", + quality="HD", + 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["url"], optimise=False) + if self.event: + devicetype = self.device.get("device_type") + audio_mpd_url = f"{chosen_audio_manifest['url']}?amznDtid={devicetype}&encoding=segmentBase" + + self.log.info(" + Downloading Audio Manifest") + try: + audio_protocol = audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"] + if audio_protocol == "DASH": + _a_raw = self.session.get(audio_mpd_url).text + _a_lang_order = _build_ordered_lang_map_from_mpd(_a_raw) + self.log.info(f" + Audio MPD language map: {sum(len(v) for v in _a_lang_order.values())} entries") + audio_tracks = DASH.from_text(_a_raw, audio_mpd_url).to_tracks(language="en") + if _a_lang_order: + _apply_ordered_lang_map(audio_tracks.audio, _a_lang_order) + elif audio_protocol == "SmoothStreaming": + audio_tracks = ISM.from_url(url=audio_mpd_url, session=self.session).to_tracks(language="en") + else: + audio_tracks = Tracks([]) + + self._tag_amzn_tracks(audio_tracks, audio_manifest) + tracks.add(audio_tracks.audio, warn_only=True) + except Exception as e: + self.log.warning(f" - Failed to parse audio manifest: {e}") + + if (self.config.get("device") or {}).get(self.profile or "default", None): + self.log.info(" + Fetching DV/UHD manifest for Atmos audio (576kbps DD+)") + temp_device = self.device + temp_token = self.device_token + temp_id = self.device_id + + if not self.device_token: + try: + self.register_device() + except Exception: + pass + + try: + uhd_audio_manifest = self.get_manifest( + title=title, + video_codec="H265", + bitrate_mode="CVBR+CBR", + quality="UHD", + hdr="DV", + ignore_errors=True + ) + except Exception: + uhd_audio_manifest = None + + self.device = temp_device + self.device_token = temp_token + self.device_id = temp_id + + if uhd_audio_manifest and (chosen_uhd := self.choose_manifest(uhd_audio_manifest, self.cdn)): + uhd_url = self.clean_mpd_url(chosen_uhd["url"], optimise=False) + self.log.info(" + Downloading DV/UHD audio manifest") + try: + uhd_prot = uhd_audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"] + if uhd_prot == "DASH": + _uhd_raw = self.session.get(uhd_url).text + _uhd_lang_map = _build_ordered_lang_map_from_mpd(_uhd_raw) + uhd_tracks = DASH.from_text(_uhd_raw, uhd_url).to_tracks(language="en") + if _uhd_lang_map: + _apply_ordered_lang_map(uhd_tracks.audio, _uhd_lang_map) + self.log.info(f" + DV audio lang map: {sum(len(v) for v in _uhd_lang_map.values())} entries") + elif uhd_prot == "SmoothStreaming": + uhd_tracks = ISM.from_url(url=uhd_url, session=self.session).to_tracks(language="en") + else: + uhd_tracks = Tracks([]) + self._tag_amzn_tracks(uhd_tracks, uhd_audio_manifest) + tracks.add(uhd_tracks.audio, warn_only=True) + _atmos_tracks = [x for x in uhd_tracks.audio if (x.bitrate or 0) >= 448000 and (x.channels or 0) >= 6] + if _atmos_tracks: + _best_kbps = max((x.bitrate or 0) for x in _atmos_tracks) // 1000 + self.log.info(f" + Added {len(_atmos_tracks)} Atmos/high-bitrate audio track(s) from DV manifest (best: {_best_kbps} kb/s)") + else: + self.log.info(" + DV audio manifest fetched (no Atmos found for this title)") + except Exception as e: + self.log.warning(f" - Failed to parse DV audio manifest: {e}") + else: + self.log.warning(" - DV/UHD audio manifest unavailable for this title/region") + + self._post_process_audio(tracks.audio) + + _all_codecs = list({v.codec for v in tracks.videos if v.codec}) + if self.vcodec == "AV1": + filtered = [v for v in tracks.videos if v.codec and ("av01" in v.codec.lower() or "av1" in v.codec.lower())] + if not filtered: + self.log.error( + f" - No AV1 tracks found (found codecs: {_all_codecs}). " + "The manifest may not have AV1 or the codec string is unexpected." + ) + raise SystemExit(1) + tracks.videos = filtered + elif self.vcodec == "H265": + filtered = [v for v in tracks.videos if v.codec and any(x in v.codec.lower() for x in ["hev1", "hvc1", "h265"])] + if filtered: + tracks.videos = filtered + else: + filtered = [v for v in tracks.videos if v.codec and "avc" in v.codec.lower()] + if filtered: + tracks.videos = filtered + + actual_range = ( + manifest.get("vodPlaybackUrls", {}).get("result", {}).get("playbackUrls", {}) + .get("urlMetadata", {}).get("dynamicRange", "None") + ) + for video in tracks.videos: + video.hdr10 = actual_range == "Hdr10" + video.dv = actual_range == "DolbyVision" + + if (self.range == "DV" or is_hybrid) and actual_range != "DolbyVision": + friendly = {"Hdr10": "HDR10", "None": "SDR"}.get(actual_range, actual_range) + self.log.warning(f" - Dolby Vision not available for this title/region. Server returned: {friendly}") + + if (self.range == "DV" or is_hybrid) and actual_range == "DolbyVision": + self.log.info(" + Hybrid mode: fetching HDR10 manifest for base layer...") + hdr10_manifest = self.get_manifest( + title=title, + video_codec=effective_vcodec, + bitrate_mode=self.bitrate, + quality=self.vquality, + hdr="HDR10", + ignore_errors=True + ) + if hdr10_manifest and hdr10_manifest.get("vodPlaybackUrls"): + chosen_hdr10 = self.choose_manifest(hdr10_manifest, self.cdn) + if chosen_hdr10: + hdr10_url = self.clean_mpd_url(chosen_hdr10["url"], False) + self.log.info(f" + HDR10 Manifest (base layer): {hdr10_url}") + try: + hdr10_protocol = hdr10_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"] + if hdr10_protocol == "DASH": + hdr10_tracks = DASH.from_url(url=hdr10_url, session=self.session).to_tracks(language="en") + elif hdr10_protocol == "SmoothStreaming": + hdr10_tracks = ISM.from_url(url=hdr10_url, session=self.session).to_tracks(language="en") + else: + hdr10_tracks = Tracks([]) + for video in hdr10_tracks.videos: + video.hdr10 = True + video.dv = False + hdr10_video_count = len(list(hdr10_tracks.videos)) + tracks.add(hdr10_tracks.videos, warn_only=True) + self.log.info(f" + Added {hdr10_video_count} HDR10 base-layer video track(s) for hybrid mux") + except Exception as e: + self.log.warning(f" - Failed to fetch HDR10 base layer for hybrid: {e}") + else: + self.log.warning(" - No HDR10 manifest CDN available for hybrid mode") + else: + self.log.warning(" - HDR10 manifest unavailable for hybrid mode; DV-only tracks will be used") + + for sub in manifest.get("timedTextUrls", {}).get("result", {}).get("subtitleUrls", []) + \ + manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []): + url = sub["url"] + url_ext = os.path.splitext(url.split("?")[0])[1].lstrip(".").lower() + codec_map = { + "ttml": Subtitle.Codec.TimedTextMarkupLang, + "dfxp": Subtitle.Codec.TimedTextMarkupLang, + "vtt": Subtitle.Codec.WebVTT, + "srt": Subtitle.Codec.SubRip, + } + detected_codec = codec_map.get(url_ext, Subtitle.Codec.TimedTextMarkupLang) + + tracks.add(Subtitle( + id_=f"{sub['trackGroupId']}_{sub['languageCode']}_{sub['type']}_{sub['subtype']}", + url=url, + codec=detected_codec, + language=_resolve_subtitle_language(sub["languageCode"], url), + forced="ForcedNarrative" in sub["type"], + sdh=sub["type"].lower() == "sdh" + ), warn_only=True) + + try: + images = title.data.get("detail", {}).get("images", {}) + if isinstance(title, Movie): + image_url = images.get("packshot") or images.get("titleshot") or images.get("covershot") + else: + image_url = images.get("covershot") or images.get("packshot") or images.get("titleshot") + if image_url and image_url.strip(): + tracks.add(Attachment.from_url( + url=image_url.strip(), + name="cover", + mime_type="image/jpeg", + session=self.session, + )) + except Exception as e: + self.log.warning(f" - Attachment failed: {e}") + if not self.no_true_region and self.vquality != "UHD" and tracks.videos: + self.manage_session(tracks.videos[0]) + + return tracks + + def get_chapters(self, title: Title_T) -> Chapters: + manifest = title.data.get("_amzn_manifest") if isinstance(getattr(title, "data", None), dict) else None + if not manifest: + manifest = self.get_manifest( + title, + video_codec=self.vcodec, + bitrate_mode=self.bitrate, + quality=self.vquality, + hdr=self.range, + ignore_errors=True + ) + if not manifest: + return Chapters() + + chapters = self._chapters_from_transitions(manifest) + if chapters: + self.log.info(f" + Found {len(chapters)} chapter marker(s) from transition timecodes") + return Chapters(chapters) + + return Chapters(self._chapters_from_xray(manifest)) + + def _chapters_from_transitions(self, manifest: dict) -> list: + result = manifest.get("transitionTimecodes", {}).get("result", {}) + events = result.get("events") + if not events: + return [] + + def fmt(ms): + s = ms / 1000 + return f"{int(s // 3600):02d}:{int((s % 3600) // 60):02d}:{s % 60:06.3f}" + + chapters = [] + for event in sorted(events, key=lambda e: e.get("startTimeMs", 0)): + start_ms = event.get("startTimeMs") + if start_ms is None: + continue + event_type = event.get("eventType", "") + if event_type == "SKIP_INTRO": + chapters.append(Chapter(name="Intro", timestamp=fmt(start_ms))) + if event.get("endTimeMs"): + chapters.append(Chapter(name="Main Content", timestamp=fmt(event["endTimeMs"]))) + elif event_type == "SKIP_RECAP": + chapters.append(Chapter(name="Recap", timestamp=fmt(start_ms))) + if event.get("endTimeMs"): + chapters.append(Chapter(name="Episode Start", timestamp=fmt(event["endTimeMs"]))) + elif event_type == "END_CREDITS": + chapters.append(Chapter(name="Credits", timestamp=fmt(start_ms))) + return chapters + + def _chapters_from_xray(self, manifest: dict) -> list: + if "vodXrayMetadata" not in manifest or "error" in manifest["vodXrayMetadata"]: + return [] + try: + xray_params = { + "pageId": "fullScreen", + "pageType": "xray", + "serviceToken": json.dumps({ + "consumptionType": "Streaming", + "deviceClass": "normal", + "playbackMode": "playback", + "vcid": json.loads(manifest["vodXrayMetadata"]["result"]["parameters"]["serviceToken"])["vcid"] + }), + "deviceID": self.device_id, + "deviceTypeID": self.config["device_types"]["browser"], + "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") + except Exception: + return [] + + if not xray: + return [] + + try: + 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"] + except (KeyError, IndexError): + return [] + + chapters = [] + for scene in scenes: + chapter_title = scene["textMap"]["PRIMARY"] + match = re.search(r"(\d+\. |)(.+)", chapter_title) + if match: + chapter_title = match.group(2) + timecode = scene["textMap"]["TERTIARY"].replace("Starts at ", "") + chapters.append(Chapter(name=chapter_title, timestamp=timecode)) + return chapters + + def playbackEnvelope_data(self, title): + try: + res = self.session.get( + url=self.endpoints["metadata"], + params={ + "metadataToEnrich": json.dumps({"placement": "HOVER", "playback": "true", "preroll": "true", "trailer": "true", "watchlist": "true"}), + "titleIDsToEnrich": json.dumps([title.id]), + "currentUrl": f"https://{self.region['base']}/" + }, + headers={ + "device-memory": "8", + "x-amzn-requestid": "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(20)), + "x-requested-with": "XMLHttpRequest" + } + ) + + if res.status_code != 200: + self.log.error(f"Unable to get playbackEnvelope: {res.text}"); raise SystemExit(1) + + data = res.json() + enrichment = data.get("enrichments", {}).get(title.id, {}) + playback_actions = [ + a for a in enrichment.get("playbackActions", []) + if a.get("playbackExperienceMetadata", {}).get("playbackEnvelope") + ] + if playback_actions: + pem = playback_actions[0]["playbackExperienceMetadata"] + self.playbackInfo = {"titleID": title.id, "playbackExperienceMetadata": pem} + return pem["playbackEnvelope"] + + preroll = enrichment.get("prerollsEnvelope") or {} + preroll_env = preroll.get("playbackEnvelope") + if preroll_env: + self.playbackInfo = {"titleID": title.id, "playbackExperienceMetadata": preroll} + return preroll_env + focus = enrichment.get("entitlementCues", {}).get("focusMessage", {}).get("message", "") + if focus and any(k in focus.lower() for k in ("trial", "rent", "buy", "subscribe", "join")): + self.log.error( + f" - This account is not entitled to '{title}'. Amazon returned: \"{focus}\". " + "Account needs a purchase/rental/subscription, or the cookies belong to " + "a different account." + ); raise SystemExit(1) + self.log.error( + f" - No playback envelope returned for '{title}' " + f"(not entitled, or bad cookies). Entitlement cue: {focus or 'none'}" + ); raise SystemExit(1) + except SystemExit: + raise + except Exception as e: + self.log.error(f"Unable to get playbackEnvelope: {e}"); raise SystemExit(1) + + def playbackEnvelope_update(self, playbackInfo): + if not playbackInfo: + return playbackInfo + try: + pem = playbackInfo.get("playbackExperienceMetadata", {}) or {} + expiry = pem.get("expiryTime", 0) + if expiry and (int(expiry) / 1000) > time.time(): + return playbackInfo + + correlation_id = pem.get("correlationId") + title_id = playbackInfo.get("titleID") + if not correlation_id or not title_id: + return playbackInfo + + self.log.debug(" + Refreshing expired playback envelope") + res = self.session.post( + url=self.endpoints["refreshplayback"], + params={ + "deviceID": self.device_id, + "deviceTypeID": self.device["device_type"], + "gascEnabled": str(self.pv).lower(), + "marketplaceID": self.region["marketplace_id"], + "uxLocale": "en_EN", + "firmware": "1", + "version": "1", + "nerid": self.generate_nerid(), + }, + data=json.dumps({ + "deviceId": self.device_id, + "deviceTypeId": self.device["device_type"], + "identifiers": {title_id: correlation_id}, + "geoToken": "null", + "identityContext": "null", + }), + ) + if res.status_code != 200: + return playbackInfo + response_data = res.json().get("response", {}) + if not isinstance(response_data, dict): + return playbackInfo + new_pe = response_data.get(title_id, {}).get("playbackExperience") + if not new_pe: + return playbackInfo + new_expiry = new_pe.get("expiryTime") + if new_expiry: + new_pe["expiryTime"] = int(new_expiry * 1000) + refreshed = {"titleID": title_id, "playbackExperienceMetadata": new_pe} + self.playbackInfo = refreshed + if new_pe.get("playbackEnvelope"): + self.playbackEnvelope = new_pe["playbackEnvelope"] + return refreshed + except Exception as e: + self.log.debug(f"Playback envelope refresh failed (non-fatal): {e}") + return playbackInfo + + def _build_manifest_payload(self, title, video_codec, bitrate_mode, quality, hdr, protocol, use_playlisted): + bitrate_adaptations = ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode] + range_fmt = self.VIDEO_RANGE_MAP.get(hdr, "None") + drm_type = "PlayReady" if self.playready else "Widevine" + + if use_playlisted and self.device_token: + def build_tech(): + return { + "bitrateAdaptations": bitrate_adaptations, + "codecs": [video_codec], + "drmType": drm_type, + "drmKeyScheme": "SingleKey" if self.playready else "DualKey", + "drmStrength": "L40", + "dynamicRangeFormats": [range_fmt], + "edgeDeliveryAuthorizationSchemes": ["PVExchangeV1", "Transparent"], + "fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"], + "frameRates": ["Standard", "High"], + "segmentInfoType": "Base", + "stitchType": "MultiPeriod", + "timedTextRepresentations": ["BurnedIn", "NotInManifestNorStream", "SeparateStreamInManifest"], + "trickplayRepresentations": ["NotInManifestNorStream"], + "variableAspectRatio": "supported", + "vastTimelineType": "Absolute", + "manifestThinningToSupportedResolution": "Forbidden", + } + + if protocol == "SmoothStreaming": + techs = {"SmoothStreaming": build_tech()} + supported_techs = ["SmoothStreaming"] + else: + techs = {"DASH": build_tech(), "SmoothStreaming": build_tech()} + supported_techs = ["DASH", "SmoothStreaming"] + + return { + "globalParameters": { + "deviceCapabilityFamily": "LivingRoomPlayer", + "playbackEnvelope": self.playbackEnvelope, + "capabilityDiscriminators": {"discriminators": {"software": {}, "version": 1}}, + }, + "timedTextUrlsRequest": {"supportedTimedTextFormats": ["TTMLv2", "DFXP"]}, + "transitionTimecodesRequest": {}, + "vodPlaylistedPlaybackUrlsRequest": { + "device": { + "displayBasedVending": "supported", + "displayHeight": 2304, + "displayWidth": 4096, + "hdcpLevel": "2.3", + "maxVideoResolution": "2160p", + "category": "Tv", + "platform": "Android", + "streamingTechnologies": techs, + "supportedStreamingTechnologies": supported_techs, + }, + "playbackSettingsRequest": { + "firmware": "UNKNOWN", + "playerType": self.player, + "responseFormatVersion": "1.0.0", + "titleId": title.id, + }, + }, + "vodXrayMetadataRequest": { + "xrayDeviceClass": "normal", + "xrayPlaybackMode": "playback", + "xrayToken": "XRAY_REIGN_3PLR_2025_V2", + }, + } + + if not self.device_token: + if protocol == "SmoothStreaming": + self.log.warning(" - ISM/SmoothStreaming requires a registered device; using DASH for web playback.") + global_params = { + "deviceCapabilityFamily": "WebPlayer", + "playbackEnvelope": self.playbackEnvelope, + "capabilityDiscriminators": { + "operatingSystem": {"name": "Windows", "version": "10.0"}, + "middleware": {"name": "EdgeNext", "version": "136.0.0.0"}, + "nativeApplication": {"name": "EdgeNext", "version": "136.0.0.0"}, + "hfrControlMode": "Legacy", + "displayResolution": {"height": 2304, "width": 4096} + } + } + audit_request = {} + vod_request = { + "device": { + "hdcpLevel": "2.2" if quality == "UHD" else "1.4", + "maxVideoResolution": ("1080p" if quality == "HD" else "480p" if quality == "SD" else "2160p"), + "supportedStreamingTechnologies": ["DASH"], + "streamingTechnologies": { + "DASH": { + "bitrateAdaptations": bitrate_adaptations, + "codecs": [video_codec], + "drmKeyScheme": "SingleKey" if self.playready else "DualKey", + "drmType": drm_type, + "dynamicRangeFormats": range_fmt, + "fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"], + "frameRates": ["Standard"], + "stitchType": "MultiPeriod", + "segmentInfoType": "Base", + "timedTextRepresentations": ["NotInManifestNorStream", "SeparateStreamInManifest"], + "trickplayRepresentations": ["NotInManifestNorStream"], + "variableAspectRatio": "supported" + } + }, + "displayWidth": 4096, + "displayHeight": 2304 + }, + "ads": {"sitePageUrl": "", "gdpr": {"enabled": "false", "consentMap": {}}}, + "playbackCustomizations": {}, + "playbackSettingsRequest": { + "firmware": "UNKNOWN", + "playerType": self.player, + "responseFormatVersion": "1.0.0", + "titleId": title.id + } + } + else: + global_params = { + "deviceCapabilityFamily": "AndroidPlayer", + "playbackEnvelope": self.playbackEnvelope, + "capabilityDiscriminators": {"discriminators": {"software": {}, "version": 1}} + } + audit_request = {"device": {"category": "Tv", "platform": "Android"}} + + technology = "SmoothStreaming" if protocol == "SmoothStreaming" else "DASH" + tech_block = { + technology: { + "bitrateAdaptations": bitrate_adaptations, + "codecs": [video_codec], + "drmType": drm_type, + "drmKeyScheme": "SingleKey" if self.playready else "DualKey", + "drmStrength": "L40", + "dynamicRangeFormats": [range_fmt], + "edgeDeliveryAuthorizationSchemes": ["PVExchangeV1", "Transparent"], + "fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"], + "frameRates": ["Standard", "High"], + "segmentInfoType": "Base", + "stitchType": "MultiPeriod", + "timedTextRepresentations": ["BurnedIn", "NotInManifestNorStream", "SeparateStreamInManifest"], + "trickplayRepresentations": ["NotInManifestNorStream"], + "variableAspectRatio": "supported", + "vastTimelineType": "Absolute", + "manifestThinningToSupportedResolution": "Forbidden" + } + } + vod_request = { + "ads": {}, + "device": { + "displayBasedVending": "supported", + "displayHeight": 2304, + "displayWidth": 4096, + "streamingTechnologies": tech_block, + "acceptedCreativeApis": [], + "category": "Tv", + "hdcpLevel": "2.2", + "maxVideoResolution": "2160p", + "platform": "Android", + "supportedStreamingTechnologies": [technology] + }, + "playbackCustomizations": {}, + "playbackSettingsRequest": { + "firmware": "UNKNOWN", + "playerType": self.player, + "responseFormatVersion": "1.0.0", + "titleId": title.id + } + } + + return { + "globalParameters": global_params, + "auditPingsRequest": audit_request, + "playbackDataRequest": {}, + "timedTextUrlsRequest": {"supportedTimedTextFormats": ["TTMLv2", "DFXP"]}, + "trickplayUrlsRequest": {}, + "transitionTimecodesRequest": {}, + "vodPlaybackUrlsRequest": vod_request, + "vodXrayMetadataRequest": { + "xrayDeviceClass": "normal", + "xrayPlaybackMode": "playback", + "xrayToken": "XRAY_WEB_2023_V2" + } + } + + @staticmethod + def _normalize_playlisted_manifest(manifest: dict) -> dict: + playlisted = manifest.get("vodPlaylistedPlaybackUrls", {}) or {} + converted = dict(manifest) + + if "error" in playlisted: + converted["vodPlaybackUrls"] = {"error": playlisted["error"]} + return converted + + playback_urls = playlisted.get("result", {}).get("playbackUrls", {}) or {} + playlist = playback_urls.get("intraTitlePlaylist", []) or [] + main = next((x for x in playlist if x.get("type") == "Main"), playlist[0] if playlist else None) + + if not main: + converted["vodPlaybackUrls"] = {"result": {"playbackUrls": {"urlSets": [], "urlMetadata": {}}}} + return converted + + url_sets = [ + {"cdn": u.get("cdn"), "url": u.get("url"), "consumptionId": u.get("consumptionId")} + for u in main.get("urls", []) if u.get("url") + ] + converted["vodPlaybackUrls"] = { + "result": { + "playbackUrls": { + "urlSets": url_sets, + "urlMetadata": main.get("manifestMetadata", {}), + } + } + } + return converted + + def get_manifest(self, title, video_codec, bitrate_mode, quality, hdr, ignore_errors=False, + protocol="DASH", use_playlisted=False, retries=3): + if use_playlisted and not self.device_token: + self.log.warning(" - Playlisted (LivingRoomPlayer) path needs a registered device; using classic path") + use_playlisted = False + + for attempt in range(retries): + if attempt == 0 or not self.playbackInfo: + self.playbackEnvelope = self.playbackEnvelope_data(title) + else: + self.log.debug(f" + Manifest retry {attempt + 1}/{retries}; refreshing playback envelope") + try: + self.playbackInfo["playbackExperienceMetadata"]["expiryTime"] = 0 + except Exception: + pass + self.playbackInfo = self.playbackEnvelope_update(self.playbackInfo) + self.playbackEnvelope = ( + (self.playbackInfo or {}).get("playbackExperienceMetadata", {}).get("playbackEnvelope") + or self.playbackEnvelope + ) + + data_dict = self._build_manifest_payload( + title, video_codec, bitrate_mode, quality, hdr, protocol, use_playlisted + ) + + res = self.session.post( + url=self.endpoints["playback"], + params={ + "deviceID": self.device_id, + "deviceTypeID": self.device["device_type"], + "gascEnabled": str(self.pv).lower(), + "marketplaceID": self.region["marketplace_id"], + "uxLocale": "en_EN", + "firmware": 1, + "titleId": title.id, + "nerid": self.generate_nerid(), + }, + data=json.dumps(data_dict), + headers={ + "Authorization": f"Bearer {self.device_token}" if self.device_token else None, + }, + ) + + try: + manifest = res.json() + except json.JSONDecodeError: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + if ignore_errors: + return {} + self.log.error(f" - Amazon reported an error when obtaining the Playback Manifest\n{res.text}"); raise SystemExit(1) + if "vodPlaylistedPlaybackUrls" in manifest and "vodPlaybackUrls" not in manifest: + manifest = self._normalize_playlisted_manifest(manifest) + + vod = manifest.get("vodPlaybackUrls", {}) + + if video_codec == "AV1" and "error" in vod: + self.log.warning(f" - AV1 manifest not available: {vod['error'].get('message', 'unknown error')}") + return {} + + if "error" in vod: + if ignore_errors: + return {} + message = vod["error"]["message"] + self.log.error(f" - Amazon reported an error when obtaining the Playback Manifest: {message}"); raise SystemExit(1) + for resource in ("PlaybackUrls", "AudioVideoUrls"): + err = manifest.get("errorsByResource", {}).get(resource) + if err and err.get("errorCode") not in (None, "PRS.NoRights.NotOwned"): + if ignore_errors: + return {} + self.log.error( + f" - Amazon had an error with the {resource}: {err.get('message')} [{err.get('errorCode')}]" + ); raise SystemExit(1) + + return manifest + + return {} + + def get_widevine_service_certificate(self, **_: Any) -> str: + return self.config["certificate"] + + def get_widevine_license(self, challenge: bytes, title: Title_T, track, **_) -> str: + return self._get_license(challenge, title, track, widevine=True) + + def get_playready_license(self, challenge: bytes, title: Title_T, track, **_) -> str: + return self._get_license(challenge, title, track, widevine=False) + + def _get_license(self, challenge: bytes, title: Title_T, track, widevine: bool): + challenge_bytes = challenge if isinstance(challenge, bytes) else challenge.encode("utf-8") + encoded_challenge = base64.b64encode(challenge_bytes).decode("utf-8") + + amzn = track.data.get("amzn", {}) if isinstance(getattr(track, "data", None), dict) else {} + envelope = amzn.get("envelope") or self.playbackEnvelope + handoff = amzn.get("handoff") or self.session_handoff_token + + data_lic = { + "licenseChallenge": encoded_challenge, + "playbackEnvelope": envelope, + } + if widevine: + data_lic["includeHdcpTestKey"] = True + + if self.device_token: + d = self.device + software = { + "application": {"name": d.get("app_name", "AIV"), "version": d.get("firmware") or d.get("app_version", "1.0")}, + "client": {"id": None}, + "operatingSystem": {"name": d.get("os_name", "Android"), "version": str(d.get("os_version", "28"))}, + "player": {"name": "Android UIPlayer SDK", "version": "4.1.18"}, + "renderer": {"drmScheme": "WIDEVINE" if widevine else "PLAYREADY", "name": "MCMD"}, + } + if d.get("firmware_version"): + software["firmware"] = {"version": str(d["firmware_version"])} + data_lic.update({ + "capabilityDiscriminators": { + "discriminators": { + "hardware": { + "chipset": d.get("device_chipset", ""), + "manufacturer": d.get("manufacturer", ""), + "modelName": d.get("device_model", ""), + }, + "software": software, + }, + "version": 1, + }, + "deviceCapabilityFamily": "AndroidPlayer", + "packagingFormat": "SMOOTH_STREAMING" if (isinstance(getattr(track, "data", None), dict) and "ism" in track.data) else "MPEG_DASH", + }) + if getattr(track, "kid", None): + try: + data_lic["keyId"] = str(uuid.UUID(str(track.kid))).upper() + except (ValueError, AttributeError): + pass + else: + if not handoff: + self.log.error(" - No sessionHandoffToken available."); raise SystemExit(1) + data_lic.update({ + "deviceCapabilityFamily": "WebPlayer", + "sessionHandoffToken": handoff, + }) + + endpoint = self.endpoints["license_wv"] if widevine else self.endpoints["license_pr"] + + res = self.session.post( + url=endpoint, + params={ + "deviceID": self.device_id, + "deviceTypeID": self.device["device_type"], + "gascEnabled": str(self.pv).lower(), + "marketplaceID": self.region["marketplace_id"], + "uxLocale": "en_EN", + "firmware": 1, + "titleId": title.id, + "nerid": self.generate_nerid(), + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {self.device_token}" if self.device_token else None, + }, + json=data_lic + ).json() + + if "errorsByResource" in res: + error = res["errorsByResource"] + code = error.get("errorCode") or error.get("type") or "Unknown" + if code == "PRS.NoRights.AnonymizerIP": + self.log.error(" - Amazon detected a Proxy/VPN and refused to return a license."); raise SystemExit(1) + self.log.error(f" - Amazon reported an error during the License request: [{code}]"); raise SystemExit(1) + + if "error" in res: + self.log.error(f" - License Error: {res['error']['message']}"); raise SystemExit(1) + primary_key = "widevineLicense" if widevine else "playReadyLicense" + lic = res.get(primary_key) + if isinstance(lic, dict) and lic.get("license"): + return lic["license"] + + for key, val in res.items(): + if "license" in key.lower() and isinstance(val, dict) and val.get("license"): + return val["license"] + if "license" in key.lower() and isinstance(val, str) and val: + return val + + self.log.error( + f" - License response did not contain a '{primary_key}'. " + f"Response keys: {list(res.keys())}. Raw (truncated): {json.dumps(res)[:600]}" + ) + raise SystemExit(1) + + def _tag_amzn_tracks(self, track_iterable, manifest: dict) -> None: + token = (manifest.get("sessionization") or {}).get("sessionHandoffToken") + envelope = self.playbackEnvelope + for t in track_iterable: + try: + if not isinstance(t.data, dict): + continue + amzn = t.data.setdefault("amzn", {}) + if token: + amzn["handoff"] = token + if envelope: + amzn["envelope"] = envelope + except Exception: + pass + + def _post_process_audio(self, audio_tracks) -> None: + for audio in audio_tracks: + try: + data = getattr(audio, "data", None) + if not isinstance(data, dict) or "dash" not in data: + continue + adaptation_set = data["dash"].get("adaptation_set") + representation = data["dash"].get("representation") + if adaptation_set is None: + continue + + subtype = (adaptation_set.get("audioTrackSubtype") or "").lower() + if "descriptive" in subtype: + audio.descriptive = True + if "boosteddialog" in subtype: + audio.bitrate = 1 + if not audio.joc: + for elem in (adaptation_set, representation): + if elem is None: + continue + try: + props = elem.findall("SupplementalProperty") + elem.findall("EssentialProperty") + except Exception: + props = [] + for prop in props: + scheme = prop.get("schemeIdUri", "") + value = (prop.get("value", "") or "").upper() + if scheme == "tag:dolby.com,2018:dash:EC3_ExtensionType:2018" and value == "JOC": + audio.joc = 16 + break + if audio.joc: + break + except Exception: + continue + + def manage_session(self, track) -> None: + try: + handoff = None + if isinstance(getattr(track, "data", None), dict): + handoff = (track.data.get("amzn") or {}).get("handoff") + handoff = handoff or self.session_handoff_token + if not handoff or not isinstance(handoff, str): + self.log.debug("No session handoff token available; skipping keep-alive session") + return + + refreshed = self.playbackEnvelope_update(self.playbackInfo) or self.playbackInfo or {} + envelope = (refreshed.get("playbackExperienceMetadata", {}) or {}).get("playbackEnvelope") \ + or self.playbackEnvelope + if not envelope: + self.log.debug("No playback envelope for keep-alive session; skipping") + return + + def session_params(): + return { + "deviceID": self.device_id, + "deviceTypeID": self.device["device_type"], + "gascEnabled": str(self.pv).lower(), + "marketplaceID": self.region["marketplace_id"], + "uxLocale": "en_EN", + "firmware": "1", + "version": "1", + "nerid": self.generate_nerid(), + } + + headers = { + "Content-Type": "application/json", + "accept": "application/json", + "x-request-priority": "CRITICAL", + "x-retry-count": "0", + } + + progress = round(random.uniform(0, 10), 6) + step = 3 + start_time = datetime.utcnow().isoformat(timespec="milliseconds") + "Z" + + res = self.session.post( + url=self.endpoints["opensession"], + params=session_params(), + headers=headers, + json={ + "sessionHandoff": handoff, + "playbackEnvelope": envelope, + "streamInfo": { + "eventType": "START", + "streamUpdateTime": progress, + "vodProgressInfo": { + "currentProgressTime": f"PT{progress:.6f}S", + "timeFormat": "ISO8601DURATION", + }, + }, + "userWatchSessionId": str(uuid.uuid4()), + }, + ) + if res.status_code != 200: + self.log.debug(f"Keep-alive: unable to open session ({res.status_code})") + return + session_token = res.json().get("sessionToken") + if not session_token: + return + + time.sleep(step) + update_time = (datetime.fromisoformat(start_time[:-1]) + timedelta(seconds=step)) \ + .isoformat(timespec="milliseconds") + "Z" + + res = self.session.post( + url=self.endpoints["updatesession"], + params=session_params(), + headers=headers, + json={ + "sessionToken": session_token, + "streamInfo": { + "eventType": "PAUSE", + "streamUpdateTime": update_time, + "vodProgressInfo": { + "currentProgressTime": f"PT{progress + step:.6f}S", + "timeFormat": "ISO8601DURATION", + }, + }, + }, + ) + if res.status_code != 200: + self.log.debug(f"Keep-alive: unable to update session ({res.status_code})") + return + session_token = res.json().get("sessionToken", session_token) + + res = self.session.post( + url=self.endpoints["closesession"], + params=session_params(), + headers=headers, + json={ + "sessionToken": session_token, + "streamInfo": { + "eventType": "STOP", + "streamUpdateTime": update_time, + "vodProgressInfo": { + "currentProgressTime": f"PT{progress + step:.6f}S", + "timeFormat": "ISO8601DURATION", + }, + }, + }, + ) + if res.status_code == 200: + self.log.info(" + True-region playback session completed") + else: + self.log.debug(f"Keep-alive: unable to close session ({res.status_code})") + except Exception as e: + self.log.debug(f"Keep-alive session failed: {e}") + + @staticmethod + def _usable_manifest(manifest: dict) -> bool: + return bool( + (manifest or {}).get("vodPlaybackUrls", {}).get("result", {}) + .get("playbackUrls", {}).get("urlSets") + ) + + def choose_manifest(self, manifest: dict, cdn=None): + if not manifest or "vodPlaybackUrls" not in manifest: + return {} + + url_sets = ( + manifest["vodPlaybackUrls"].get("result", {}).get("playbackUrls", {}).get("urlSets", []) + ) + if not url_sets: + return {} + + if cdn: + cdn = cdn.lower() + return next((x for x in url_sets if (x.get("cdn") or "").lower() == cdn), {}) + + return secrets.choice(url_sets) + + @staticmethod + def generate_nerid(length=24): + chars = string.ascii_letters + string.digits + return "".join(secrets.choice(chars) for _ in range(length)) + + @staticmethod + def clean_mpd_url(mpd_url, optimise=False): + if optimise: + return mpd_url.replace("~", "") + "?encoding=segmentBase" + if match := re.match(r"(https?://.*/)d.?/.*~/(.*)", mpd_url): + mpd_url = "".join(match.groups()) + else: + try: + mpd_url = "".join( + re.split(r"(?i)(/)", mpd_url)[:5] + re.split(r"(?i)(/)", mpd_url)[9:] + ) + except IndexError: + pass + return mpd_url + + def get_region(self) -> dict: + domain_region = self.get_domain_region() + if not domain_region: + return {} + + region = self.config["regions"].get(domain_region) + if not region: + self.log.error(f" - There's no region configuration data for the region: {domain_region}"); raise SystemExit(1) + + 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: + self.log.error(" - Failed to get PrimeVideo region"); raise SystemExit(1) + 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): + tlds = [tldextract.extract(x.domain) for x in self.session.cookies if x.domain_specified] + tld = next((x.suffix for x in tlds if x.domain.lower() in ("amazon", "primevideo")), None) + if tld: + tld = tld.split(".")[-1] + region = {"com": "us", "uk": "gb"}.get(tld, tld) + + if region == "us": + lc_cookie = next( + (x.value for x in self.session.cookies + if x.name in ("lc-main-av", "lc-main") and x.domain_specified), + None + ) + if lc_cookie: + parts = lc_cookie.replace("-", "_").split("_") + if len(parts) >= 2: + country = parts[-1].lower() + if country not in ("us", ""): + mapped = {"uk": "gb"}.get(country, country) + if mapped in self.config.get("regions", {}): + region = mapped + + return region + + def prepare_endpoint(self, name: str, uri: str, region: dict) -> str: + if name in ("playback", "license_wv", "license_pr", "xray", + "refreshplayback", "opensession", "updatesession", "closesession", + "configuration"): + return f"https://{region['base_manifest']}{uri}" + if name in ("ontv", "devicelink", "details", "getDetailWidgets", "metadata"): + if self.pv: + host = "www.primevideo.com" + else: + host = f"{region['base']}/gp/video" if name == "metadata" else region["base"] + return f"https://{host}{uri}" + if name in ("codepair", "register", "token"): + base_api = region.get("base_api") or self.config["regions"]["us"]["base_api"] + return f"https://{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 register_device(self) -> None: + _profile = self.profile or "default" + self.device = dict((self.config.get("device") or {}).get(_profile, {})) + + identity_cache = Cacher("AMZN") + identity_key = f"device_identity_{_profile}" + cached_identity = identity_cache.get(identity_key) + + if cached_identity and cached_identity.data: + identity = cached_identity.data + self.log.debug(" + Using cached device identity") + else: + unique_serial = secrets.token_hex(8) + base_name = self.device.get("device_name", "%FIRST_NAME%'s Shield TV") + clean_name = re.sub(r"%DUPE_STRATEGY[^%]*%", "", base_name).rstrip() + suffix = secrets.token_hex(2).upper() + unique_name = f"{clean_name}-{suffix}" + identity = {"device_serial": unique_serial, "device_name": unique_name} + cached_identity = identity_cache.get(identity_key) + cached_identity.set(identity, int(time.time()) + 60 * 60 * 24 * 3650) + self.log.info(f" + Generated unique device identity: serial={unique_serial}, name={unique_name!r}") + + self.device["device_serial"] = identity["device_serial"] + self.device["device_name"] = identity["device_name"] + + device_hash = hashlib.md5(json.dumps(self.device, sort_keys=True).encode()).hexdigest()[0:6] + device_cache_path = f"device_tokens_{_profile}_{device_hash}" + + 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: + self.log.error(f" - A device serial is required in the config, try: {os.urandom(8).hex()}"); raise SystemExit(1) + + class DeviceRegistration: + def __init__(self, device: dict, endpoints: dict, cache_path: str, session: requests.Session, log): + 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 + + cached_data = self.cache.get(self.cache_path) + + if cached_data and cached_data.data: + if cached_data.data.get("expires_in", 0) > int(time.time()): + self.log.info(" + Using cached device bearer") + self.bearer = cached_data.data["access_token"] + else: + self.log.info("Cached device bearer expired, refreshing...") + refresh_token = cached_data.data.get("refresh_token") + refreshed_tokens = self.refresh(self.device, refresh_token) if refresh_token else None + if refreshed_tokens and refreshed_tokens.get("access_token"): + refreshed_tokens["refresh_token"] = refreshed_tokens.get("refresh_token") or refresh_token + expires_seconds = int(refreshed_tokens.get("expires_in") or 3600) + refreshed_tokens["expires_in"] = int(time.time()) + expires_seconds + cached_data.set(refreshed_tokens, refreshed_tokens["expires_in"]) + self.bearer = refreshed_tokens["access_token"] + else: + self.log.info(" + Re-registering device bearer") + self.bearer = self.register(self.device) + else: + self.log.info(" + Registering new device bearer") + self.bearer = self.register(self.device) + + def register(self, device: dict) -> str: + code_pair = self.get_code_pair(device) + public_code = code_pair["public_code"] + + self.log.info(f" + Visit https://www.primevideo.com/mytv and enter the code: {public_code}") + self.log.info(" Waiting for authorisation (up to 5 minutes)") + + interval = 10 + deadline = int(time.time()) + 300 + + while int(time.time()) < deadline: + res = 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 + ) + data = res.json() + + if res.status_code == 200 and "success" in data.get("response", {}): + break + + error_code = data.get("response", {}).get("error", {}).get("code", "") + if error_code == "Unauthorized": + time.sleep(interval) + continue + else: + self.log.error(f"Unable to register: {res.text}"); raise SystemExit(1) + else: + self.log.error("Device registration timed out. Code not approved in time."); raise SystemExit(1) + + bearer = data["response"]["success"]["tokens"]["bearer"] + expires_val = bearer.get("expires_in", 3600) + if isinstance(expires_val, dict): + expires_val = expires_val.get("value", 3600) + bearer_data = { + "access_token": bearer["access_token"], + "refresh_token": bearer.get("refresh_token", ""), + "expires_in": int(time.time()) + int(expires_val), + } + keyed_cache = self.cache.get(self.cache_path) + keyed_cache.set(bearer_data, int(time.time()) + int(expires_val)) + + self.log.info(" + Device registered and token cached successfully") + return bearer_data["access_token"] + + def refresh(self, device: dict, refresh_token: str): + try: + res = self.session.post( + url=self.endpoints["token"], + json={ + "app_name": device["app_name"], + "app_version": device["app_version"], + "source_token_type": "refresh_token", + "source_token": refresh_token, + "requested_token_type": "access_token" + } + ).json() + except Exception as e: + self.log.warning(f"Device token refresh request failed ({e}); re-registering.") + return None + + if "error" in res: + self.log.warning( + f"Could not refresh device token ({res.get('error_description') or res.get('error')}); re-registering." + ) + return None + + return res + + def get_csrf_token(self) -> str: + res = self.session.get(self.endpoints["ontv"]) + if 'name="appAction" value="SIGNIN"' in res.text or "SIGNIN_PWD_COLLECT" in res.text: + self.log.error("Cookies are signed out, cannot get ontv CSRF token.") + raise SystemExit(1) + for match in re.finditer(r'', res.text, re.DOTALL): + try: + prop = json.loads(match.group(1)) + token = prop.get("props", {}).get("codeEntry", {}).get("token") + if token: + return token + token = prop.get("codeEntry", {}).get("token") + if token: + return token + except Exception: + pass + ce_idx = res.text.find('"codeEntry"') + if ce_idx != -1: + snippet = res.text[ce_idx:ce_idx + 2000] + m2 = re.search(r'"token"\s*:\s*"([^"]+)"', snippet) + if m2: + return m2.group(1) + self.log.error("Unable to get ontv CSRF token") + raise SystemExit(1) + + def get_code_pair(self, device: dict) -> dict: + 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: + self.log.error(f"Unable to get code pair: {res['error']}"); raise SystemExit(1) + return res + + 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 \ No newline at end of file diff --git a/AMZN/config.yaml b/AMZN/config.yaml new file mode 100644 index 0000000..8476e26 --- /dev/null +++ b/AMZN/config.yaml @@ -0,0 +1,149 @@ +certificate: | + CAUSwgUKvAIIAxIQCuQRtZRasVgFt7DIvVtVHBi17OSpBSKOAjCCAQoCggEBAKU2UrYVOSDlcXajWhpEgGhqGraJtFdUPgu6plJGy9ViaRn5mhyXON5PXm + w1krQdi0SLxf00FfIgnYFLpDfvNeItGn9rcx0RNPwP39PW7aW0Fbqi6VCaKWlR24kRpd7NQ4woyMXr7xlBWPwPNxK4xmR/6UuvKyYWEkroyeIjWHAqgCjC + mpfIpVcPsyrnMuPFGl82MMVnAhTweTKnEPOqJpxQ1bdQvVNCvkba5gjOTbEnJ7aXegwhmCdRQzXjTeEV2dO8oo5YfxW6pRBovzF6wYBMQYpSCJIA24ptAP + /2TkneyJuqm4hJNFvtF8fsBgTQQ4TIhnX4bZ9imuhivYLa6HsCAwEAAToPYW1hem9uLmNvbS1wcm9kEoADETQD6R0H/h9fyg0Hw7mj0M7T4s0bcBf4fMhA + Rpwk2X4HpvB49bJ5Yvc4t41mAnXGe/wiXbzsddKMiMffkSE1QWK1CFPBgziU23y1PjQToGiIv/sJIFRKRJ4qMBxIl95xlvSEzKdt68n7wqGa442+uAgk7C + XU3uTfVofYY76CrPBnEKQfad/CVqTh48geNTb4qRH1TX30NzCsB9NWlcdvg10pCnWSm8cSHu1d9yH+2yQgsGe52QoHHCqHNzG/wAxMYWTevXQW7EPTBeFy + SPY0xUN+2F2FhCf5/A7uFUHywd0zNTswh0QJc93LBTh46clRLO+d4RKBiBSj3rah6Y5iXMw9N9o58tCRc9gFHrjfMNubopWHjDOO3ATUgqXrTp+fKVCmsG + uGl1ComHxXV9i1AqHwzzY2JY2vFqo73jR3IElr6oChPIwcNokmNc0D4TXtjE0BoYkbWKJfHvJJihzMOvDicWUsemVHvua9/FBtpbHgpbgwijFPjtQF9Ldb + 8Swf + +dtid_dict: [ + "A3EFHJ9BGBJ8L2", "A3VN4E5F7BBC7S", "A28RQHJKHM2A2W", "AFOQV1TK6EU6O","A1IJNVP3L4AY8B", "A2Z1NVLU6LCAUO", + "A2E50Q8IVZ79QG", "A17PYKRA4ES6YB", "AFRP7VQQ7US69", "A2VZ790DVVI91K", "A2IK56KYGDHUVQ", "A1EXS4KWCX7GYC", + "ANSTXZRNSPRXT", "A1KF4O3GA2MPZU", "A2XEBUI9EJ55OK", "A7YWW3KUHA54O", "A3DOA4P2WFIK9D", "ABJRG3JXAY5JL", + "AI00TLC8V1PCT", "A2YWQEZX3UC3J1", "AM08S97P8ESGT", "A234HDVPYTUVNS", "AJ3B6LDE2HP5J", "A2GZIBBOG0DCV4", + "A3MEKX9EL7SW8T", "A1GXILNJBV9CU7", "ANHPQG9GCMO4A", "A2CBAN119017AE", "A3FO6QR7E7PFQX", "A27OOP63XLO9TI", + "A2EOVT31LL6KPV", "A1FNA83TYYU3QK", "A3JB7490MR9K86", "A2TIAYXTNQWU3T", "ADP5BND5THPTX", "A1H8RTR0E3Y362", + "AUNIXHOL9EVMI", "AZKAGPPWORIRY", "A324X3KDTS7NYA", "A390CW53E1P0G4", "A2D0X18EHNKEOJ", "A3JN21B5ZOWUAN", + "A1TG8VNKP4DSQR", "A2XZMRZUFPEDN4", "A2LJ4A527WOX9J", "A2RJLFEH0UEKI9", "AE5DW8GVLP9NX", "AIE8AMJ60B7OK", + "A3ZKCWKG4097P", "A2H1I0AR67NWAC", "A3QXXOBP9MU5LY", "A25521KS9QCAMD", "AGQHFIWNI20PO", "A27XSKZJJPVQA4", + "A1LCAPNEM1C36Z", "A31POKKHZJR1J4", "AOLDXB6WYN0UM", "A3SSWQ04XYPXBH", "A1BSQJM6E77NJE", "A2TX61L00VISA5", + "AO4A5QLO9663Q", "A15MU3EQ4XZ3Y5", "A3IWJ2DYJQRA3T", "A1Q878J3NE8P81", "AAJB0R7QJO84W", "A2M1CHUCI6RHN8", + "A6IUL9CVJZXRR", "A2RGJ95OVLR12U", "A1G2XVSR1VA5DI", "A1S15DUFSI8AUG", "ALYWZPYF4JAIT", "A2M4YX06LWP8WI", + "A2O85NMVNLPKVN", "A3L0T0VL9A921N", "A1J16TEDOYCZTN", "A1Q7QCGNMXAKYW", "A38EHHIB10L47V", "A3R9S4ZZECZ6YL", + "A1C66CX2XD756O", "A1ZB65LA390I4K", "AVU7CPPF2ZRAS", "ATNLRCEBX3W4P", "A2N49KXGVA18AR", "A271DR1789MXDS", + "A1TD5Z1R8IWBHA", "A1DOD0Z74XEFYC", "A17AIVOKIKR4QQ", "A1S310WB67VFPY", "A2QCPPMSOLGVZE", "A1NPAGU1M4PA7Z", + "A3ORLONYQTBTOZ", "A3M7PA8JXKE627", "A71I8788P1ZV8", "A2V9UEGZ82H4KZ", "A3URJAABOST7NW", "A2E0SNTXJVT7WK", + "ADVBD696BHNV5", "A12GXV8XMS007S", "A2LWARUGJLBYEW", "A2GFL5ZMWNE0PX", "AKPGW064GI9HE", "A3HF4YRA2L7XGC", + "AGHZIK8D6X7QR", "A1F8D55J0FWDTN", "A1P7E7V3FCZKU6", "A1NL4BVLQ4L3N3", "A10A33FOX2NUBK", "AWZZ5CVHX2CD", + "A4ZP7ZC4PI6TO", "A1Z88NGR2BK6A2", "A2MDL5WTJIU8SZ", "AP4RS91ZQ0OOI", "AFF5OAL5E3DIU", "A2HYAJ0FEWP6N3", + "A3SUJTTQGF9GNF", "A346DYAAR4WSNS", "A93SQJNJQLDSS", "A2JKHJ0PX4J3L3", "A2WJI2JG7UW2O1", "ARJHEDRXLP6DM", + "A1AGU0A4YA5RF9", "A2WV8TTM37P2CB", "AN630UQPG2CA4", "A30OJ8LMIAF6GP", "A8MCGN45KMHDH", "A33S43L213VSHQ", + "A2NYIDFQSJW39B", "A31DTMEEVDDOIV", "A2FDUYD6UQ1BQ", "A3MTL1JKF2IXY3", "AK6OCP5ZLUJI1", "A3JTVZS31ZJ340", + "A43PXU4ZN2AL1", "A1OTX5GMM5144Z", "A2Z1NVLU6LCAUO", "A43PXU4ZN2AL1", "A1OTX5GMM5144Z", "A2Z1NVLU6LCAUO" +] + +device: + default: + manufacturer: LG ELECTRONICS INC. + device_chipset: Mediatek + domain: Device + app_name: AIV + os_name: Android + app_version: '3.19.5' + device_model: OLED55C3PUA + os_version: '23' + device_serial: '95047855cf573479341d134104cfa312' + device_type: A71I8788P1ZV8 + device_name: "%FIRST_NAME%'s%DUPE_STRATEGY_1ST% LG-webOS-TV" + software_version: '23.20.61' + firmware: 'webOS 8.0.0/2304' + firmware_version: 'LG Browser/8.00.00 (webOS.TV-2023), _TV_O18/23.20.61 (LG, OLED55C3PUA, wireless)' + user_agent: 'Dalvik/2.1.0 (Linux; U; Android 6.0.1; OLED55C3PUA Build/MMB29M)' + +device_types: + browser: 'AOAGZA014O5RE' + tv_generic: 'A2SNKIF736WF4T' + pc_app: 'A1RTAM01W29CUP' + mobile_app: 'A43PXU4ZN2AL1' + echo: 'A7WXQPH584YP' + echo_dot: 'A32DOYMUN6DTXA' + echo_studio: 'A3RBAYBE7VM004' + fire_7: 'A2M4YX06LWP8WI' + fire_hd_8: 'A1C66CX2XD756O' + fire_hd_8_plus_2020: 'AVU7CPPF2ZRAS' + fire_hd_10: 'A1ZB65LA390I4K' + fire_tv: 'A2E0SNTXJVT7WK' + fire_tv_gen2: 'A12GXV8XMS007S' + fire_tv_cube: 'A2JKHJ0PX4J3L3' + fire_tv_stick_gen1: 'ADVBD696BHNV5' + fire_tv_stick_gen2: 'A2LWARUGJLBYEW' + fire_tv_stick_4k: 'A2GFL5ZMWNE0PX' + fire_tv_stick_4k_gen3: 'AKPGW064GI9HE' + nvidia_shield: 'A1KAXIG6VXSG8Y' + +endpoints: + configuration: '/acm/GetConfiguration/WebClient' + details: '/gp/video/api/getDetailPage' + getDetailWidgets: '/gp/video/api/getDetailWidgets' + playback: '/playback/prs/GetVodPlaybackResources' + metadata: '/api/enrichItemMetadata' + refreshplayback: '/playback/tags/getRefreshedPlaybackEnvelope' + license_wv: '/playback/drm-vod/GetWidevineLicense' + license_pr: '/playback/drm-vod/GetPlayReadyLicense' + opensession: '/cdp/playback/pes/StartSession' + updatesession: '/cdp/playback/pes/UpdateSession' + closesession: '/cdp/playback/pes/StopSession' + xray: '/swift/page/xray' + ontv: '/gp/video/ontv/code' + devicelink: '/gp/video/api/codeBasedLinking' + codepair: '/auth/create/codepair' + register: '/auth/register' + token: '/auth/token' + +regions: + us: + base: 'www.amazon.com' + base_api: 'api.amazon.com' + base_manifest: 'atv-ps.amazon.com' + marketplace_id: 'ATVPDKIKX0DER' + + fr: + base: 'www.amazon.fr' + base_api: 'api.amazon.fr' + base_manifest: 'atv-ps-eu.primevideo.com' + marketplace_id: 'A13V1IB3VIYZZH' + + gb: + base: 'www.amazon.co.uk' + base_api: 'api.amazon.co.uk' + base_manifest: 'atv-ps-eu.amazon.co.uk' + marketplace_id: 'A1F83G8C2ARO7P' # A2IR4J4NTCP2M5 + + es: + base: 'www.amazon.es' + base_api: 'api.amazon.es' + base_manifest: 'atv-ps-eu.primevideo.com' + marketplace_id: 'A1RKKUPIHCS9HS' + + it: + base: 'www.amazon.it' + base_api: 'api.amazon.it' + base_manifest: 'atv-ps-eu.primevideo.com' + marketplace_id: 'A3K6Y4MI8GDYMT' + + de: + base: 'www.amazon.de' + base_api: 'api.amazon.de' + base_manifest: 'atv-ps-eu.amazon.de' + marketplace_id: 'A1PA6795UKMFR9' + + au: + base: 'www.amazon.com.au' + base_api: 'api.amazon.com.au' + base_manifest: 'atv-ps-fe.amazon.com.au' + marketplace_id: 'A3K6Y4MI8GDYMT' + + jp: + base: 'www.amazon.co.jp' + base_api: 'api.amazon.co.jp' + base_manifest: 'atv-ps-fe.amazon.co.jp' + marketplace_id: 'A1VC38T7YXB528' + + pl: + base: 'www.amazon.com' + base_api: 'api.amazon.com' + base_manifest: 'atv-ps-eu.primevideo.com' + marketplace_id: 'A3K6Y4MI8GDYMT' \ No newline at end of file