Files
unshackle-services/AMZN/__init__.py
T
2026-07-08 18:56:50 -05:00

1911 lines
85 KiB
Python

import base64
import hashlib
import json
import os
import random
import re
import secrets
import string
import time
import uuid
from collections import defaultdict
from datetime import datetime, timedelta
from http.cookiejar import CookieJar
from typing import Any, Optional
from urllib.parse import quote
import click
import requests
from click.core import ParameterSource
from langcodes import Language
from tldextract import tldextract
from unshackle.core.cacher import Cacher
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH, ISM
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Attachment, Chapter, Chapters, Subtitle, Tracks
from unshackle.core.utils.collections import as_list
def _build_ordered_lang_map_from_mpd(mpd_text: str) -> dict:
import xml.etree.ElementTree as ET
import re as _re
ns_strip = _re.compile(r"\{[^}]*\}")
rid_lang_re = _re.compile(r"^audio_([a-zA-Z]{2,3}-[a-zA-Z0-9]{2,5})")
result: dict = {}
try:
root = ET.fromstring(mpd_text)
for elem in root.iter():
if ns_strip.sub("", elem.tag) != "AdaptationSet":
continue
content_type = elem.get("contentType", "") or elem.get("mimeType", "")
if "audio" not in content_type.lower():
if not any("audio" in (r.get("mimeType", "")).lower() for r in elem):
continue
base_lang = elem.get("lang") or elem.get("language") or ""
if not base_lang:
continue
for r in elem:
if ns_strip.sub("", r.tag) != "Representation":
continue
rid = r.get("id") or ""
m = rid_lang_re.match(rid)
precise = m.group(1) if m else base_lang
result.setdefault(base_lang, []).append(precise)
except Exception:
pass
return result
def _apply_ordered_lang_map(audio_tracks, lang_map: dict) -> None:
from langcodes import Language as _Lang
counters: dict = {}
for track in audio_tracks:
base = str(track.language)
if base not in lang_map:
continue
ordered = lang_map[base]
if not any("-" in p for p in ordered):
continue
idx = counters.get(base, 0)
if idx < len(ordered):
track.language = _Lang.get(ordered[idx])
counters[base] = idx + 1
def _resolve_subtitle_language(language_code: str, url: str) -> str:
if "-" in language_code:
return language_code
import re as _re
m = _re.search(
r"(?:^|[/_-])(" + _re.escape(language_code) + r"[-_][A-Za-z0-9]{2,5})(?:[/_.-]|$)",
url, _re.IGNORECASE
)
if m:
return m.group(1).replace("_", "-")
return language_code
class AMZN(Service):
"""
Service code for Amazon VOD (https://amazon.com) & Amazon Prime Video (https://primevideo.com).
Author: n0stal6ic
Authorization: Cookies
Security: UHD@L1 FHD@Chrome SD@L3
"""
ALIASES = ["AMZN", "amazon", "prime"]
TITLE_RE = [
r"^(?:https?://(?:www\.)?(?P<domain>amazon\.(?P<region>com|co\.uk|de|co\.jp)|primevideo\.com)(?:/.+)?/)?(?P<id>[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)",
r"^(?:https?://(?:www\.)?(?P<domain>amazon\.(?P<region>com|co\.uk|de|co\.jp)|primevideo\.com)(?:/[^?]*)?(?:\?gti=)?)(?P<id>[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)"
]
REGION_TLD_MAP = {
"au": "com.au",
"br": "com.br",
"jp": "co.jp",
"mx": "com.mx",
"tr": "com.tr",
"gb": "co.uk",
"us": "com",
}
VIDEO_RANGE_MAP = {
"SDR": "None",
"HDR10": "Hdr10",
"DV": "DolbyVision",
}
@staticmethod
@click.command(name="AMZN", short_help="https://amazon.com, https://primevideo.com", help=__doc__)
@click.argument("title", type=str, required=False)
@click.option("-b", "--bitrate", default="CBR",
type=click.Choice(["CVBR", "CBR", "CVBR+CBR"], case_sensitive=False),
help="Video Bitrate Mode to download in. CVBR=Constrained Variable Bitrate, CBR=Constant Bitrate.")
@click.option("-p", "--player", default="html5",
type=click.Choice(["html5", "xp"], case_sensitive=False),
help="Video playerType to download in. html5, xp.")
@click.option("-c", "--cdn", default=None, type=str,
help="CDN to download from, defaults to a random CDN from the available set.")
@click.option("-vq", "--vquality", default="HD",
type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False),
help="Manifest quality to request.")
@click.option("-s", "--single", is_flag=True, default=False,
help="Force single episode/season instead of getting series ASIN.")
@click.option("-am", "--amanifest", default="CVBR",
type=click.Choice(["CVBR", "CBR", "H265"], case_sensitive=False),
help="Manifest to use for audio. Defaults to H265 if the video manifest is missing 640k audio.")
@click.option("-aq", "--aquality", default="SD",
type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False),
help="Manifest quality to request for audio. Defaults to the same as --quality.")
@click.option("-mt", "--manifest", "manifest_type", default="DASH",
type=click.Choice(["DASH", "ISM"], case_sensitive=False),
help="Manifest protocol for the video stream. ISM (SmoothStreaming) can carry a "
"higher-bitrate HDR10 video track than DASH, but requires a registered device.")
@click.option("-drm", "--drm-system", type=click.Choice(["widevine", "playready"], case_sensitive=False),
default="playready",
help="which drm system to use")
@click.option("-nr", "--no-true-region", "no_true_region", is_flag=True, default=False,
help="Skip the 'true region' playback session (keep-alive) and don't adopt the IP-geo "
"marketplace from the configuration endpoint. Session keep-alive is ON by default.")
@click.option("-pl", "--playlisted", is_flag=True, default=False,
help="Use Amazon's newer LivingRoomPlayer 'vodPlaylistedPlaybackUrls' request path "
"(requires a registered device). Off by default; the classic path is used first and "
"this is also tried automatically as a fallback when a device is registered.")
@click.pass_context
def cli(ctx, **kwargs):
return AMZN(ctx, **kwargs)
def __init__(self, ctx, title, bitrate: str, player: str, cdn: str, vquality: str, single: bool,
amanifest: str, aquality: str, manifest_type: str, drm_system: str,
no_true_region: bool, playlisted: bool):
super().__init__(ctx)
self.parse_title(ctx, title)
self.bitrate = bitrate
self.player = player
self.bitrate_source = ctx.get_parameter_source("bitrate")
self.cdn = cdn
self.vquality = vquality
self.vquality_source = ctx.get_parameter_source("vquality")
self.single = single
self.amanifest = amanifest
self.aquality = aquality
self.manifest_type = (manifest_type or "DASH").upper()
self.drm_system = drm_system
self.no_true_region = no_true_region
self.playlisted = playlisted
assert ctx.parent is not None
self.chapters_only = ctx.parent.params.get("chapters_only")
self.quality = ctx.parent.params.get("quality") or 1080
vcodec = ctx.parent.params.get("vcodec")
range_ = ctx.parent.params.get("range_")
self.range = range_[0].name if range_ else "SDR"
if vcodec and "AV1" in str(vcodec).upper():
self.vcodec = "AV1"
elif vcodec and "HEVC" in str(vcodec).upper():
self.vcodec = "H265"
else:
self.vcodec = "H264"
self.cdm = ctx.obj.cdm
self.profile = ctx.obj.profile
self.playready = self.drm_system == "playready"
self.region: dict[str, str] = {}
self.endpoints: dict[str, str] = {}
self.device: dict[str, str] = {}
self.pv = False
self.event = False
self.device_token = None
self.device_id = None
self.customer_id = None
self.client_id = "f22dbddb-ef2c-48c5-8876-bed0d47594fd"
self.playbackEnvelope = None
self.playbackInfo = None
self.session_handoff_token = None
if self.vquality_source != ParameterSource.COMMANDLINE:
q_check = self.quality[0] if isinstance(self.quality, list) else self.quality
if 0 < q_check <= 576 and self.range == "SDR":
self.log.info(" + Setting manifest quality to SD")
self.vquality = "SD"
if q_check > 1080:
self.log.info(" + Setting manifest quality to UHD to be able to get 2160p video track")
self.vquality = "UHD"
self.vquality = self.vquality or "HD"
if self.vquality == "UHD":
self.vcodec = "H265"
if self.bitrate_source != ParameterSource.COMMANDLINE:
if self.manifest_type == "ISM":
self.bitrate = "CBR"
self.log.info(" + Forcing bitrate mode to CBR for ISM (SmoothStreaming)")
elif self.vcodec == "AV1":
self.bitrate = "CVBR"
self.log.info(" + Forcing bitrate mode to CVBR for AV1 codec")
elif self.vcodec == "H265" and self.range == "SDR" and self.bitrate != "CVBR+CBR":
self.bitrate = "CVBR+CBR"
self.log.info(" + Changed bitrate mode to CVBR+CBR to be able to get H.265 SDR video track")
if self.manifest_type != "ISM" and self.vquality == "UHD" and self.range != "SDR" and self.bitrate != "CBR":
self.bitrate = "CBR"
self.log.info(f" + Changed bitrate mode to CBR to be able to get highest quality UHD {self.range} video track")
self.orig_bitrate = self.bitrate
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not cookies:
raise EnvironmentError("Service requires Cookies for Authentication.")
self.session.cookies.update(cookies)
self.configure()
def configure(self) -> None:
if len(self.title) > 10:
self.pv = True
self.pv = True
self.log.info("Getting Account Region")
self.region = self.get_region()
if not self.region:
self.log.error(" - Failed to get Amazon Account region"); raise SystemExit(1)
self.log.info(f" + Region: {self.region['code']}")
self.endpoints = self.prepare_endpoints(self.config["endpoints"], self.region)
self.session.headers.update({
"Origin": f"https://{self.region['base']}"
})
_profile = self.profile or "default"
self.device = (self.config.get("device") or {}).get(_profile, {})
dtid_dict = self.config.get("dtid_dict", [])
if self.device and dtid_dict:
if self.device.get("device_type") not in set(dtid_dict):
self.log.error(
f" - Device type '{self.device.get('device_type')}' is NOT in the approved "
"dtid_dict. Using it could result in an Amazon account ban. Update your config."
)
raise SystemExit(1)
need_device = False
if (isinstance(self.quality, list) and self.quality[0] > 1080) or self.vquality == "UHD" or self.range != "SDR":
need_device = True
if self.manifest_type == "ISM":
need_device = True
if self.playlisted:
need_device = True
if self.manifest_type == "ISM" and not self.device:
self.log.error(
" - ISM/SmoothStreaming requires a configured device in config.yaml. "
"Add one or use --manifest DASH."
)
raise SystemExit(1)
if self.playlisted and not self.device:
self.log.warning(
" - --playlisted (LivingRoomPlayer) needs a configured device in config.yaml; "
"falling back to the classic browser path."
)
self.playlisted = False
if self.device:
if need_device and self.vcodec == "H265":
self.log.info("Using device to get UHD manifests")
else:
self.log.info(f"Using configured device for profile: {_profile}")
res_cfg = self.session.get(
url=self.endpoints["configuration"],
params={"deviceTypeID": self.device["device_type"], "deviceID": "Tv"}
)
self._apply_verified_territory(res_cfg)
self.register_device()
else:
self.log.warning(
"No Device information was provided for %s, using browser device...",
self.profile
)
self.device_id = hashlib.sha224(
("CustomerID" + self.session.headers["User-Agent"]).encode("utf-8")
).hexdigest()
self.device = {"device_type": self.config["device_types"]["browser"]}
res_cfg = self.session.get(
url=self.endpoints["configuration"],
params={"deviceTypeID": self.device["device_type"], "deviceID": "Web"}
)
self._apply_verified_territory(res_cfg)
def _apply_verified_territory(self, res_cfg) -> None:
if res_cfg.status_code != 200:
self.log.warning(f" - Configuration endpoint returned {res_cfg.status_code}, using config values")
return
try:
ctx = res_cfg.json().get("requestContext", {})
except ValueError:
return
territory = ctx.get("currentTerritory")
marketplace = ctx.get("marketplaceID")
account_region = self.region.get("code")
if territory and account_region and territory.lower() != account_region.lower():
self.log.warning(
f" - IP region '{territory}' does not match account region '{account_region}'. "
f"Amazon geo-locates by IP. Licensing and/or title lookup may be blocked. "
f"If the license step fails, use a {account_region} IP (e.g. --proxy {account_region} or a VPN)."
)
return
if territory:
self.log.info(f" + Region (verified): {territory}")
if marketplace and not self.no_true_region:
self.region["marketplace_id"] = marketplace
elif marketplace and self.no_true_region:
self.log.debug(" + --no-true-region set; keeping marketplace from config, ignoring IP-geo value")
def get_titles(self) -> Titles_T:
res = self.session.get(
url=self.endpoints["details"],
params={
"titleID": self.title,
"isElcano": "1",
"sections": ["Atf", "Btf"]
},
headers={"Accept": "application/json"}
)
if not res.ok:
self.log.error(f"Unable to get title: {res.text} [{res.status_code}]"); raise SystemExit(1)
data = res.json()["widgets"]
product_details = data.get("productDetails", {}).get("detail")
if not product_details:
error = res.json()["degradations"][0]
self.log.error(f"Unable to get title: {error['message']} [{error['code']}]"); raise SystemExit(1)
if data["pageContext"]["subPageType"] == "Event":
self.event = True
if data["pageContext"]["subPageType"] in ["Movie", "Event"]:
card = data["productDetails"]["detail"]
return Movies([Movie(
id_=card["catalogId"],
name=product_details["title"],
year=card.get("releaseYear", ""),
service=self.__class__,
data=card
)])
episodes_list = []
seasons = []
for season in (data.get("seasonSelector") or []):
sid = season.get("titleID")
if sid and sid not in seasons:
seasons.append(sid)
if self.single:
break
if not seasons and not self.single:
seasons = self._discover_seasons_via_spa()
if not seasons:
seasons = [self.title]
for season in seasons:
res = self.session.get(
url=self.endpoints["details"],
params={"titleID": season, "isElcano": "1", "sections": "Btf"},
headers={"Accept": "application/json"},
).json()["widgets"]
try:
episode_data_list = res["episodeList"]["episodes"]
except KeyError:
continue
product_details_season = res["productDetails"]["detail"]
for episode in episode_data_list:
details = episode["detail"]
episodes_list.append(Episode(
id_=details["catalogId"],
title=product_details["title"],
name=details["title"],
season=product_details_season["seasonNumber"],
number=episode["self"]["sequenceNumber"],
service=self.__class__,
data=episode
))
pagination_data = res.get("episodeList", {}).get("actions", {}).get("pagination", [])
token = next((quote(item.get("token")) for item in pagination_data if item.get("tokenType") == "NextPage"), None)
while token:
res_page = self.session.get(
url=self.endpoints["getDetailWidgets"],
params={
"titleID": self.title,
"isTvodOnRow": "1",
"widgets": f'[{{"widgetType":"EpisodeList","widgetToken":"{token}"}}]'
},
headers={"Accept": "application/json"}
).json()
episodeListWidget = res_page["widgets"].get("episodeList", {})
for item in episodeListWidget.get("episodes", []):
ep_num = int(item.get("self", {}).get("sequenceNumber", 0))
episodes_list.append(Episode(
id_=item["detail"]["catalogId"],
title=product_details["title"],
name=item["detail"]["title"],
season=product_details_season["seasonNumber"],
number=ep_num,
service=self.__class__,
data=item
))
pagination_data = res_page["widgets"].get("episodeList", {}).get("actions", {}).get("pagination", [])
token = next((quote(item.get("token")) for item in pagination_data if item.get("tokenType") == "NextPage"), None)
return Series(episodes_list)
def _discover_seasons_via_spa(self) -> list:
try:
headers = {
"accept": "application/json",
"device-memory": "8", "downlink": "10", "dpr": "2", "ect": "4g", "rtt": "50",
"viewport-width": "604", "x-amzn-client-ttl-seconds": "58.999",
"x-purpose": "navigation", "x-requested-with": "WebSPA",
}
base = self.region["base"]
if self.pv:
res_spa = self.session.get(f"https://{base}/detail/{self.title}", headers=headers)
url = (
f"https://{base}{res_spa.json()['redirect']}"
if "redirect" in res_spa.text else res_spa.url
)
else:
url = f"https://{base}/dp/{self.title}"
headers["referer"] = url
response = self.session.get(
url=url, params={"dvWebSPAClientVersion": "1.0.106799.0"}, headers=headers
)
if response.status_code != 200 or not response.text:
return []
spa_data = response.json()
seasons_data = None
for page in spa_data.get("page", []) or []:
assembly = page.get("assembly") or {}
for body in assembly.get("body", []) or []:
seasons_data = body.get("props", {}).get("atf", {}).get("state", {}).get("seasons")
if seasons_data:
break
if seasons_data:
break
seasons: list = []
if seasons_data:
first_group = next(iter(seasons_data.values()), [])
for season in first_group or []:
match = re.search(r"/detail/([A-Z0-9]{10,})/", season.get("seasonLink", ""))
if match and match.group(1) not in seasons:
seasons.append(match.group(1))
if seasons:
self.log.info(f" + Discovered {len(seasons)} season(s) via SPA detail page")
return seasons
except Exception as e:
self.log.debug(f"SPA season discovery failed: {e}")
return []
def get_tracks(self, title: Title_T) -> Tracks:
if self.chapters_only:
return Tracks([])
is_hybrid = self.range == "HYBRID"
effective_vcodec = self.vcodec
effective_range = "DV" if is_hybrid else self.range
video_protocol = "SmoothStreaming" if self.manifest_type == "ISM" else "DASH"
manifest = self.get_manifest(
title,
video_codec=effective_vcodec,
bitrate_mode=self.bitrate,
quality=self.vquality,
hdr=effective_range,
ignore_errors=(self.range == "DV" or is_hybrid),
protocol=video_protocol,
use_playlisted=self.playlisted
)
if (self.range == "DV" or is_hybrid) and not manifest.get("vodPlaybackUrls"):
self.log.warning(" - Dolby Vision request rejected by server, retrying with HDR10...")
effective_range = "HDR10"
manifest = self.get_manifest(
title,
video_codec=effective_vcodec,
bitrate_mode=self.bitrate,
quality=self.vquality,
hdr=effective_range,
ignore_errors=False,
protocol=video_protocol,
use_playlisted=self.playlisted
)
if not self._usable_manifest(manifest) and self.device_token and not self.playlisted:
self.log.info(" + Classic manifest empty; trying newer playlisted (LivingRoomPlayer) path...")
fallback = self.get_manifest(
title,
video_codec=effective_vcodec,
bitrate_mode=self.bitrate,
quality=self.vquality,
hdr=effective_range,
ignore_errors=True,
protocol=video_protocol,
use_playlisted=True
)
if self._usable_manifest(fallback):
manifest = fallback
self.log.info(" + Using playlisted (LivingRoomPlayer) manifest")
if "rightsException" in manifest.get("returnedTitleRendition", {}).get("selectedEntitlement", {}):
self.log.error(" - The profile used does not have the rights to this title.")
return Tracks([])
self.session_handoff_token = (manifest.get("sessionization") or {}).get("sessionHandoffToken")
if isinstance(getattr(title, "data", None), dict):
title.data["_amzn_manifest"] = manifest
chosen_manifest = self.choose_manifest(manifest, self.cdn)
if not chosen_manifest:
self.log.error("No manifests available"); raise SystemExit(1)
streamingProtocol = (
manifest.get("vodPlaybackUrls", {}).get("result", {}).get("playbackUrls", {})
.get("urlMetadata", {}).get("streamingProtocol", "DASH")
)
if self.event:
devicetype = self.device.get("device_type")
manifest_url = f"{chosen_manifest['url']}?amznDtid={devicetype}&encoding=segmentBase"
elif streamingProtocol == "DASH":
manifest_url = self.clean_mpd_url(chosen_manifest["url"], False)
else:
manifest_url = chosen_manifest["url"]
self.log.info(f" + Manifest: {manifest_url}")
if streamingProtocol == "DASH":
_mpd_raw = self.session.get(manifest_url).text
_lang_order_map = _build_ordered_lang_map_from_mpd(_mpd_raw)
self.log.info(f" + MPD language map: {sum(len(v) for v in _lang_order_map.values())} representations indexed")
else:
_lang_order_map = {}
tracks = Tracks()
if streamingProtocol == "DASH":
tracks = Tracks([
x for x in iter(DASH.from_text(_mpd_raw, manifest_url).to_tracks(language="en"))
])
elif streamingProtocol == "SmoothStreaming":
_ism_tracks = Tracks()
for _t in iter(ISM.from_url(url=manifest_url, session=self.session).to_tracks(language="en")):
_ism_tracks.add(_t, warn_only=True)
tracks = _ism_tracks
else:
self.log.error(f"Unsupported manifest type: {streamingProtocol}"); raise SystemExit(1)
if _lang_order_map:
_apply_ordered_lang_map(tracks.audio, _lang_order_map)
self._tag_amzn_tracks(tracks, manifest)
need_separate_audio = ((self.aquality or self.vquality) != self.vquality
or self.amanifest == "CVBR" and (self.vcodec, self.bitrate) != ("H264", "CVBR")
or self.amanifest == "CBR" and (self.vcodec, self.bitrate) != ("H264", "CBR")
or self.amanifest == "H265" and self.vcodec != "H265"
or self.amanifest != "H265" and self.vcodec == "H265")
if not need_separate_audio:
audios = defaultdict(list)
for audio in tracks.audio:
audios[audio.language].append(audio)
for lang in audios:
if not any((x.bitrate or 0) >= 640000 for x in audios[lang]):
need_separate_audio = True
break
if need_separate_audio:
manifest_type = self.amanifest
self.log.info(f"Getting audio from {manifest_type} manifest for potential higher bitrate or better codec")
audio_manifest = self.get_manifest(
title=title,
video_codec="H264",
bitrate_mode="CVBR",
quality="HD",
hdr=None,
ignore_errors=True
)
if not audio_manifest:
self.log.warning(f" - Unable to get {manifest_type} audio manifests, skipping")
elif not (chosen_audio_manifest := self.choose_manifest(audio_manifest, self.cdn)):
self.log.warning(f" - No {manifest_type} audio manifests available, skipping")
else:
audio_mpd_url = self.clean_mpd_url(chosen_audio_manifest["url"], optimise=False)
if self.event:
devicetype = self.device.get("device_type")
audio_mpd_url = f"{chosen_audio_manifest['url']}?amznDtid={devicetype}&encoding=segmentBase"
self.log.info(" + Downloading Audio Manifest")
try:
audio_protocol = audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
if audio_protocol == "DASH":
_a_raw = self.session.get(audio_mpd_url).text
_a_lang_order = _build_ordered_lang_map_from_mpd(_a_raw)
self.log.info(f" + Audio MPD language map: {sum(len(v) for v in _a_lang_order.values())} entries")
audio_tracks = DASH.from_text(_a_raw, audio_mpd_url).to_tracks(language="en")
if _a_lang_order:
_apply_ordered_lang_map(audio_tracks.audio, _a_lang_order)
elif audio_protocol == "SmoothStreaming":
audio_tracks = ISM.from_url(url=audio_mpd_url, session=self.session).to_tracks(language="en")
else:
audio_tracks = Tracks([])
self._tag_amzn_tracks(audio_tracks, audio_manifest)
tracks.add(audio_tracks.audio, warn_only=True)
except Exception as e:
self.log.warning(f" - Failed to parse audio manifest: {e}")
if (self.config.get("device") or {}).get(self.profile or "default", None):
self.log.info(" + Fetching DV/UHD manifest for Atmos audio (576kbps DD+)")
temp_device = self.device
temp_token = self.device_token
temp_id = self.device_id
if not self.device_token:
try:
self.register_device()
except Exception:
pass
try:
uhd_audio_manifest = self.get_manifest(
title=title,
video_codec="H265",
bitrate_mode="CVBR+CBR",
quality="UHD",
hdr="DV",
ignore_errors=True
)
except Exception:
uhd_audio_manifest = None
self.device = temp_device
self.device_token = temp_token
self.device_id = temp_id
if uhd_audio_manifest and (chosen_uhd := self.choose_manifest(uhd_audio_manifest, self.cdn)):
uhd_url = self.clean_mpd_url(chosen_uhd["url"], optimise=False)
self.log.info(" + Downloading DV/UHD audio manifest")
try:
uhd_prot = uhd_audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
if uhd_prot == "DASH":
_uhd_raw = self.session.get(uhd_url).text
_uhd_lang_map = _build_ordered_lang_map_from_mpd(_uhd_raw)
uhd_tracks = DASH.from_text(_uhd_raw, uhd_url).to_tracks(language="en")
if _uhd_lang_map:
_apply_ordered_lang_map(uhd_tracks.audio, _uhd_lang_map)
self.log.info(f" + DV audio lang map: {sum(len(v) for v in _uhd_lang_map.values())} entries")
elif uhd_prot == "SmoothStreaming":
uhd_tracks = ISM.from_url(url=uhd_url, session=self.session).to_tracks(language="en")
else:
uhd_tracks = Tracks([])
self._tag_amzn_tracks(uhd_tracks, uhd_audio_manifest)
tracks.add(uhd_tracks.audio, warn_only=True)
_atmos_tracks = [x for x in uhd_tracks.audio if (x.bitrate or 0) >= 448000 and (x.channels or 0) >= 6]
if _atmos_tracks:
_best_kbps = max((x.bitrate or 0) for x in _atmos_tracks) // 1000
self.log.info(f" + Added {len(_atmos_tracks)} Atmos/high-bitrate audio track(s) from DV manifest (best: {_best_kbps} kb/s)")
else:
self.log.info(" + DV audio manifest fetched (no Atmos found for this title)")
except Exception as e:
self.log.warning(f" - Failed to parse DV audio manifest: {e}")
else:
self.log.warning(" - DV/UHD audio manifest unavailable for this title/region")
self._post_process_audio(tracks.audio)
_all_codecs = list({v.codec for v in tracks.videos if v.codec})
if self.vcodec == "AV1":
filtered = [v for v in tracks.videos if v.codec and ("av01" in v.codec.lower() or "av1" in v.codec.lower())]
if not filtered:
self.log.error(
f" - No AV1 tracks found (found codecs: {_all_codecs}). "
"The manifest may not have AV1 or the codec string is unexpected."
)
raise SystemExit(1)
tracks.videos = filtered
elif self.vcodec == "H265":
filtered = [v for v in tracks.videos if v.codec and any(x in v.codec.lower() for x in ["hev1", "hvc1", "h265"])]
if filtered:
tracks.videos = filtered
else:
filtered = [v for v in tracks.videos if v.codec and "avc" in v.codec.lower()]
if filtered:
tracks.videos = filtered
actual_range = (
manifest.get("vodPlaybackUrls", {}).get("result", {}).get("playbackUrls", {})
.get("urlMetadata", {}).get("dynamicRange", "None")
)
for video in tracks.videos:
video.hdr10 = actual_range == "Hdr10"
video.dv = actual_range == "DolbyVision"
if (self.range == "DV" or is_hybrid) and actual_range != "DolbyVision":
friendly = {"Hdr10": "HDR10", "None": "SDR"}.get(actual_range, actual_range)
self.log.warning(f" - Dolby Vision not available for this title/region. Server returned: {friendly}")
if (self.range == "DV" or is_hybrid) and actual_range == "DolbyVision":
self.log.info(" + Hybrid mode: fetching HDR10 manifest for base layer...")
hdr10_manifest = self.get_manifest(
title=title,
video_codec=effective_vcodec,
bitrate_mode=self.bitrate,
quality=self.vquality,
hdr="HDR10",
ignore_errors=True
)
if hdr10_manifest and hdr10_manifest.get("vodPlaybackUrls"):
chosen_hdr10 = self.choose_manifest(hdr10_manifest, self.cdn)
if chosen_hdr10:
hdr10_url = self.clean_mpd_url(chosen_hdr10["url"], False)
self.log.info(f" + HDR10 Manifest (base layer): {hdr10_url}")
try:
hdr10_protocol = hdr10_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"]
if hdr10_protocol == "DASH":
hdr10_tracks = DASH.from_url(url=hdr10_url, session=self.session).to_tracks(language="en")
elif hdr10_protocol == "SmoothStreaming":
hdr10_tracks = ISM.from_url(url=hdr10_url, session=self.session).to_tracks(language="en")
else:
hdr10_tracks = Tracks([])
for video in hdr10_tracks.videos:
video.hdr10 = True
video.dv = False
hdr10_video_count = len(list(hdr10_tracks.videos))
tracks.add(hdr10_tracks.videos, warn_only=True)
self.log.info(f" + Added {hdr10_video_count} HDR10 base-layer video track(s) for hybrid mux")
except Exception as e:
self.log.warning(f" - Failed to fetch HDR10 base layer for hybrid: {e}")
else:
self.log.warning(" - No HDR10 manifest CDN available for hybrid mode")
else:
self.log.warning(" - HDR10 manifest unavailable for hybrid mode; DV-only tracks will be used")
for sub in manifest.get("timedTextUrls", {}).get("result", {}).get("subtitleUrls", []) + \
manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []):
url = sub["url"]
url_ext = os.path.splitext(url.split("?")[0])[1].lstrip(".").lower()
codec_map = {
"ttml": Subtitle.Codec.TimedTextMarkupLang,
"dfxp": Subtitle.Codec.TimedTextMarkupLang,
"vtt": Subtitle.Codec.WebVTT,
"srt": Subtitle.Codec.SubRip,
}
detected_codec = codec_map.get(url_ext, Subtitle.Codec.TimedTextMarkupLang)
tracks.add(Subtitle(
id_=f"{sub['trackGroupId']}_{sub['languageCode']}_{sub['type']}_{sub['subtype']}",
url=url,
codec=detected_codec,
language=_resolve_subtitle_language(sub["languageCode"], url),
forced="ForcedNarrative" in sub["type"],
sdh=sub["type"].lower() == "sdh"
), warn_only=True)
try:
images = title.data.get("detail", {}).get("images", {})
if isinstance(title, Movie):
image_url = images.get("packshot") or images.get("titleshot") or images.get("covershot")
else:
image_url = images.get("covershot") or images.get("packshot") or images.get("titleshot")
if image_url and image_url.strip():
tracks.add(Attachment.from_url(
url=image_url.strip(),
name="cover",
mime_type="image/jpeg",
session=self.session,
))
except Exception as e:
self.log.warning(f" - Attachment failed: {e}")
if not self.no_true_region and self.vquality != "UHD" and tracks.videos:
self.manage_session(tracks.videos[0])
return tracks
def get_chapters(self, title: Title_T) -> Chapters:
manifest = title.data.get("_amzn_manifest") if isinstance(getattr(title, "data", None), dict) else None
if not manifest:
manifest = self.get_manifest(
title,
video_codec=self.vcodec,
bitrate_mode=self.bitrate,
quality=self.vquality,
hdr=self.range,
ignore_errors=True
)
if not manifest:
return Chapters()
chapters = self._chapters_from_transitions(manifest)
if chapters:
self.log.info(f" + Found {len(chapters)} chapter marker(s) from transition timecodes")
return Chapters(chapters)
return Chapters(self._chapters_from_xray(manifest))
def _chapters_from_transitions(self, manifest: dict) -> list:
result = manifest.get("transitionTimecodes", {}).get("result", {})
events = result.get("events")
if not events:
return []
def fmt(ms):
s = ms / 1000
return f"{int(s // 3600):02d}:{int((s % 3600) // 60):02d}:{s % 60:06.3f}"
chapters = []
for event in sorted(events, key=lambda e: e.get("startTimeMs", 0)):
start_ms = event.get("startTimeMs")
if start_ms is None:
continue
event_type = event.get("eventType", "")
if event_type == "SKIP_INTRO":
chapters.append(Chapter(name="Intro", timestamp=fmt(start_ms)))
if event.get("endTimeMs"):
chapters.append(Chapter(name="Main Content", timestamp=fmt(event["endTimeMs"])))
elif event_type == "SKIP_RECAP":
chapters.append(Chapter(name="Recap", timestamp=fmt(start_ms)))
if event.get("endTimeMs"):
chapters.append(Chapter(name="Episode Start", timestamp=fmt(event["endTimeMs"])))
elif event_type == "END_CREDITS":
chapters.append(Chapter(name="Credits", timestamp=fmt(start_ms)))
return chapters
def _chapters_from_xray(self, manifest: dict) -> list:
if "vodXrayMetadata" not in manifest or "error" in manifest["vodXrayMetadata"]:
return []
try:
xray_params = {
"pageId": "fullScreen",
"pageType": "xray",
"serviceToken": json.dumps({
"consumptionType": "Streaming",
"deviceClass": "normal",
"playbackMode": "playback",
"vcid": json.loads(manifest["vodXrayMetadata"]["result"]["parameters"]["serviceToken"])["vcid"]
}),
"deviceID": self.device_id,
"deviceTypeID": self.config["device_types"]["browser"],
"marketplaceID": self.region["marketplace_id"],
"gascEnabled": str(self.pv).lower(),
"decorationScheme": "none",
"version": "inception-v2",
"uxLocale": "en-US",
"featureScheme": "XRAY_WEB_2020_V1"
}
xray = self.session.get(url=self.endpoints["xray"], params=xray_params).json().get("page")
except Exception:
return []
if not xray:
return []
try:
widgets = xray["sections"]["center"]["widgets"]["widgetList"]
scenes = next((x for x in widgets if x["tabType"] == "scenesTab"), None)
if not scenes:
return []
scenes = scenes["widgets"]["widgetList"][0]["items"]["itemList"]
except (KeyError, IndexError):
return []
chapters = []
for scene in scenes:
chapter_title = scene["textMap"]["PRIMARY"]
match = re.search(r"(\d+\. |)(.+)", chapter_title)
if match:
chapter_title = match.group(2)
timecode = scene["textMap"]["TERTIARY"].replace("Starts at ", "")
chapters.append(Chapter(name=chapter_title, timestamp=timecode))
return chapters
def playbackEnvelope_data(self, title):
try:
res = self.session.get(
url=self.endpoints["metadata"],
params={
"metadataToEnrich": json.dumps({"placement": "HOVER", "playback": "true", "preroll": "true", "trailer": "true", "watchlist": "true"}),
"titleIDsToEnrich": json.dumps([title.id]),
"currentUrl": f"https://{self.region['base']}/"
},
headers={
"device-memory": "8",
"x-amzn-requestid": "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(20)),
"x-requested-with": "XMLHttpRequest"
}
)
if res.status_code != 200:
self.log.error(f"Unable to get playbackEnvelope: {res.text}"); raise SystemExit(1)
data = res.json()
enrichment = data.get("enrichments", {}).get(title.id, {})
playback_actions = [
a for a in enrichment.get("playbackActions", [])
if a.get("playbackExperienceMetadata", {}).get("playbackEnvelope")
]
if playback_actions:
pem = playback_actions[0]["playbackExperienceMetadata"]
self.playbackInfo = {"titleID": title.id, "playbackExperienceMetadata": pem}
return pem["playbackEnvelope"]
preroll = enrichment.get("prerollsEnvelope") or {}
preroll_env = preroll.get("playbackEnvelope")
if preroll_env:
self.playbackInfo = {"titleID": title.id, "playbackExperienceMetadata": preroll}
return preroll_env
focus = enrichment.get("entitlementCues", {}).get("focusMessage", {}).get("message", "")
if focus and any(k in focus.lower() for k in ("trial", "rent", "buy", "subscribe", "join")):
self.log.error(
f" - This account is not entitled to '{title}'. Amazon returned: \"{focus}\". "
"Account needs a purchase/rental/subscription, or the cookies belong to "
"a different account."
); raise SystemExit(1)
self.log.error(
f" - No playback envelope returned for '{title}' "
f"(not entitled, or bad cookies). Entitlement cue: {focus or 'none'}"
); raise SystemExit(1)
except SystemExit:
raise
except Exception as e:
self.log.error(f"Unable to get playbackEnvelope: {e}"); raise SystemExit(1)
def playbackEnvelope_update(self, playbackInfo):
if not playbackInfo:
return playbackInfo
try:
pem = playbackInfo.get("playbackExperienceMetadata", {}) or {}
expiry = pem.get("expiryTime", 0)
if expiry and (int(expiry) / 1000) > time.time():
return playbackInfo
correlation_id = pem.get("correlationId")
title_id = playbackInfo.get("titleID")
if not correlation_id or not title_id:
return playbackInfo
self.log.debug(" + Refreshing expired playback envelope")
res = self.session.post(
url=self.endpoints["refreshplayback"],
params={
"deviceID": self.device_id,
"deviceTypeID": self.device["device_type"],
"gascEnabled": str(self.pv).lower(),
"marketplaceID": self.region["marketplace_id"],
"uxLocale": "en_EN",
"firmware": "1",
"version": "1",
"nerid": self.generate_nerid(),
},
data=json.dumps({
"deviceId": self.device_id,
"deviceTypeId": self.device["device_type"],
"identifiers": {title_id: correlation_id},
"geoToken": "null",
"identityContext": "null",
}),
)
if res.status_code != 200:
return playbackInfo
response_data = res.json().get("response", {})
if not isinstance(response_data, dict):
return playbackInfo
new_pe = response_data.get(title_id, {}).get("playbackExperience")
if not new_pe:
return playbackInfo
new_expiry = new_pe.get("expiryTime")
if new_expiry:
new_pe["expiryTime"] = int(new_expiry * 1000)
refreshed = {"titleID": title_id, "playbackExperienceMetadata": new_pe}
self.playbackInfo = refreshed
if new_pe.get("playbackEnvelope"):
self.playbackEnvelope = new_pe["playbackEnvelope"]
return refreshed
except Exception as e:
self.log.debug(f"Playback envelope refresh failed (non-fatal): {e}")
return playbackInfo
def _build_manifest_payload(self, title, video_codec, bitrate_mode, quality, hdr, protocol, use_playlisted):
bitrate_adaptations = ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode]
range_fmt = self.VIDEO_RANGE_MAP.get(hdr, "None")
drm_type = "PlayReady" if self.playready else "Widevine"
if use_playlisted and self.device_token:
def build_tech():
return {
"bitrateAdaptations": bitrate_adaptations,
"codecs": [video_codec],
"drmType": drm_type,
"drmKeyScheme": "SingleKey" if self.playready else "DualKey",
"drmStrength": "L40",
"dynamicRangeFormats": [range_fmt],
"edgeDeliveryAuthorizationSchemes": ["PVExchangeV1", "Transparent"],
"fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"],
"frameRates": ["Standard", "High"],
"segmentInfoType": "Base",
"stitchType": "MultiPeriod",
"timedTextRepresentations": ["BurnedIn", "NotInManifestNorStream", "SeparateStreamInManifest"],
"trickplayRepresentations": ["NotInManifestNorStream"],
"variableAspectRatio": "supported",
"vastTimelineType": "Absolute",
"manifestThinningToSupportedResolution": "Forbidden",
}
if protocol == "SmoothStreaming":
techs = {"SmoothStreaming": build_tech()}
supported_techs = ["SmoothStreaming"]
else:
techs = {"DASH": build_tech(), "SmoothStreaming": build_tech()}
supported_techs = ["DASH", "SmoothStreaming"]
return {
"globalParameters": {
"deviceCapabilityFamily": "LivingRoomPlayer",
"playbackEnvelope": self.playbackEnvelope,
"capabilityDiscriminators": {"discriminators": {"software": {}, "version": 1}},
},
"timedTextUrlsRequest": {"supportedTimedTextFormats": ["TTMLv2", "DFXP"]},
"transitionTimecodesRequest": {},
"vodPlaylistedPlaybackUrlsRequest": {
"device": {
"displayBasedVending": "supported",
"displayHeight": 2304,
"displayWidth": 4096,
"hdcpLevel": "2.3",
"maxVideoResolution": "2160p",
"category": "Tv",
"platform": "Android",
"streamingTechnologies": techs,
"supportedStreamingTechnologies": supported_techs,
},
"playbackSettingsRequest": {
"firmware": "UNKNOWN",
"playerType": self.player,
"responseFormatVersion": "1.0.0",
"titleId": title.id,
},
},
"vodXrayMetadataRequest": {
"xrayDeviceClass": "normal",
"xrayPlaybackMode": "playback",
"xrayToken": "XRAY_REIGN_3PLR_2025_V2",
},
}
if not self.device_token:
if protocol == "SmoothStreaming":
self.log.warning(" - ISM/SmoothStreaming requires a registered device; using DASH for web playback.")
global_params = {
"deviceCapabilityFamily": "WebPlayer",
"playbackEnvelope": self.playbackEnvelope,
"capabilityDiscriminators": {
"operatingSystem": {"name": "Windows", "version": "10.0"},
"middleware": {"name": "EdgeNext", "version": "136.0.0.0"},
"nativeApplication": {"name": "EdgeNext", "version": "136.0.0.0"},
"hfrControlMode": "Legacy",
"displayResolution": {"height": 2304, "width": 4096}
}
}
audit_request = {}
vod_request = {
"device": {
"hdcpLevel": "2.2" if quality == "UHD" else "1.4",
"maxVideoResolution": ("1080p" if quality == "HD" else "480p" if quality == "SD" else "2160p"),
"supportedStreamingTechnologies": ["DASH"],
"streamingTechnologies": {
"DASH": {
"bitrateAdaptations": bitrate_adaptations,
"codecs": [video_codec],
"drmKeyScheme": "SingleKey" if self.playready else "DualKey",
"drmType": drm_type,
"dynamicRangeFormats": range_fmt,
"fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"],
"frameRates": ["Standard"],
"stitchType": "MultiPeriod",
"segmentInfoType": "Base",
"timedTextRepresentations": ["NotInManifestNorStream", "SeparateStreamInManifest"],
"trickplayRepresentations": ["NotInManifestNorStream"],
"variableAspectRatio": "supported"
}
},
"displayWidth": 4096,
"displayHeight": 2304
},
"ads": {"sitePageUrl": "", "gdpr": {"enabled": "false", "consentMap": {}}},
"playbackCustomizations": {},
"playbackSettingsRequest": {
"firmware": "UNKNOWN",
"playerType": self.player,
"responseFormatVersion": "1.0.0",
"titleId": title.id
}
}
else:
global_params = {
"deviceCapabilityFamily": "AndroidPlayer",
"playbackEnvelope": self.playbackEnvelope,
"capabilityDiscriminators": {"discriminators": {"software": {}, "version": 1}}
}
audit_request = {"device": {"category": "Tv", "platform": "Android"}}
technology = "SmoothStreaming" if protocol == "SmoothStreaming" else "DASH"
tech_block = {
technology: {
"bitrateAdaptations": bitrate_adaptations,
"codecs": [video_codec],
"drmType": drm_type,
"drmKeyScheme": "SingleKey" if self.playready else "DualKey",
"drmStrength": "L40",
"dynamicRangeFormats": [range_fmt],
"edgeDeliveryAuthorizationSchemes": ["PVExchangeV1", "Transparent"],
"fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"],
"frameRates": ["Standard", "High"],
"segmentInfoType": "Base",
"stitchType": "MultiPeriod",
"timedTextRepresentations": ["BurnedIn", "NotInManifestNorStream", "SeparateStreamInManifest"],
"trickplayRepresentations": ["NotInManifestNorStream"],
"variableAspectRatio": "supported",
"vastTimelineType": "Absolute",
"manifestThinningToSupportedResolution": "Forbidden"
}
}
vod_request = {
"ads": {},
"device": {
"displayBasedVending": "supported",
"displayHeight": 2304,
"displayWidth": 4096,
"streamingTechnologies": tech_block,
"acceptedCreativeApis": [],
"category": "Tv",
"hdcpLevel": "2.2",
"maxVideoResolution": "2160p",
"platform": "Android",
"supportedStreamingTechnologies": [technology]
},
"playbackCustomizations": {},
"playbackSettingsRequest": {
"firmware": "UNKNOWN",
"playerType": self.player,
"responseFormatVersion": "1.0.0",
"titleId": title.id
}
}
return {
"globalParameters": global_params,
"auditPingsRequest": audit_request,
"playbackDataRequest": {},
"timedTextUrlsRequest": {"supportedTimedTextFormats": ["TTMLv2", "DFXP"]},
"trickplayUrlsRequest": {},
"transitionTimecodesRequest": {},
"vodPlaybackUrlsRequest": vod_request,
"vodXrayMetadataRequest": {
"xrayDeviceClass": "normal",
"xrayPlaybackMode": "playback",
"xrayToken": "XRAY_WEB_2023_V2"
}
}
@staticmethod
def _normalize_playlisted_manifest(manifest: dict) -> dict:
playlisted = manifest.get("vodPlaylistedPlaybackUrls", {}) or {}
converted = dict(manifest)
if "error" in playlisted:
converted["vodPlaybackUrls"] = {"error": playlisted["error"]}
return converted
playback_urls = playlisted.get("result", {}).get("playbackUrls", {}) or {}
playlist = playback_urls.get("intraTitlePlaylist", []) or []
main = next((x for x in playlist if x.get("type") == "Main"), playlist[0] if playlist else None)
if not main:
converted["vodPlaybackUrls"] = {"result": {"playbackUrls": {"urlSets": [], "urlMetadata": {}}}}
return converted
url_sets = [
{"cdn": u.get("cdn"), "url": u.get("url"), "consumptionId": u.get("consumptionId")}
for u in main.get("urls", []) if u.get("url")
]
converted["vodPlaybackUrls"] = {
"result": {
"playbackUrls": {
"urlSets": url_sets,
"urlMetadata": main.get("manifestMetadata", {}),
}
}
}
return converted
def get_manifest(self, title, video_codec, bitrate_mode, quality, hdr, ignore_errors=False,
protocol="DASH", use_playlisted=False, retries=3):
if use_playlisted and not self.device_token:
self.log.warning(" - Playlisted (LivingRoomPlayer) path needs a registered device; using classic path")
use_playlisted = False
for attempt in range(retries):
if attempt == 0 or not self.playbackInfo:
self.playbackEnvelope = self.playbackEnvelope_data(title)
else:
self.log.debug(f" + Manifest retry {attempt + 1}/{retries}; refreshing playback envelope")
try:
self.playbackInfo["playbackExperienceMetadata"]["expiryTime"] = 0
except Exception:
pass
self.playbackInfo = self.playbackEnvelope_update(self.playbackInfo)
self.playbackEnvelope = (
(self.playbackInfo or {}).get("playbackExperienceMetadata", {}).get("playbackEnvelope")
or self.playbackEnvelope
)
data_dict = self._build_manifest_payload(
title, video_codec, bitrate_mode, quality, hdr, protocol, use_playlisted
)
res = self.session.post(
url=self.endpoints["playback"],
params={
"deviceID": self.device_id,
"deviceTypeID": self.device["device_type"],
"gascEnabled": str(self.pv).lower(),
"marketplaceID": self.region["marketplace_id"],
"uxLocale": "en_EN",
"firmware": 1,
"titleId": title.id,
"nerid": self.generate_nerid(),
},
data=json.dumps(data_dict),
headers={
"Authorization": f"Bearer {self.device_token}" if self.device_token else None,
},
)
try:
manifest = res.json()
except json.JSONDecodeError:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
if ignore_errors:
return {}
self.log.error(f" - Amazon reported an error when obtaining the Playback Manifest\n{res.text}"); raise SystemExit(1)
if "vodPlaylistedPlaybackUrls" in manifest and "vodPlaybackUrls" not in manifest:
manifest = self._normalize_playlisted_manifest(manifest)
vod = manifest.get("vodPlaybackUrls", {})
if video_codec == "AV1" and "error" in vod:
self.log.warning(f" - AV1 manifest not available: {vod['error'].get('message', 'unknown error')}")
return {}
if "error" in vod:
if ignore_errors:
return {}
message = vod["error"]["message"]
self.log.error(f" - Amazon reported an error when obtaining the Playback Manifest: {message}"); raise SystemExit(1)
for resource in ("PlaybackUrls", "AudioVideoUrls"):
err = manifest.get("errorsByResource", {}).get(resource)
if err and err.get("errorCode") not in (None, "PRS.NoRights.NotOwned"):
if ignore_errors:
return {}
self.log.error(
f" - Amazon had an error with the {resource}: {err.get('message')} [{err.get('errorCode')}]"
); raise SystemExit(1)
return manifest
return {}
def get_widevine_service_certificate(self, **_: Any) -> str:
return self.config["certificate"]
def get_widevine_license(self, challenge: bytes, title: Title_T, track, **_) -> str:
return self._get_license(challenge, title, track, widevine=True)
def get_playready_license(self, challenge: bytes, title: Title_T, track, **_) -> str:
return self._get_license(challenge, title, track, widevine=False)
def _get_license(self, challenge: bytes, title: Title_T, track, widevine: bool):
challenge_bytes = challenge if isinstance(challenge, bytes) else challenge.encode("utf-8")
encoded_challenge = base64.b64encode(challenge_bytes).decode("utf-8")
amzn = track.data.get("amzn", {}) if isinstance(getattr(track, "data", None), dict) else {}
envelope = amzn.get("envelope") or self.playbackEnvelope
handoff = amzn.get("handoff") or self.session_handoff_token
data_lic = {
"licenseChallenge": encoded_challenge,
"playbackEnvelope": envelope,
}
if widevine:
data_lic["includeHdcpTestKey"] = True
if self.device_token:
d = self.device
software = {
"application": {"name": d.get("app_name", "AIV"), "version": d.get("firmware") or d.get("app_version", "1.0")},
"client": {"id": None},
"operatingSystem": {"name": d.get("os_name", "Android"), "version": str(d.get("os_version", "28"))},
"player": {"name": "Android UIPlayer SDK", "version": "4.1.18"},
"renderer": {"drmScheme": "WIDEVINE" if widevine else "PLAYREADY", "name": "MCMD"},
}
if d.get("firmware_version"):
software["firmware"] = {"version": str(d["firmware_version"])}
data_lic.update({
"capabilityDiscriminators": {
"discriminators": {
"hardware": {
"chipset": d.get("device_chipset", ""),
"manufacturer": d.get("manufacturer", ""),
"modelName": d.get("device_model", ""),
},
"software": software,
},
"version": 1,
},
"deviceCapabilityFamily": "AndroidPlayer",
"packagingFormat": "SMOOTH_STREAMING" if (isinstance(getattr(track, "data", None), dict) and "ism" in track.data) else "MPEG_DASH",
})
if getattr(track, "kid", None):
try:
data_lic["keyId"] = str(uuid.UUID(str(track.kid))).upper()
except (ValueError, AttributeError):
pass
else:
if not handoff:
self.log.error(" - No sessionHandoffToken available."); raise SystemExit(1)
data_lic.update({
"deviceCapabilityFamily": "WebPlayer",
"sessionHandoffToken": handoff,
})
endpoint = self.endpoints["license_wv"] if widevine else self.endpoints["license_pr"]
res = self.session.post(
url=endpoint,
params={
"deviceID": self.device_id,
"deviceTypeID": self.device["device_type"],
"gascEnabled": str(self.pv).lower(),
"marketplaceID": self.region["marketplace_id"],
"uxLocale": "en_EN",
"firmware": 1,
"titleId": title.id,
"nerid": self.generate_nerid(),
},
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {self.device_token}" if self.device_token else None,
},
json=data_lic
).json()
if "errorsByResource" in res:
error = res["errorsByResource"]
code = error.get("errorCode") or error.get("type") or "Unknown"
if code == "PRS.NoRights.AnonymizerIP":
self.log.error(" - Amazon detected a Proxy/VPN and refused to return a license."); raise SystemExit(1)
self.log.error(f" - Amazon reported an error during the License request: [{code}]"); raise SystemExit(1)
if "error" in res:
self.log.error(f" - License Error: {res['error']['message']}"); raise SystemExit(1)
primary_key = "widevineLicense" if widevine else "playReadyLicense"
lic = res.get(primary_key)
if isinstance(lic, dict) and lic.get("license"):
return lic["license"]
for key, val in res.items():
if "license" in key.lower() and isinstance(val, dict) and val.get("license"):
return val["license"]
if "license" in key.lower() and isinstance(val, str) and val:
return val
self.log.error(
f" - License response did not contain a '{primary_key}'. "
f"Response keys: {list(res.keys())}. Raw (truncated): {json.dumps(res)[:600]}"
)
raise SystemExit(1)
def _tag_amzn_tracks(self, track_iterable, manifest: dict) -> None:
token = (manifest.get("sessionization") or {}).get("sessionHandoffToken")
envelope = self.playbackEnvelope
for t in track_iterable:
try:
if not isinstance(t.data, dict):
continue
amzn = t.data.setdefault("amzn", {})
if token:
amzn["handoff"] = token
if envelope:
amzn["envelope"] = envelope
except Exception:
pass
def _post_process_audio(self, audio_tracks) -> None:
for audio in audio_tracks:
try:
data = getattr(audio, "data", None)
if not isinstance(data, dict) or "dash" not in data:
continue
adaptation_set = data["dash"].get("adaptation_set")
representation = data["dash"].get("representation")
if adaptation_set is None:
continue
subtype = (adaptation_set.get("audioTrackSubtype") or "").lower()
if "descriptive" in subtype:
audio.descriptive = True
if "boosteddialog" in subtype:
audio.bitrate = 1
if not audio.joc:
for elem in (adaptation_set, representation):
if elem is None:
continue
try:
props = elem.findall("SupplementalProperty") + elem.findall("EssentialProperty")
except Exception:
props = []
for prop in props:
scheme = prop.get("schemeIdUri", "")
value = (prop.get("value", "") or "").upper()
if scheme == "tag:dolby.com,2018:dash:EC3_ExtensionType:2018" and value == "JOC":
audio.joc = 16
break
if audio.joc:
break
except Exception:
continue
def manage_session(self, track) -> None:
try:
handoff = None
if isinstance(getattr(track, "data", None), dict):
handoff = (track.data.get("amzn") or {}).get("handoff")
handoff = handoff or self.session_handoff_token
if not handoff or not isinstance(handoff, str):
self.log.debug("No session handoff token available; skipping keep-alive session")
return
refreshed = self.playbackEnvelope_update(self.playbackInfo) or self.playbackInfo or {}
envelope = (refreshed.get("playbackExperienceMetadata", {}) or {}).get("playbackEnvelope") \
or self.playbackEnvelope
if not envelope:
self.log.debug("No playback envelope for keep-alive session; skipping")
return
def session_params():
return {
"deviceID": self.device_id,
"deviceTypeID": self.device["device_type"],
"gascEnabled": str(self.pv).lower(),
"marketplaceID": self.region["marketplace_id"],
"uxLocale": "en_EN",
"firmware": "1",
"version": "1",
"nerid": self.generate_nerid(),
}
headers = {
"Content-Type": "application/json",
"accept": "application/json",
"x-request-priority": "CRITICAL",
"x-retry-count": "0",
}
progress = round(random.uniform(0, 10), 6)
step = 3
start_time = datetime.utcnow().isoformat(timespec="milliseconds") + "Z"
res = self.session.post(
url=self.endpoints["opensession"],
params=session_params(),
headers=headers,
json={
"sessionHandoff": handoff,
"playbackEnvelope": envelope,
"streamInfo": {
"eventType": "START",
"streamUpdateTime": progress,
"vodProgressInfo": {
"currentProgressTime": f"PT{progress:.6f}S",
"timeFormat": "ISO8601DURATION",
},
},
"userWatchSessionId": str(uuid.uuid4()),
},
)
if res.status_code != 200:
self.log.debug(f"Keep-alive: unable to open session ({res.status_code})")
return
session_token = res.json().get("sessionToken")
if not session_token:
return
time.sleep(step)
update_time = (datetime.fromisoformat(start_time[:-1]) + timedelta(seconds=step)) \
.isoformat(timespec="milliseconds") + "Z"
res = self.session.post(
url=self.endpoints["updatesession"],
params=session_params(),
headers=headers,
json={
"sessionToken": session_token,
"streamInfo": {
"eventType": "PAUSE",
"streamUpdateTime": update_time,
"vodProgressInfo": {
"currentProgressTime": f"PT{progress + step:.6f}S",
"timeFormat": "ISO8601DURATION",
},
},
},
)
if res.status_code != 200:
self.log.debug(f"Keep-alive: unable to update session ({res.status_code})")
return
session_token = res.json().get("sessionToken", session_token)
res = self.session.post(
url=self.endpoints["closesession"],
params=session_params(),
headers=headers,
json={
"sessionToken": session_token,
"streamInfo": {
"eventType": "STOP",
"streamUpdateTime": update_time,
"vodProgressInfo": {
"currentProgressTime": f"PT{progress + step:.6f}S",
"timeFormat": "ISO8601DURATION",
},
},
},
)
if res.status_code == 200:
self.log.info(" + True-region playback session completed")
else:
self.log.debug(f"Keep-alive: unable to close session ({res.status_code})")
except Exception as e:
self.log.debug(f"Keep-alive session failed: {e}")
@staticmethod
def _usable_manifest(manifest: dict) -> bool:
return bool(
(manifest or {}).get("vodPlaybackUrls", {}).get("result", {})
.get("playbackUrls", {}).get("urlSets")
)
def choose_manifest(self, manifest: dict, cdn=None):
if not manifest or "vodPlaybackUrls" not in manifest:
return {}
url_sets = (
manifest["vodPlaybackUrls"].get("result", {}).get("playbackUrls", {}).get("urlSets", [])
)
if not url_sets:
return {}
if cdn:
cdn = cdn.lower()
return next((x for x in url_sets if (x.get("cdn") or "").lower() == cdn), {})
return secrets.choice(url_sets)
@staticmethod
def generate_nerid(length=24):
chars = string.ascii_letters + string.digits
return "".join(secrets.choice(chars) for _ in range(length))
@staticmethod
def clean_mpd_url(mpd_url, optimise=False):
if optimise:
return mpd_url.replace("~", "") + "?encoding=segmentBase"
if match := re.match(r"(https?://.*/)d.?/.*~/(.*)", mpd_url):
mpd_url = "".join(match.groups())
else:
try:
mpd_url = "".join(
re.split(r"(?i)(/)", mpd_url)[:5] + re.split(r"(?i)(/)", mpd_url)[9:]
)
except IndexError:
pass
return mpd_url
def get_region(self) -> dict:
domain_region = self.get_domain_region()
if not domain_region:
return {}
region = self.config["regions"].get(domain_region)
if not region:
self.log.error(f" - There's no region configuration data for the region: {domain_region}"); raise SystemExit(1)
region["code"] = domain_region
if self.pv:
res = self.session.get("https://www.primevideo.com").text
match = re.search(r'ue_furl *= *([\'"])fls-(na|eu|fe)\.amazon\.[a-z.]+\1', res)
if match:
pv_region = match.group(2).lower()
else:
self.log.error(" - Failed to get PrimeVideo region"); raise SystemExit(1)
pv_region = {"na": "atv-ps"}.get(pv_region, f"atv-ps-{pv_region}")
region["base_manifest"] = f"{pv_region}.primevideo.com"
region["base"] = "www.primevideo.com"
return region
def get_domain_region(self):
tlds = [tldextract.extract(x.domain) for x in self.session.cookies if x.domain_specified]
tld = next((x.suffix for x in tlds if x.domain.lower() in ("amazon", "primevideo")), None)
if tld:
tld = tld.split(".")[-1]
region = {"com": "us", "uk": "gb"}.get(tld, tld)
if region == "us":
lc_cookie = next(
(x.value for x in self.session.cookies
if x.name in ("lc-main-av", "lc-main") and x.domain_specified),
None
)
if lc_cookie:
parts = lc_cookie.replace("-", "_").split("_")
if len(parts) >= 2:
country = parts[-1].lower()
if country not in ("us", ""):
mapped = {"uk": "gb"}.get(country, country)
if mapped in self.config.get("regions", {}):
region = mapped
return region
def prepare_endpoint(self, name: str, uri: str, region: dict) -> str:
if name in ("playback", "license_wv", "license_pr", "xray",
"refreshplayback", "opensession", "updatesession", "closesession",
"configuration"):
return f"https://{region['base_manifest']}{uri}"
if name in ("ontv", "devicelink", "details", "getDetailWidgets", "metadata"):
if self.pv:
host = "www.primevideo.com"
else:
host = f"{region['base']}/gp/video" if name == "metadata" else region["base"]
return f"https://{host}{uri}"
if name in ("codepair", "register", "token"):
base_api = region.get("base_api") or self.config["regions"]["us"]["base_api"]
return f"https://{base_api}{uri}"
raise ValueError(f"Unknown endpoint: {name}")
def prepare_endpoints(self, endpoints: dict, region: dict) -> dict:
return {k: self.prepare_endpoint(k, v, region) for k, v in endpoints.items()}
def register_device(self) -> None:
_profile = self.profile or "default"
self.device = dict((self.config.get("device") or {}).get(_profile, {}))
identity_cache = Cacher("AMZN")
identity_key = f"device_identity_{_profile}"
cached_identity = identity_cache.get(identity_key)
if cached_identity and cached_identity.data:
identity = cached_identity.data
self.log.debug(" + Using cached device identity")
else:
unique_serial = secrets.token_hex(8)
base_name = self.device.get("device_name", "%FIRST_NAME%'s Shield TV")
clean_name = re.sub(r"%DUPE_STRATEGY[^%]*%", "", base_name).rstrip()
suffix = secrets.token_hex(2).upper()
unique_name = f"{clean_name}-{suffix}"
identity = {"device_serial": unique_serial, "device_name": unique_name}
cached_identity = identity_cache.get(identity_key)
cached_identity.set(identity, int(time.time()) + 60 * 60 * 24 * 3650)
self.log.info(f" + Generated unique device identity: serial={unique_serial}, name={unique_name!r}")
self.device["device_serial"] = identity["device_serial"]
self.device["device_name"] = identity["device_name"]
device_hash = hashlib.md5(json.dumps(self.device, sort_keys=True).encode()).hexdigest()[0:6]
device_cache_path = f"device_tokens_{_profile}_{device_hash}"
self.device_token = self.DeviceRegistration(
device=self.device,
endpoints=self.endpoints,
log=self.log,
cache_path=device_cache_path,
session=self.session
).bearer
self.device_id = self.device.get("device_serial")
if not self.device_id:
self.log.error(f" - A device serial is required in the config, try: {os.urandom(8).hex()}"); raise SystemExit(1)
class DeviceRegistration:
def __init__(self, device: dict, endpoints: dict, cache_path: str, session: requests.Session, log):
self.session = session
self.device = device
self.endpoints = endpoints
self.cache_path = cache_path
self.log = log
self.cache = Cacher("AMZN")
self.device = {k: str(v) if not isinstance(v, str) else v for k, v in self.device.items()}
self.bearer = None
cached_data = self.cache.get(self.cache_path)
if cached_data and cached_data.data:
if cached_data.data.get("expires_in", 0) > int(time.time()):
self.log.info(" + Using cached device bearer")
self.bearer = cached_data.data["access_token"]
else:
self.log.info("Cached device bearer expired, refreshing...")
refresh_token = cached_data.data.get("refresh_token")
refreshed_tokens = self.refresh(self.device, refresh_token) if refresh_token else None
if refreshed_tokens and refreshed_tokens.get("access_token"):
refreshed_tokens["refresh_token"] = refreshed_tokens.get("refresh_token") or refresh_token
expires_seconds = int(refreshed_tokens.get("expires_in") or 3600)
refreshed_tokens["expires_in"] = int(time.time()) + expires_seconds
cached_data.set(refreshed_tokens, refreshed_tokens["expires_in"])
self.bearer = refreshed_tokens["access_token"]
else:
self.log.info(" + Re-registering device bearer")
self.bearer = self.register(self.device)
else:
self.log.info(" + Registering new device bearer")
self.bearer = self.register(self.device)
def register(self, device: dict) -> str:
code_pair = self.get_code_pair(device)
public_code = code_pair["public_code"]
self.log.info(f" + Visit https://www.primevideo.com/mytv and enter the code: {public_code}")
self.log.info(" Waiting for authorisation (up to 5 minutes)")
interval = 10
deadline = int(time.time()) + 300
while int(time.time()) < deadline:
res = self.session.post(
url=self.endpoints["register"],
headers={"Content-Type": "application/json", "Accept-Language": "en-US"},
json={
"auth_data": {"code_pair": code_pair},
"registration_data": device,
"requested_token_type": ["bearer"],
"requested_extensions": ["device_info", "customer_info"]
},
cookies=None
)
data = res.json()
if res.status_code == 200 and "success" in data.get("response", {}):
break
error_code = data.get("response", {}).get("error", {}).get("code", "")
if error_code == "Unauthorized":
time.sleep(interval)
continue
else:
self.log.error(f"Unable to register: {res.text}"); raise SystemExit(1)
else:
self.log.error("Device registration timed out. Code not approved in time."); raise SystemExit(1)
bearer = data["response"]["success"]["tokens"]["bearer"]
expires_val = bearer.get("expires_in", 3600)
if isinstance(expires_val, dict):
expires_val = expires_val.get("value", 3600)
bearer_data = {
"access_token": bearer["access_token"],
"refresh_token": bearer.get("refresh_token", ""),
"expires_in": int(time.time()) + int(expires_val),
}
keyed_cache = self.cache.get(self.cache_path)
keyed_cache.set(bearer_data, int(time.time()) + int(expires_val))
self.log.info(" + Device registered and token cached successfully")
return bearer_data["access_token"]
def refresh(self, device: dict, refresh_token: str):
try:
res = self.session.post(
url=self.endpoints["token"],
json={
"app_name": device["app_name"],
"app_version": device["app_version"],
"source_token_type": "refresh_token",
"source_token": refresh_token,
"requested_token_type": "access_token"
}
).json()
except Exception as e:
self.log.warning(f"Device token refresh request failed ({e}); re-registering.")
return None
if "error" in res:
self.log.warning(
f"Could not refresh device token ({res.get('error_description') or res.get('error')}); re-registering."
)
return None
return res
def get_csrf_token(self) -> str:
res = self.session.get(self.endpoints["ontv"])
if 'name="appAction" value="SIGNIN"' in res.text or "SIGNIN_PWD_COLLECT" in res.text:
self.log.error("Cookies are signed out, cannot get ontv CSRF token.")
raise SystemExit(1)
for match in re.finditer(r'<script type="text/template">(.+?)</script>', res.text, re.DOTALL):
try:
prop = json.loads(match.group(1))
token = prop.get("props", {}).get("codeEntry", {}).get("token")
if token:
return token
token = prop.get("codeEntry", {}).get("token")
if token:
return token
except Exception:
pass
ce_idx = res.text.find('"codeEntry"')
if ce_idx != -1:
snippet = res.text[ce_idx:ce_idx + 2000]
m2 = re.search(r'"token"\s*:\s*"([^"]+)"', snippet)
if m2:
return m2.group(1)
self.log.error("Unable to get ontv CSRF token")
raise SystemExit(1)
def get_code_pair(self, device: dict) -> dict:
res = self.session.post(
url=self.endpoints["codepair"],
headers={"Content-Type": "application/json", "Accept-Language": "en-US"},
json={"code_data": device}
).json()
if "error" in res:
self.log.error(f"Unable to get code pair: {res['error']}"); raise SystemExit(1)
return res
def parse_title(self, ctx, title):
title = title or ctx.parent.params.get("title")
if not title:
self.log.error(" - No title ID specified")
if not getattr(self, "TITLE_RE"):
self.title = title
return {}
for regex in as_list(self.TITLE_RE):
m = re.search(regex, title)
if m:
self.title = m.group("id")
return m.groupdict()
self.log.warning(f" - Unable to parse title ID {title!r}, using as-is")
self.title = title