import base64
import hashlib
import json
import sys
import re
import os
import uuid
import secrets
import string
import jsonpickle
import time
from pathlib import Path
from collections.abc import Generator
from datetime import datetime, timezone, timedelta
from collections import defaultdict
from http.cookiejar import CookieJar
from typing import Optional, Union
from urllib.parse import urlencode, quote
from unshackle.core.downloaders import n_m3u8dl_re
from unshackle.core.downloaders import requests
import click
import random
from langcodes import Language
# import tldextract
from click.core import ParameterSource
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH, ISM
from unshackle.core.search_result import SearchResult
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, Subtitle, Tracks, Video, Audio
from pywidevine.device import DeviceTypes
from pyplayready.cdm import Cdm as PlayReadyCdm
from unshackle.core.cacher import Cacher

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) FHD@L3, Maintains their own license server like Netflix, be cautious.

    \b
    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/381997-amazon-video-asin-display
    
    unshackle dl --list -z uk -q 1080 Amazon B09SLGYLK8
    """
    ALIASES = ["AMZN", "amazon", "amzn", "Amazon"]
    TITLE_RE = re.compile(
        r"^(?:https?://(?:www\.)?(?P<domain>amazon\.(?P<region>com|co\.uk|de|co\.jp)|primevideo\.com)"
        r"(?:(?:/.+)?/|(?:/[^?]*)?(?:\?gti=)?)?)"
        r"(?P<title_id>[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)"
    )
    
    CDN_PREFERENCE = ["Akamai", "Cloudfront", "Level3", "Limelight", "Fastly"]
    
    REGION_TLD_MAP = {
        "au": "com.au",
        "br": "com.br",
        "jp": "co.jp",
        "mx": "com.mx",
        "tr": "com.tr",
        "gb": "co.uk",
        "us": "com",
    }
    # Update the VIDEO_RANGE_MAP to handle HYBRID requests:
    VIDEO_RANGE_MAP = {
        "SDR": "None",
        "HDR10": "Hdr10",
        "HDR10+": "Hdr10",
        "DV": "DolbyVision",
        "HYBRID": "Hdr10",  # Start with HDR10, DV will be fetched separately
    }
    AUDIO_CODEC_MAP = {
        "AAC": "mp4a",
        "EC3": "ec-3",
    }
    
    
    AUDIO_EDITION_MAP = {
        "boosteddialoglow": "Dialogue Boost: Low",
        "boosteddialogmedium": "Dialogue Boost: Medium",
        "boosteddialoghigh": "Dialogue Boost: High",
    }

    @staticmethod
    @click.command(name="AMZN", short_help="https://amazon.com, https://primevideo.com")
    @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="Akamai", type=str,
                  help="CDN to download from, defaults to the CDN with the highest weight set by Amazon.") # Akamai, Cloudfront
    # 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="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("-nr", "--no_true_region",is_flag=True, default=False,
                  help="Skip checking true current region.")
    @click.option("-atmos", "--atmos",is_flag=True, default=False,
                  help="Get atmos Audio")
    @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, no_true_region: bool, atmos: bool):
        self.title = 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.atmos = atmos
        self.cdm = ctx.obj.cdm
        self.playready = isinstance(self.cdm, PlayReadyCdm)
        self.no_true_region = no_true_region
        super().__init__(ctx)
        assert ctx.parent is not None

        
        quality_value = ctx.parent.params.get("quality") or 1080
        if isinstance(quality_value, list):
            quality_value = quality_value[0] if quality_value else 1080
        
        self.quality = int(quality_value)
        
        self.vcodec = ctx.parent.params.get("vcodec")
        self.vcodec = self.vcodec[0] if len(self.vcodec)>0 else Video.Codec.AVC  if isinstance(self.vcodec,list) else self.vcodec
        
        self.vcodec = self.vcodec.upper() or Video.Codec.AVC
        self.acodec = (ctx.parent.params.get("acodec") or "").upper() or Audio.Codec.EC3

        

        # Get range parameter for HDR support
        range_param = ctx.parent.params.get("range_")
        self.range = range_param[0].name if range_param else "SDR"
        # NEW: Check if HYBRID mode is requested
        self.hybrid_mode = any(r.name == "HYBRID" for r in range_param) if range_param else False

        if self.config is None:
            raise Exception("Config is missing!")
        else:
            profile_name = ctx.parent.params.get("profile")
            if profile_name is None:
                profile_name = "default"
            self.profile = profile_name

        self.region: dict[str, str] = {}
        self.endpoints: dict[str, str] = {}
        self.device: dict[str, str] = {}

        self.pv = False
        self.rpv = False
        self.event = False
        self.device_token = None
        self.device_id: None
        self.customer_id = None

        self.client_id = "f22dbddb-ef2c-48c5-8876-bed0d47594fd"  # browser client id

        if self.vquality_source != ParameterSource.COMMANDLINE:
            if 0 < self.quality <= 576 and self.range == "SDR":
                self.log.info(" + Setting manifest quality to SD")
                self.vquality = "SD"

            if self.quality > 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 = Video.Codec.HEVC

        if self.bitrate_source != ParameterSource.COMMANDLINE:
            if self.vcodec == Video.Codec.HEVC 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")
            elif self.vcodec in ["AV1"] and self.range == "SDR" and self.bitrate != "CVBR":
                self.bitrate = "CVBR"
                self.log.info(f" + Changed bitrate mode to CVBR to be able to get {self.vcodec} 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


    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
        super().authenticate(cookies)
        if not cookies:
            raise EnvironmentError("Service requires Cookies for Authentication.")
        
        # Get domain from cookies first - this sets self.domain
        domain_region = self.get_domain_region()
        
        # Determine if this is Prime Video based on the actual URL domain, not title length
        match = self.TITLE_RE.match(self.title)
        if match:
            matched_domain = match.group("domain")
            if matched_domain and "primevideo" in matched_domain:
                self.pv = True
            elif matched_domain and "amazon" in matched_domain:
                self.pv = False
            else:
                # Fallback: check cookie domain
                self.pv = self.domain == "primevideo"
        else:
            # If no match, assume based on cookie domain
            self.pv = self.domain == "primevideo"
        
        self.log.info("Getting Account Region")
        self.region = self.get_region()

        if not self.region:
            sys.exit(" - Failed to get Amazon Account region")

        self.log.info(f" + Region: {self.region['code'].upper()}")
        
        if self.no_true_region:
            self.log.info(f" + Region: {self.region['code']}")

        # 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']}",
            "Referer": f"https://{self.region['base']}/"
        })

        self.device = (self.config.get("device") or {}).get(self.profile, {})
        if self.device and self.device["device_type"] not in set(self.config["dtid_dict"]):
            sys.exit(f"{self.device['device_type']} Banned from Amazon Prime, Use another one to avoid Amazon Account Ban !!!")
        if (self.quality > 1080 or self.range != "SDR") and self.vcodec == Video.Codec.HEVC and hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["CHROME"]:
            self.log.info(f"Using device to get UHD manifests")
            self.register_device()
        elif not self.device or hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["CHROME"] or self.vquality != "UHD":
            # 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 = "c3714f0d-59c9-4eb7-8b96-903f0f8c3619"
            self.device = {"device_type": self.config["device_types"]["browser"]}

            res = self.session.get(
                url=self.endpoints["configuration"],
                params = {
                    "deviceTypeID": self.device["device_type"],
                    "deviceID": "Web",
                }
            )

            if not res.status_code == 200:
                sys.exit(res.text)
            
            data = res.json()
            
            if not self.no_true_region:
                self.log.info(f" + Current Region: {data['requestContext']['currentTerritory']}")     
                self.region["marketplace_id"] = data["requestContext"]["marketplaceID"]
            
        else:
            res = self.session.get(
                url=self.endpoints["configuration"],
                params = {
                    "deviceTypeID": self.device["device_type"],
                    "deviceID": "Tv",
                }
            )

            if not res.status_code == 200:
                sys.exit(res.text)
            
            data = res.json()
            
            if not self.no_true_region:
                self.log.info(f" + Current Region: {data['requestContext']['currentTerritory']}")
                self.region["marketplace_id"] = data["requestContext"]["marketplaceID"]

            self.register_device()


    def search(self) -> Generator[SearchResult, None, None]:
        search = self.session.get(
            url=self.config["endpoints"]["search"], params={"q": self.title, "token": self.token}
        ).json()

        for result in search["entries"]:
            yield SearchResult(
                id_=result["id"],
                title=result["title"],
                label="SERIES" if result["programType"] == "series" else "MOVIE",
                url=result["url"],
            )
    

    def get_titles(self) -> Titles_T:
        # Extract just the title ID
        match = self.TITLE_RE.match(self.title)
        if not match:
            # If no match, check if it's just a plain ASIN (10 uppercase alphanumeric characters)
            if re.match(r'^[A-Z0-9]{10}$', self.title):
                # It's a plain ASIN, use it directly
                pass
            else:
                sys.exit("Invalid title URL or ID format")
        else:
            self.title = match.group("title_id")

        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:
            sys.exit(f"Unable to get title: {res.text} [{res.status_code}]")

        data = res.json()["widgets"]
        product_details = data.get("productDetails", {}).get("detail")

        if not product_details:
            error = res.json()["degradations"][0]
            sys.exit(f"Unable to get title: {error['message']} [{error['code']}]")

        titles_ = []

        if data["pageContext"]["subPageType"] == "Event":
            self.event = True

        if data["pageContext"]["subPageType"] in ("Movie", "Event"):
            card = data["productDetails"]["detail"]
            movie = Movie(
                id_=card["catalogId"],
                service=self.__class__,
                name=product_details["title"],
                year=card.get("releaseYear", ""),
                language=None,
                data=card
            )
            playbackEnvelope_info = self.playbackEnvelope_data([card["catalogId"]])

            for playbackInfo in playbackEnvelope_info:
                if movie.id == playbackInfo["titleID"]:
                    movie.data.update({"playbackInfo": playbackInfo})
            titles_.append(movie)

        else:
            if not self.single:
                try:
                    seasons = [x.get("titleID") for x in data["seasonSelector"]]
                    for season in seasons:
                        titles_.extend(self.get_episodes(season))
                except (KeyError, Exception) as e:
                    self.log.warning(f"Failed to parse seasons via seasonSelector ({e}), querying title directly...")
                    titles_.extend(self.get_episodes(self.title))
            else:
                titles_.extend(self.get_episodes(self.title))

        if not titles_:
            sys.exit(" - The profile used does not have the rights to this title.")

        # Set language
        
        original_lang = self.get_original_language(self.get_manifest(
            titles_[0],
            video_codec=self.vcodec,
            bitrate_mode=self.bitrate,
            quality=self.vquality,
            ignore_errors=True
        ))

        if original_lang:
            for title in titles_:
                title.language = Language.get(original_lang)
        else:
            for title in titles_:
                title.language = Language.get("en")

        # Deduplicate by id_ (catalogId)
        unique_titles = {}
        for t in titles_:
            if t.id not in unique_titles:
                unique_titles[t.id] = t
        titles_ = list(unique_titles.values())

        if data["pageContext"]["subPageType"] in ("Movie", "Event"):
            return Movies(titles_)
        return Series(titles_)

    @staticmethod
    def _restore_audio_language(tracks: Tracks) -> None:
        """
        langcodes.Language.get() strips region subtags, collapsing "es-419"
        and "es-ES" into plain "es".  Amazon stores the full BCP-47 tag in
        the adaptation set's audioTrackId attribute (e.g. "es-419_dd-joc_5.1").
        Read that first, then fall back to the raw lang attribute.
        """
        for audio in tracks.audio:
            adaptation_set = audio.data.get("dash", {}).get("adaptation_set", {})

            track_id = adaptation_set.get("audioTrackId", "")
            raw_lang = track_id.split("_")[0] if "_" in track_id else ""

            if not raw_lang:
                raw_lang = (
                    adaptation_set.get("lang")
                    or audio.data.get("dash", {}).get("representation", {}).get("lang")
                    or ""
                )

            if not raw_lang:
                continue

            try:
                restored = Language.get(raw_lang)
                current_tag  = audio.language.to_tag() if hasattr(audio.language, "to_tag") else str(audio.language)
                restored_tag = restored.to_tag()        if hasattr(restored,       "to_tag") else str(restored)

                if restored_tag != current_tag and restored_tag.startswith(current_tag):
                    audio.language = restored
            except Exception:
                pass
                    
    def get_tracks(self, title: Title_T) -> Tracks:
        """Get tracks with HYBRID mode support for HDR10+DV"""

        if self.hybrid_mode:
            self.log.info("HYBRID mode: Processing HDR10 and DV tracks")

            tracks = Tracks()
            bitrates = [self.orig_bitrate]

            if self.vcodec != Video.Codec.HEVC:
                bitrates = self.orig_bitrate.split('+')

            for bitrate in bitrates:
                hdr10_manifest = self.get_manifest(
                    title,
                    video_codec=self.vcodec,
                    bitrate_mode=bitrate,
                    quality=self.vquality,
                    hdr="HDR10",
                    ignore_errors=False
                )

                if "rightsException" in hdr10_manifest:
                    self.log.error(" - The profile used does not have the rights to this title.")
                    return tracks

                bitrate_actual = hdr10_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["bitrateAdaptation"]

                hdr10_chosen = self.choose_manifest(hdr10_manifest, self.cdn)
                if not hdr10_chosen:
                    self.log.warning(f"No {bitrate} HDR10 manifests available")
                    continue

                hdr10_url = self.clean_mpd_url(hdr10_chosen["url"], False)
                if self.event:
                    devicetype = self.device["device_type"]
                    hdr10_url = hdr10_chosen["url"]
                    hdr10_url = f"{hdr10_url}?amznDtid={devicetype}&encoding=segmentBase"

                self.log.info(f" + Downloading {bitrate} HDR10 Manifest")

                hdr10_streamingProtocol = hdr10_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
                hdr10_sessionHandoffToken = hdr10_manifest["sessionization"]["sessionHandoffToken"]

                if hdr10_streamingProtocol == "DASH":
                    hdr10_tracks = DASH.from_url(url=hdr10_url, session=self.session).to_tracks(language=title.language)
                elif hdr10_streamingProtocol == "SmoothStreaming":
                    hdr10_tracks = ISM.from_url(url=hdr10_url).to_tracks(language=title.language)
                else:
                    self.log.error(f"Unsupported manifest type: {hdr10_streamingProtocol}")
                    continue

                self._restore_audio_language(hdr10_tracks)

                for track in hdr10_tracks:
                    track.extra['sessionHandoffToken'] = hdr10_sessionHandoffToken

                for audio in hdr10_tracks.audio:
                    adaptation_set = audio.data.get("dash", {}).get("adaptation_set", {})
                    if adaptation_set.get("audioTrackSubtype") == "descriptive":
                        audio.descriptive = True
                    track_label = adaptation_set.get("label")
                    if track_label and "Audio Description" in track_label:
                        audio.descriptive = True

                hdr10_tracks.audio = [
                    audio for audio in hdr10_tracks.audio
                    if not any(boost_type in audio.data.get("dash", {}).get("adaptation_set", {}).get("audioTrackSubtype", "").lower()
                               for boost_type in ["boosteddialoglow", "boosteddialogmedium", "boosteddialoghigh"])
                ]

                for video in hdr10_tracks.videos:
                    video.range = Video.Range.HDR10
                    video.note = bitrate_actual

                tracks.add(hdr10_tracks)

            if len(self.orig_bitrate.split('+')) > 1:
                self.bitrate = "CVBR,CBR"
                self.log.info(f"Selected video manifest bitrate: {self.bitrate}")

            if tracks.videos and self.quality:
                filtered_videos = []
                for video in tracks.videos:
                    if video.height == self.quality or int(video.width * 9 / 16) == self.quality:
                        filtered_videos.append(video)

                if filtered_videos:
                    best_video = max(filtered_videos, key=lambda v: v.bitrate or 0)
                    tracks.videos = [best_video]
                    self.log.info(f"Filtered to single HDR10 track: {best_video.height}p @ {best_video.bitrate // 1000 if best_video.bitrate else 0} kb/s")

            if tracks.audio:
                audio_by_lang = defaultdict(list)
                for audio in tracks.audio:
                    lang_key = audio.language.to_tag() if hasattr(audio.language, "to_tag") else str(audio.language)
                    audio_by_lang[lang_key].append(audio)

                filtered_audio = []
                for lang_key, audios in audio_by_lang.items():
                    best_audio = max(audios, key=lambda a: a.bitrate or 0)
                    filtered_audio.append(best_audio)

                tracks.audio = filtered_audio
                self.log.info(f"Filtered to {len(tracks.audio)} audio track(s)")

            self.log.info(f"Final selection: {len(tracks.videos)} video track(s) and {len(tracks.audio)} audio track(s)")

            first_hdr10_manifest = hdr10_manifest

            self.log.info("HYBRID mode: Fetching DV manifest for RPU extraction")

            dv_manifest = self.get_manifest(
                title,
                video_codec=self.vcodec,
                bitrate_mode=self.bitrate,
                quality="SD",
                hdr="DolbyVision",
                ignore_errors=True
            )

            if not dv_manifest:
                self.log.warning("No DV manifest available, HYBRID mode requires DV tracks")
            elif "rightsException" not in dv_manifest:
                dv_chosen = self.choose_manifest(dv_manifest, self.cdn)
                if dv_chosen:
                    dv_url = self.clean_mpd_url(dv_chosen["url"], False)
                    if self.event:
                        devicetype = self.device["device_type"]
                        dv_url = dv_chosen["url"]
                        dv_url = f"{dv_url}?amznDtid={devicetype}&encoding=segmentBase"

                    self.log.info(" + Downloading DV Manifest")

                    dv_streamingProtocol = dv_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
                    dv_sessionHandoffToken = dv_manifest["sessionization"]["sessionHandoffToken"]

                    if dv_streamingProtocol == "DASH":
                        dv_tracks = DASH.from_url(url=dv_url, session=self.session).to_tracks(language=title.language)
                    elif dv_streamingProtocol == "SmoothStreaming":
                        dv_tracks = ISM.from_url(url=dv_url).to_tracks(language=title.language)
                    else:
                        self.log.warning(f"Unsupported DV manifest type: {dv_streamingProtocol}")
                        dv_tracks = None

                    if dv_tracks:
                        for track in dv_tracks:
                            track.extra['sessionHandoffToken'] = dv_sessionHandoffToken

                        for video in dv_tracks.videos:
                            video.range = Video.Range.DV

                        dv_video_tracks = sorted(dv_tracks.videos, key=lambda v: v.height if v.height else 0)
                        if dv_video_tracks:
                            selected_dv = dv_video_tracks[0]
                            tracks.add([selected_dv])
                            self.log.info(f"Added DV video track for RPU extraction: {selected_dv.height}p @ {selected_dv.bitrate // 1000 if selected_dv.bitrate else 0} kb/s")

            for sub in first_hdr10_manifest.get("timedTextUrls", {}).get("result", {}).get("subtitleUrls", []) + \
                        first_hdr10_manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []):
                tracks.add(Subtitle(
                    id_=f"{sub['trackGroupId']}_{sub['languageCode']}_{sub['type']}_{sub['subtype']}",
                    url=os.path.splitext(sub["url"])[0] + ".srt",
                    codec=Subtitle.Codec.from_mime("srt"),
                    language=sub["languageCode"],
                    forced="ForcedNarrative" in sub["type"],
                    sdh=sub["type"].lower() == "sdh"
                ), warn_only=True)

            if self.vquality != "UHD" and not self.no_true_region:
                self.manage_session(tracks.videos[0])

            return tracks

        # ── Non-hybrid path ────────────────────────────────────────────────────────
        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
        )

        if "rightsException" in manifest:
            self.log.error(" - The profile used does not have the rights to this title.")
            return tracks

        chosen_manifest = self.choose_manifest(manifest, self.cdn)

        if not chosen_manifest:
            sys.exit(f"No manifests available")

        manifest_url = self.clean_mpd_url(chosen_manifest["url"], False)
        self.log.debug(manifest_url)
        if self.event:
            devicetype = self.device["device_type"]
            manifest_url = chosen_manifest["url"]
            manifest_url = f"{manifest_url}?amznDtid={devicetype}&encoding=segmentBase"
        self.log.info(" + Downloading Manifest")

        streamingProtocol = manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
        sessionHandoffToken = manifest["sessionization"]["sessionHandoffToken"]

        self._restore_audio_language(tracks)

        need_separate_audio = ((self.aquality or self.vquality) != self.vquality
                               or self.amanifest == "CVBR" and (self.vcodec, self.bitrate) != (Video.Codec.AVC, "CVBR")
                               or self.amanifest == "CBR" and (self.vcodec, self.bitrate) != (Video.Codec.AVC, "CBR")
                               or self.amanifest == "H265" and self.vcodec != Video.Codec.HEVC
                               or self.amanifest != "H265" and self.vcodec == Video.Codec.HEVC)

        if not need_separate_audio:
            audios = defaultdict(list)
            for audio in tracks.audio:
                lang_tag = audio.language.to_tag() if hasattr(audio.language, "to_tag") else str(audio.language)
                audios[lang_tag].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:
            manifest_type = self.amanifest or "CVBR"
            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="AV1" if self.vcodec == "AV1" else ("H265" if (self.amanifest == "H265" or self.vcodec == Video.Codec.HEVC) else "H264"),
                bitrate_mode="CVBR",
                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["url"], optimise=False)
                self.log.debug(audio_mpd_url)
                if self.event:
                    devicetype = self.device["device_type"]
                    audio_mpd_url = chosen_audio_manifest["url"]
                    audio_mpd_url = f"{audio_mpd_url}?amznDtid={devicetype}&encoding=segmentBase"
                self.log.info(" + Downloading CVBR manifest")

                streamingProtocol = audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
                sessionHandoffToken = audio_manifest["sessionization"]["sessionHandoffToken"]

                try:
                    if streamingProtocol == "DASH":
                        audio_mpd = DASH.from_url(
                                url=audio_mpd_url,
                                session=self.session
                            ).to_tracks(language=title.language)
                        for track in audio_mpd:
                            track.extra['sessionHandoffToken'] = sessionHandoffToken
                    elif streamingProtocol == "SmoothStreaming":
                        audio_mpd = Tracks([
                                x for x in iter(ISM.from_url(url=audio_mpd_url))
                        ])
                        for track in audio_mpd:
                            track.extra['sessionHandoffToken'] = sessionHandoffToken
                except KeyError:
                    self.log.warning(f" - Title has no {self.amanifest} stream, cannot get higher quality audio")
                else:
                    self._restore_audio_language(audio_mpd)

                    for audio in audio_mpd.audio:
                        adaptation_set = audio.data.get("dash", {}).get("adaptation_set", {})
                        if adaptation_set.get("audioTrackSubtype") == "descriptive":
                            audio.descriptive = True
                        track_label = adaptation_set.get("label")
                        if track_label and "Audio Description" in track_label:
                            audio.descriptive = True

                    audio_mpd.audio = [
                        audio for audio in audio_mpd.audio
                        if not any(boost_type in audio.data.get("dash", {}).get("adaptation_set", {}).get("audioTrackSubtype", "").lower()
                                   for boost_type in ["boosteddialoglow", "boosteddialogmedium", "boosteddialoghigh"])
                    ]

                    tracks.add(audio_mpd.audio, warn_only=True)

        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.audios:
                lang_tag = audio.language.to_tag() if hasattr(audio.language, "to_tag") else str(audio.language)
                audios[lang_tag].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.playready or hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["CHROME"] 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="DolbyVision",
                    ignore_errors=True
                )
            except Exception:
                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:
                uhd_audio_mpd_url = self.clean_mpd_url(chosen_uhd_audio_manifest["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["url"]
                    uhd_audio_mpd_url = f"{uhd_audio_mpd_url}?amznDtid={devicetype}&encoding=segmentBase"
                self.log.info(" + Downloading UHD manifest")

                streamingProtocol = uhd_audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
                sessionHandoffToken = uhd_audio_manifest["sessionization"]["sessionHandoffToken"]

                try:
                    if streamingProtocol == "DASH":
                        uhd_audio_mpd = DASH.from_url(
                                url=uhd_audio_mpd_url,
                                session=self.session
                            ).to_tracks(language=title.language)
                        for track in uhd_audio_mpd:
                            track.extra['sessionHandoffToken'] = sessionHandoffToken
                    elif streamingProtocol == "SmoothStreaming":
                        uhd_audio_mpd = ISM.from_url(
                                url=uhd_audio_mpd_url
                            ).to_tracks(language=title.language)
                        for track in uhd_audio_mpd:
                            track.extra['sessionHandoffToken'] = sessionHandoffToken
                except KeyError:
                    self.log.warning(f" - Title has no UHD stream, cannot get higher quality audio")
                else:
                    self._restore_audio_language(uhd_audio_mpd)

                    for audio in uhd_audio_mpd.audio:
                        adaptation_set = audio.data.get("dash", {}).get("adaptation_set", {})
                        if adaptation_set.get("audioTrackSubtype") == "descriptive":
                            audio.descriptive = True
                        track_label = adaptation_set.get("label")
                        if track_label and "Audio Description" in track_label:
                            audio.descriptive = True

                    uhd_audio_mpd.audio = [
                        audio for audio in uhd_audio_mpd.audio
                        if not any(boost_type in audio.data.get("dash", {}).get("adaptation_set", {}).get("audioTrackSubtype", "").lower()
                                   for boost_type in ["boosteddialoglow", "boosteddialogmedium", "boosteddialoghigh"])
                    ]

                    if any(x for x in uhd_audio_mpd.audio if x.atmos):
                        tracks.audio = uhd_audio_mpd.audio

        for video in tracks.videos:
            if manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["dynamicRange"] == "Hdr10":
                video.range = Video.Range.HDR10
            elif manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["dynamicRange"] == "DolbyVision":
                video.range = Video.Range.DV

        # Dedup by full language tag so es-ES and es-419 are both preserved.
        unique_audio_tracks = {}
        for audio in tracks.audio:
            lang_tag = audio.language.to_tag() if hasattr(audio.language, "to_tag") else str(audio.language)
            key = (lang_tag, audio.bitrate, audio.descriptive)
            if key not in unique_audio_tracks:
                unique_audio_tracks[key] = audio
        tracks.audio = list(unique_audio_tracks.values())

        for sub in manifest.get("timedTextUrls", {}).get("result", {}).get("subtitleUrls", []) + \
                    manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []):
            tracks.add(Subtitle(
                id_=f"{sub['trackGroupId']}_{sub['languageCode']}_{sub['type']}_{sub['subtype']}",
                url=os.path.splitext(sub["url"])[0] + ".srt",
                codec=Subtitle.Codec.from_mime("srt"),
                language=sub["languageCode"],
                forced="ForcedNarrative" in sub["type"],
                sdh=sub["type"].lower() == "sdh"
            ), warn_only=True)

        if self.vquality != "UHD" and not self.no_true_region:
            self.manage_session(tracks.videos[0])

        return tracks

    
    def _get_tracks_for_range(self, title: Title_T, range_override: str = None) -> Tracks:
        current_range = range_override if range_override else self.range

        params = {
            "token": self.token,
            "guid": title.id,
        }

        data = {
            "type": self.config["client"][self.device]["type"],
        }

        if current_range == "HDR10":
            data["video_format"] = "hdr10"
        elif current_range == "DV":
            data["video_format"] = "dolby_vision"
        else:
            data["video_format"] = "sdr"

        if current_range in ("HDR10", "DV") and self.cdm.security_level == 3:
            return Tracks()

        streams = self.session.post(
            url=self.config["endpoints"]["streams"],
            params=params,
            data=data,
        ).json()["media"]

        self.license = {
            "url": streams["drm"]["url"],
            "data": streams["drm"]["data"],
            "session": streams["drm"]["session"],
        }

        manifest_url = streams["url"].split("?")[0]

        self.log.debug(f"Manifest URL: {manifest_url}")
        tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)

        for video in tracks.videos:
            if current_range == "HDR10":
                video.range = Video.Range.HDR10
            elif current_range == "DV":
                video.range = Video.Range.DV
            else:
                video.range = Video.Range.SDR

        tracks.audio = [
            track for track in tracks.audio if "clear" not in track.data["dash"]["representation"].get("id")
        ]

        for track in tracks.audio:
            if track.channels == 6.0:
                track.channels = 5.1
            track_label = track.data["dash"]["adaptation_set"].get("label")
            if track_label and "Audio Description" in track_label:
                track.descriptive = True

        tracks.subtitles.clear()
        if streams.get("captions"):
            for subtitle in streams["captions"]:
                tracks.add(
                    Subtitle(
                        id_=hashlib.md5(subtitle["url"].encode()).hexdigest()[0:6],
                        url=subtitle["url"],
                        codec=Subtitle.Codec.from_mime("vtt"),
                        language=Language.get(subtitle["language"]),
                        sdh=True,
                    )
                )

        if not self.movie:
            title.data["chapters"] = self.session.get(
                url=self.config["endpoints"]["metadata"].format(title_id=title.id), params={"token": self.token}
            ).json()["chapters"]

        return tracks

    def get_chapters(self, title: Title_T) -> list[Chapter]:
        chapters = []
        return chapters
    
    def get_widevine_service_certificate(self, **_) -> str:
        return self.config["certificate"]
        
    def get_playready_license(
        self, *, challenge: bytes, title, track
    ) -> Optional[bytes]:
        """Retrieve a PlayReady license for a given track (PlayReady only)."""

        self.playbackInfo = self.playbackEnvelope_update(self.playbackInfo)

        if isinstance(challenge, str):
            challenge = challenge.encode("utf-8")

        request_json = {
            "licenseChallenge": base64.b64encode(challenge).decode("utf-8"),
            "playbackEnvelope": self.playbackInfo["playbackExperienceMetadata"][
                "playbackEnvelope"
            ],
        }

        if self.device_token:
            request_json.update({
                "capabilityDiscriminators": {
                    "discriminators": {
                        "hardware": {
                            "chipset": self.device["device_chipset"],
                            "manufacturer": self.device["manufacturer"],
                            "modelName": self.device["device_model"],
                        },
                        "software": {
                            "application": {
                                "name": self.device["app_name"],
                                "version": self.device["firmware"],
                            },
                            "operatingSystem": {
                                "name": "Android",
                                "version": self.device["os_version"],
                            },
                            "player": {
                                "name": "Android UIPlayer SDK",
                                "version": "4.1.18",
                            },
                            "renderer": {
                                "drmScheme": "PLAYREADY",
                                "name": "MCMD",
                            },
                        },
                    },
                    "version": 1,
                },
                "deviceCapabilityFamily": "AndroidPlayer",
                "packagingFormat": (
                    "SMOOTH_STREAMING"
                    if track.descriptor == track.Descriptor.ISM
                    else "MPEG_DASH"
                ),
            })
        else:
            session_handoff = track.extra.get("sessionHandoffToken")
            if not session_handoff:
                self.log.exit(
                    "No sessionHandoffToken found in track.extra. "
                    "Web PlayReady licensing requires sessionHandoffToken."
                )

            request_json.update({
                "sessionHandoff": session_handoff,
                "deviceCapabilityFamily": "WebPlayer",
            })

        license_url = self.endpoints["licence_pr"]
        if not license_url:
            raise ValueError("PlayReady license endpoint not configured")

        try:
            res = self.session.post(
                url=license_url,
                headers={
                    "Accept": "application/json",
                    "Content-Type": "application/json; charset=utf-8",
                    "Authorization": f"Bearer {self.device_token}"
                    if self.device_token
                    else None,
                    "connection": "Keep-Alive",
                    "x-gasc-enabled": "true",
                    "x-request-priority": "CRITICAL",
                    "x-retry-count": "0",
                },
                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(),
                },
                json=request_json,
            )
            res.raise_for_status()
            lic = res.json()

        except Exception as e:
            self.log.exit(f"Failed to request PlayReady license: {e}")

        if "errorsByResource" in lic or "error" in lic:
            err = lic.get("errorsByResource") or lic.get("error")
            code = err.get("errorCode") or err.get("type")
            msg = err.get("message", "Unknown PlayReady licensing error")

            if code == "PRS.NoRights.AnonymizerIP":
                self.log.exit(
                    "Amazon detected a Proxy/VPN and refused to return a PlayReady license."
                )

            self.log.exit(f"PlayReady license error: {msg} [{code}]")

        return base64.b64decode(lic["playReadyLicense"]["license"])
            
    def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:

        self.playbackInfo = self.playbackEnvelope_update(self.playbackInfo)

        response = self.session.post(
            url=self.endpoints["licence"],
            params={
                "deviceID": self.device_id,
                "deviceTypeID": self.device["device_type"],
                "gascEnabled": str(self.pv).lower(),
                "marketplaceID": self.region["marketplace_id"],
                "uxLocale": "en_US",
                "firmware": 1,
                "titleId": title.id,
                "nerid": self.generate_nerid(),
            },
            headers={
                "Accept": "*/*",
                "Content-Type": "text/plain",
                "Authorization": f"Bearer {self.device_token}" if self.device_token else None,
            },
            cookies=None if self.device_token else self.session.cookies,
            json={
                "includeHdcpTestKey": True,
                "licenseChallenge": base64.b64encode(challenge).decode(),
                "playbackEnvelope": self.playbackInfo["playbackExperienceMetadata"]["playbackEnvelope"],
                "sessionHandoffToken": track.extra['sessionHandoffToken'],
            },
        ).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["widevineLicense"]["license"]
    
    def register_device(self) -> None:
        self.device = (self.config.get("device") or {}).get(self.profile, {})
        digest = hashlib.md5(json.dumps(self.device).encode()).hexdigest()[:6]
        device_cache_path = self.cache.get(f"device_tokens_{self.profile}_{digest}")
        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:
            sys.exit(f" - A device serial is required in the config, perhaps use: {os.urandom(8).hex()}")

    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:
            sys.exit(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:
                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):
        """Get the region of the cookies from the domain."""
        
        def parse_domain(domain: str):
            domain = domain.lstrip('.').lower()
            parts = domain.split('.')

            if len(parts) < 2:
                return None, None

            # handle common multi-level TLDs (extend if needed)
            if len(parts) >= 3 and parts[-2] in ('co', 'ac', 'go', 'or'):
                root = parts[-3]
                suffix = '.'.join(parts[-2:])
            else:
                root = parts[-2]
                suffix = parts[-1]

            return root, suffix

        parsed = [
            parse_domain(x.domain)
            for x in self.session.cookies
            if x.domain_specified
        ]

        # find amazon / primevideo
        match = next(
            ((root, suffix) for root, suffix in parsed
            if root in ("amazon", "primevideo")),
            (None, None)
        )

        root, suffix = match
        self.domain = root

        if suffix:
            suffix = suffix.split('.')[-1]

        return {"com": "us", "uk": "gb"}.get(suffix, suffix)

    def prepare_endpoint(self, name: str, uri: str, region: dict) -> str:
        if name in ("browse", "configuration", "refreshplayback", "playback", "licence", "licence_pr", "xray", "opensession", "updatesession", "closesession"):
            return f"https://{(region['base_manifest'])}{uri}"
        if name in ("ontv", "devicelink", "details", "getDetailWidgets", "metadata"):
            if self.pv:
                host = region["base"]
            else:
                if name in ("metadata"):
                    host = f"{region['base']}/gp/video"
                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["vodPlaybackUrls"]["result"]["playbackUrls"]["urlSets"] if x["cdn"].lower() == cdn), {})
            if not manifest:
                sys.exit(f" - There isn't any DASH manifests available on the CDN \"{cdn}\" for this title")
        else:
            url_sets = manifest["vodPlaybackUrls"]["result"]["playbackUrls"].get("urlSets", [])
            manifest = random.choice(url_sets) if url_sets else {}

        return manifest
    
    def manage_session(self, track: Tracks):
        try:
            current_progress_time = round(random.uniform(0, 10), 6)
            time_ = 3 # Seconds

            stream_update_time = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
            res = self.session.post(
                url=self.endpoints["opensession"],
                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(),
                },
                headers={
                    "Content-Type": "application/json",
                    "accept": "application/json",
                    "x-request-priority": "CRITICAL",
                    "x-retry-count": "0"
                },
                json={
                    "sessionHandoff": track.extra['sessionHandoffToken'],
                    "playbackEnvelope": self.playbackEnvelope_update(self.playbackInfo)["playbackExperienceMetadata"]["playbackEnvelope"],
                    "streamInfo": {
                        "eventType": "START",
                        "streamUpdateTime": current_progress_time,
                        "vodProgressInfo": {
                            "currentProgressTime": f"PT{current_progress_time:.6f}S",
                            "timeFormat": "ISO8601DURATION",
                        },
                    },
                    "userWatchSessionId": str(uuid.uuid4())
                }
            )
            if res.status_code == 200:
                try:
                    data = res.json()
                    sessionToken = data["sessionToken"]
                except Exception as e:
                    raise Exception(f"Unable to open session: {e}")
            else:
                raise Exception(f"Unable to open session: {res.text}")
            
            # Update Session
            time.sleep(time_)
            stream_update_time = (datetime.fromisoformat(stream_update_time[:-1]) + timedelta(seconds=time_)).isoformat(timespec="milliseconds") + "Z"
            res = self.session.post(
                url=self.endpoints["updatesession"],
                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()
                },
                headers={
                    "Content-Type": "application/json",
                    "accept": "application/json",
                    "x-request-priority": "CRITICAL",
                    "x-retry-count": "0"
                },
                json={
                    "sessionToken": sessionToken,
                    "streamInfo": {
                        "eventType": "PAUSE",
                        "streamUpdateTime": stream_update_time,
                        "vodProgressInfo": {
                            "currentProgressTime": f"PT{current_progress_time + time_:.6f}S",
                            "timeFormat": "ISO8601DURATION",
                        }
                    }
                }
            )
            if res.status_code == 200:
                try:
                    data = res.json()
                    sessionToken = data["sessionToken"]
                except Exception as e:
                    raise Exception(f"Unable to update session: {e}")
            else:
                raise Exception(f"Unable to update session: {res.text}")
            
            # Close session
            res = self.session.post(
                url=self.endpoints["closesession"],
                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()
                },
                headers={
                    "Content-Type": "application/json",
                    "accept": "application/json",
                    "x-request-priority": "CRITICAL",
                    "x-retry-count": "0"
                },
                json={
                    "sessionToken": sessionToken,
                    "streamInfo": {
                        "eventType": "STOP",
                        "streamUpdateTime": stream_update_time,
                        "vodProgressInfo": {
                            "currentProgressTime": f"PT{current_progress_time + time_:.6f}S",
                            "timeFormat": "ISO8601DURATION",
                        }
                    }
                }
            )
            if res.status_code == 200:
                self.log.info("Session completed successfully!")
                return None
            else:
                raise Exception(f"Unable to close session: {res.text}")
        except Exception as e:
            self.log.error(f"Unable to get session: {e}")

    def playbackEnvelope_data(self, titles):
        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(titles),
                    "currentUrl":  f"https://{self.region['base']}/"
                },
                headers={
                    "device-memory": "8",
                    "downlink": "10",
                    "dpr": "2",
                    "ect": "4g",
                    "rtt": "50",
                    "viewport-width": "671",
                    "x-amzn-client-ttl-seconds": "15",
                    "x-amzn-requestid": "".join(random.choices(string.ascii_uppercase + string.digits, k=20)).upper(),
                    "x-requested-with": "XMLHttpRequest"
                }
            )
            
            if res.status_code == 200:
                try:
                    data = res.json()
                    playbackEnvelope_info = []
                    enrichments = data["enrichments"]
                    
                    for titleid_, enrichment in list(enrichments.items()):
                        playbackActions = enrichment["playbackActions"]
                        if enrichment["entitlementCues"]['focusMessage'].get('message') == "Watch with a 30 day free Prime trial, auto renews at €4.99/month":
                            self.log.error("Cookies Expired")
                            return []
                        if playbackActions == []:
                            continue
                        for playbackAction in playbackActions:
                            if playbackAction.get("titleID") or playbackAction.get("legacyOfferASIN"):
                                title_id = titleid_
                                playbackExperienceMetadata = playbackAction.get("playbackExperienceMetadata")
                                if not title_id or not playbackExperienceMetadata:
                                    continue
                                playbackEnvelope_info.append({"titleID": title_id, "playbackExperienceMetadata": playbackExperienceMetadata})
                    return playbackEnvelope_info
                except Exception as e:
                    self.log.error(f"Unable to get playbackEnvelope: {e}")
                    return []
            else:
                return []
        except Exception as e:
            return []
        
    def playbackEnvelope_update(self, playbackInfo):
        try:
            if not playbackInfo:
                self.log.error("Unable to update playbackEnvelope")
                return playbackInfo
            if (int(playbackInfo["playbackExperienceMetadata"]["expiryTime"]) / 1000) < int(datetime.now().timestamp()):
                self.log.warning("Updating playbackEnvelope")
                correlationId = playbackInfo["playbackExperienceMetadata"]["correlationId"]
                titleID = playbackInfo["titleID"]
                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": {titleID: correlationId},
                        "geoToken": "null",
                        "identityContext": "null"
                    })
                )
                if res.status_code == 200:
                    try:
                        data = res.json()
                        playbackExperience = data["response"][titleID]["playbackExperience"]
                        playbackExperience["expiryTime"] = int(playbackExperience["expiryTime"] * 1000)
                        return {"titleID": titleID, "playbackExperienceMetadata": playbackExperience}
                    except Exception as e:
                        sys.exit(f"Unable to update playbackEnvelope: {e}")
                else:
                    sys.exit(f"Unable to update playbackEnvelope: {res.text}")
            else:
                return playbackInfo
        except Exception as e:
            sys.exit(f"Unable to update playbackEnvelope: {e}")

    def get_manifest(
        self, title, video_codec: str, bitrate_mode: str, quality: str, hdr=None,
            ignore_errors: bool = False
    ) -> dict:
        
        self.playbackInfo = self.playbackEnvelope_update(title.data.get("playbackInfo"))
        title.data["playbackInfo"] = self.playbackInfo
        data_dict = {
            "globalParameters": {
                "deviceCapabilityFamily": "WebPlayer" if not self.device_token else "AndroidPlayer",
                "playbackEnvelope": self.playbackInfo["playbackExperienceMetadata"]["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}
                } if not self.device_token else {
                    "discriminators": {
                        "software": {},
                        "version": 1
                    }
                }
            },
            "auditPingsRequest": {
                **({
                    "device": {
                        "category": "Tv",
                        "platform": "Android"
                    }
                } if self.device_token else {})
            },
            "playbackDataRequest": {},
            "timedTextUrlsRequest": {
                "supportedTimedTextFormats": ["TTMLv2", "DFXP"]
            },
            "trickplayUrlsRequest": {},
            "transitionTimecodesRequest": {},
            "vodPlaybackUrlsRequest":
                {
                    "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": ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode],
                                "codecs": [video_codec],
                                "drmKeyScheme": "SingleKey" if self.playready else "DualKey",
                                "drmType": "PlayReady" if self.playready else "Widevine",
                                "dynamicRangeFormats": self.VIDEO_RANGE_MAP.get(hdr, "None"),
                                "fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"],
                                "frameRates": ["Standard"],
                                "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
                    }
                } if not self.device_token else {
                    "ads": {},
                    "device": {
                        "displayBasedVending": "supported",
                        "displayHeight": 2304,
                        "displayWidth": 4096,
                        "streamingTechnologies": {
                            "DASH": {
                                "fragmentRepresentations": [
                                    "ByteOffsetRange",
                                    "SeparateFile"
                                ],
                                "manifestThinningToSupportedResolution": "Forbidden",
                                "segmentInfoType": "List",
                                "timedTextRepresentations": [
                                    "BurnedIn",
                                    "NotInManifestNorStream",
                                    "SeparateStreamInManifest"
                                ],
                                "trickplayRepresentations": [
                                    "NotInManifestNorStream"
                                ],
                                "variableAspectRatio": "supported",
                                "vastTimelineType": "Absolute",
                                "bitrateAdaptations": ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode],
                                "codecs": [video_codec],
                                "drmKeyScheme": "SingleKey",
                                "drmStrength": "L40",
                                "drmType": "PlayReady" if self.playready else "Widevine",
                                "dynamicRangeFormats": [self.VIDEO_RANGE_MAP.get(hdr, "None")],
                                "frameRates": ["Standard"]
                            },
                            "SmoothStreaming": {
                                "fragmentRepresentations": [
                                    "ByteOffsetRange",
                                    "SeparateFile"
                                ],
                                "manifestThinningToSupportedResolution": "Forbidden",
                                "segmentInfoType": "List",
                                "timedTextRepresentations": [
                                    "BurnedIn",
                                    "NotInManifestNorStream",
                                    "SeparateStreamInManifest"
                                ],
                                "trickplayRepresentations": [
                                    "NotInManifestNorStream"
                                ],
                                "variableAspectRatio": "supported",
                                "vastTimelineType": "Absolute",
                                "bitrateAdaptations": ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode],
                                "codecs": [video_codec],
                                "drmKeyScheme": "SingleKey",
                                "drmStrength": "L40",
                                "drmType": "PlayReady",
                                "dynamicRangeFormats": [self.VIDEO_RANGE_MAP.get(hdr, "None")],
                                "frameRates": ["Standard"]
                            }
                        },
                        "acceptedCreativeApis": [],
                        "category": "Tv",
                        "hdcpLevel": "2.2",
                        "maxVideoResolution": "2160p",
                        "platform": "Android",
                        "supportedStreamingTechnologies": [
                            "DASH", "SmoothStreaming"
                        ]
                    },
                    "playbackCustomizations": {},
                    "playbackSettingsRequest": {
                        "firmware": "UNKNOWN",
                        "playerType": self.player,
                        "responseFormatVersion": "1.0.0",
                        "titleId": title.id
                    }
                },
                "vodXrayMetadataRequest": {
                    "xrayDeviceClass": "normal",
                    "xrayPlaybackMode": "playback",
                    "xrayToken": "XRAY_WEB_2023_V2"
                }
            }

        json_data = json.dumps(data_dict)

        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_data,
            headers={
                "Authorization": f"Bearer {self.device_token}" if self.device_token else None,
            },
        )
        try:
            manifest = res.json()
        except json.JSONDecodeError:
            if ignore_errors:
                return {}
            sys.exit(f" - Amazon reported an error when obtaining the Playback Manifest\n{res.text}")

        if "error" in manifest["vodPlaybackUrls"]:
            if ignore_errors:
                return {}
            message = manifest["vodPlaybackUrls"]["error"]["message"]
            sys.exit(f" - Amazon reported an error when obtaining the Playback Manifest: {message}")

        if (
          manifest.get("errorsByResource", {}).get("PlaybackUrls") and
          manifest["errorsByResource"]["PlaybackUrls"].get("errorCode") != "PRS.NoRights.NotOwned"
        ):
            if ignore_errors:
                return {}
            error = manifest["errorsByResource"]["PlaybackUrls"]
            sys.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"]
            sys.exit(f" - Amazon had an error with the A/V Urls: {error['message']} [{error['errorCode']}]")

        return manifest

    def get_episodes(self, titleID):
        episodes_map = {}
    
        res = self.session.get(
            url=self.endpoints["details"],
            params={"titleID": titleID, "isElcano": "1", "sections": ["Atf", "Btf"]},
            headers={"Accept": "application/json"},
        )
        if not res.ok:
            sys.exit(f"Unable to get title: {res.text} [{res.status_code}]")
    
        root_data = res.json()["widgets"]
        seasons = [x.get("titleID") for x in root_data.get("seasonSelector", [])]
    
        for season in seasons:
            season_data = self.session.get(
                url=self.endpoints["details"],
                params={"titleID": season, "isElcano": "1", "sections": "Btf"},
                headers={"Accept": "application/json"},
            ).json()["widgets"]
    
            product_details = season_data["productDetails"]["detail"]
            episodes = season_data.get("episodeList", {}).get("episodes", [])
    
            while True:
                episodes_titles = []
    
                for ep in episodes:
                    details = ep["detail"]
                    ep_id = details["catalogId"]
                    seq = ep["self"]["sequenceNumber"]
    
                    if ep_id not in episodes_map:
                        episodes_map[ep_id] = Episode(
                            id_=ep_id,
                            service=self.__class__,
                            title=product_details.get("parentTitle"),
                            season=product_details.get("seasonNumber"),
                            number=seq,
                            name=details.get("title"),
                            language=None,
                            data=ep,
                        )
                    else:
                        episodes_map[ep_id].data.update(ep)
    
                    episodes_titles.append(ep_id)
    
                if episodes_titles:
                    playbackEnvelope_info = self.playbackEnvelope_data(episodes_titles)
                    playback_map = {info["titleID"]: info for info in playbackEnvelope_info}
    
                    for ep_id in episodes_titles:
                        if ep_id in playback_map:
                            episodes_map[ep_id].data.update({"playbackInfo": playback_map[ep_id]})
    
                pagination_data = season_data.get("episodeList", {}).get("actions", {}).get("pagination", [])
                token = next(
                    (quote(item.get("token")) for item in pagination_data if item.get("tokenType") == "NextPage"),
                    None,
                )
                if not token:
                    break
    
                season_data = self.session.get(
                    url=self.endpoints["getDetailWidgets"],
                    params={
                        "titleID": season,
                        "isTvodOnRow": "1",
                        "widgets": f'[{{"widgetType":"EpisodeList","widgetToken":"{token}"}}]',
                    },
                    headers={"Accept": "application/json"},
                ).json()["widgets"]
    
                episodes = season_data.get("episodeList", {}).get("episodes", [])
    
        unique_sorted = sorted(
            episodes_map.values(),
            key=lambda ep: (ep.season, ep.number)
        )
    
        return Series(unique_sorted)

    @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("vodPlaybackUrls", {}).get("result", {}).get("playbackUrls", {}):
            try:
                return manifest["vodPlaybackUrls"]["result"]["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 generate_nerid(e=0):
        """Generates Network Edge Request ID."""
        BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    
        timestamp = (int(datetime.now().timestamp()) * 1000)
        ts_chars = []
        for _ in range(7):
            ts_chars.append(BASE64_CHARS[timestamp % 64])
            timestamp //= 64
            ts_part = ''.join(reversed(ts_chars))
    
        rand_part = ''.join(secrets.choice(BASE64_CHARS) for _ in range(15))
        suffix = f"{e % 100:02d}"
    
        return ts_part + rand_part + suffix


    @staticmethod
    def clean_mpd_url(mpd_url, optimise=False):
        """Clean up an Amazon MPD manifest url."""
        if '@' in mpd_url:
            mpd_url = re.sub(r'/\d+@[^/]+', '', mpd_url, count=1)
        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:
                raise IndexError("Unable to parse MPD URL")

        return mpd_url
        
    def get_best_quality(self, title):
        """
        Choose the best quality manifest from CBR / CVBR
        """

        tracks = Tracks()
        bitrates = [self.orig_bitrate]

        if self.vcodec != Video.Codec.HEVC:
            bitrates = self.orig_bitrate.split('+')
        for bitrate in bitrates:
            manifest = self.get_manifest(
                title,
                video_codec=self.vcodec,
                bitrate_mode=bitrate,
                quality=self.vquality,
                hdr=self.range,
                ignore_errors=False
            )

            if not manifest:
                self.log.warning(f"Skipping {bitrate} manifest due to error")
                continue

            bitrate = manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["bitrateAdaptation"]

            chosen_manifest = self.choose_manifest(manifest, self.cdn)

            if not chosen_manifest:
                self.log.warning(f"No {bitrate} DASH manifests available")
                continue

            mpd_url = self.clean_mpd_url(chosen_manifest["url"], optimise=False)
            self.log.debug(mpd_url)
            if self.event:
                devicetype = self.device["device_type"]
                mpd_url = chosen_manifest["url"]
                mpd_url = f"{mpd_url}?amznDtid={devicetype}&encoding=segmentBase"
            self.log.info(f" + Downloading {bitrate} MPD")

            streamingProtocol = manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
            sessionHandoffToken = manifest["sessionization"]["sessionHandoffToken"]

            if streamingProtocol == "DASH":
                new_tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=title.language)
                for track in new_tracks:
                    track.extra['sessionHandoffToken'] = sessionHandoffToken
            elif streamingProtocol == "SmoothStreaming":
                new_tracks = ISM.from_url(url=mpd_url, session=self.session).to_tracks(language=title.language)
                for track in new_tracks:
                    track.extra['sessionHandoffToken'] = sessionHandoffToken
            else:
                sys.exit(f"Unsupported manifest type: {streamingProtocol}")

            self._restore_audio_language(new_tracks)

            for audio in new_tracks.audio:
                adaptation_set = audio.data.get("dash", {}).get("adaptation_set", {})
                if adaptation_set.get("audioTrackSubtype") == "descriptive":
                    audio.descriptive = True
                track_label = adaptation_set.get("label")
                if track_label and "Audio Description" in track_label:
                    audio.descriptive = True

            new_tracks.audio = [
                audio for audio in new_tracks.audio
                if not any(boost_type in audio.data.get("dash", {}).get("adaptation_set", {}).get("audioTrackSubtype", "").lower()
                           for boost_type in ["boosteddialoglow", "boosteddialogmedium", "boosteddialoghigh"])
            ]

            for video in new_tracks.videos:
                video.note = bitrate

            tracks.add(new_tracks)

        if len(self.bitrate.split('+')) > 1:
            self.bitrate = "CVBR,CBR"
            self.log.info("Selected video manifest bitrate: %s", self.bitrate)

        return tracks


    # Service specific classes

    class DeviceRegistration:
        def __init__(self, device, endpoints, cache_path, session, log):
            self.session = session
            self.device = device
            self.endpoints = endpoints
            self.cache_path = cache_path  # can be a path/str or a Cacher instance
            self.log = log

            # normalize device values
            self.device = {k: str(v) if not isinstance(v, str) else v for k, v in self.device.items()}

            self.bearer = None

            # Resolve cache into a Cacher-like object and a key
            if isinstance(self.cache_path, Cacher):
                cacher_instance = self.cache_path
                cache_key = cacher_instance.key or (cacher_instance.path.stem if cacher_instance.path else None)
                if not cacher_instance.data and cache_key:
                    try:
                        cacher_instance = Cacher(cacher_instance.service_tag).get(cache_key)
                    except Exception:
                        pass
            else:
                p = Path(self.cache_path)
                cache_key = p.stem
                cacher_instance = Cacher("AMZN").get(cache_key)

            if cacher_instance and cacher_instance.data:
                cache = cacher_instance  # type: ignore
                if cache.expiration and cache.expiration > datetime.now():
                    self.log.info(" + Using cached device bearer")
                    self.bearer = cache.data.get("access_token")
                else:
                    self.log.info("Cached device bearer expired, refreshing...")
                    refresh_token = cache.data.get("refresh_token")
                    if not refresh_token:
                        self.log.info("No refresh token in cache; registering a new device")
                        self.bearer = self.register(self.device)
                    else:
                        refreshed_tokens = self.refresh(self.device, refresh_token)
                        refreshed_tokens["refresh_token"] = refreshed_tokens.get("refresh_token", refresh_token)
                        expires_in = refreshed_tokens.get("expires_in")
                        if isinstance(expires_in, (int, float, str)):
                            try:
                                expires_seconds = int(expires_in)
                                expiration_dt = datetime.fromtimestamp(time.time() + expires_seconds)
                            except Exception:
                                try:
                                    expiration_dt = datetime.fromtimestamp(int(expires_in))
                                except Exception:
                                    expiration_dt = None
                        elif isinstance(expires_in, datetime):
                            expiration_dt = expires_in
                        else:
                            expiration_dt = None

                        try:
                            setter = Cacher("AMZN", cache_key)
                            setter.set(refreshed_tokens, expiration=expiration_dt)
                        except Exception:
                            try:
                                with open(p if not isinstance(self.cache_path, Cacher) else cacher_instance.path, "w", encoding="utf-8") as fd:
                                    fd.write(jsonpickle.encode(refreshed_tokens))
                            except Exception:
                                pass

                        self.bearer = refreshed_tokens.get("access_token")
            else:
                self.log.info(" + Registering new device bearer")
                self.bearer = self.register(self.device)

        def _exit(self, message: str):
            """Call logger exit if available, otherwise raise SystemExit"""
            if hasattr(self.log, "exit"):
                try:
                    return self.log.error(message)
                except Exception:
                    raise SystemExit(message)
            raise SystemExit(message)

        def register(self, device: dict) -> str:
            """
            Register device to the account and return access token string.
            """
            csrf_token = self.get_csrf_token()
            code_pair = self.get_code_pair(device)

            form_data = {
                "ref_": "atv_set_rd_reg",
                "publicCode": code_pair["public_code"],
                "token": csrf_token,
            }

            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",
                    "Content-Type": "application/x-www-form-urlencoded",
                    "Referer": self.endpoints.get("ontv", "")
                },
                data=form_data,
            )
            if response.status_code != 200:
                self._exit(f"Unexpected response with the codeBasedLinking request: {response.text} [{response.status_code}]")

            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,
            )
            if response.status_code != 200:
                self._exit(f"Unable to register: {response.text} [{response.status_code}]")

            try:
                resp_json = response.json()
                bearer = resp_json["response"]["success"]["tokens"]["bearer"]
            except Exception:
                self._exit(f"Unexpected register response: {response.text}")

            try:
                expires_seconds = int(bearer.get("expires_in", 3600))
                expiration_dt = datetime.fromtimestamp(time.time() + expires_seconds)
            except Exception:
                expiration_dt = None

            try:
                key = Path(self.cache_path).stem if not isinstance(self.cache_path, Cacher) else (self.cache_path.key or Path(self.cache_path.path).stem)
                setter = Cacher("AMZN", key)
                setter.set(bearer, expiration=expiration_dt)
            except Exception:
                try:
                    cache_file = self.cache_path if not isinstance(self.cache_path, Cacher) else self.cache_path.path
                    with open(cache_file, "w", encoding="utf-8") as fd:
                        fd.write(jsonpickle.encode(bearer))
                except Exception:
                    pass

            return bearer.get("access_token")

        def refresh(self, device: dict, refresh_token: str) -> dict:
            response = self.session.post(
                url=self.endpoints["token"],
                json={
                    "app_name": device.get("app_name", "unshackle"),
                    "app_version": device.get("app_version", "1.0"),
                    "source_token_type": "refresh_token",
                    "source_token": refresh_token,
                    "requested_token_type": "access_token"
                }
            )
            if response.status_code != 200:
                self._exit(f"Unable to refresh token: {response.text} [{response.status_code}]")
            return response.json()

        def get_csrf_token(self) -> str:
            res = self.session.get(self.endpoints["ontv"])
            html = res.text
            if 'input type="hidden" name="appAction" value="SIGNIN"' in html:
                self._exit(
                    "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"<script type=\"text/template\">(.+?)</script>", html, re.DOTALL):
                try:
                    prop = json.loads(match.group(1))
                except Exception:
                    continue
                token = prop.get("props", {}).get("codeEntry", {}).get("token")
                if token:
                    return token

            self._exit("Unable to get ontv CSRF token")

        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}
            )
            try:
                data = res.json()
            except Exception:
                self._exit(f"Unable to parse code pair response: {res.text}")

            if "error" in data:
                self._exit(f"Unable to get code pair: {data.get('error_description', '')} [{data.get('error')}]")

            return data