Enhance AMZN service with additional features and logging

Added Video import and enhanced logging for licensing and manifest retrieval processes. Improved error handling for entitlement checks and device registration.
This commit is contained in:
n0stal6ic
2026-07-09 08:54:17 -05:00
committed by GitHub
parent c5553b89c0
commit 7c9ccc9675
+419 -107
View File
@@ -23,7 +23,7 @@ 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.tracks import Attachment, Chapter, Chapters, Subtitle, Tracks, Video
from unshackle.core.utils.collections import as_list
@@ -90,8 +90,8 @@ class AMZN(Service):
"""
Service code for Amazon VOD (https://amazon.com) & Amazon Prime Video (https://primevideo.com).
Author: n0stal6ic
Authorization: Cookies
www.nostalgic.cc
Authorization: Credentials, Cookies
Security: UHD@L1 FHD@Chrome SD@L3
"""
@@ -176,6 +176,7 @@ class AMZN(Service):
self.playlisted = playlisted
assert ctx.parent is not None
self.ctx = ctx
self.chapters_only = ctx.parent.params.get("chapters_only")
self.quality = ctx.parent.params.get("quality") or 1080
@@ -184,12 +185,15 @@ class AMZN(Service):
range_ = ctx.parent.params.get("range_")
self.range = range_[0].name if range_ else "SDR"
if vcodec and "AV1" in str(vcodec).upper():
vstr = str(vcodec).upper() if vcodec else ""
if "AV1" in vstr:
self.vcodec = "AV1"
elif vcodec and "HEVC" in str(vcodec).upper():
elif "HEVC" in vstr:
self.vcodec = "H265"
else:
elif "AVC" in vstr:
self.vcodec = "H264"
else:
self.vcodec = "AV1"
self.cdm = ctx.obj.cdm
self.profile = ctx.obj.profile
@@ -202,12 +206,16 @@ class AMZN(Service):
self.pv = False
self.event = False
self.device_token = None
self.device_refresh_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
self.actor_token = None
self.profile_id = None
self.living_room = False
if self.vquality_source != ParameterSource.COMMANDLINE:
q_check = self.quality[0] if isinstance(self.quality, list) else self.quality
@@ -241,6 +249,7 @@ class AMZN(Service):
self.log.info(f" + Changed bitrate mode to CBR to be able to get highest quality UHD {self.range} video track")
self.orig_bitrate = self.bitrate
self.requested_vcodec = self.vcodec
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
@@ -290,7 +299,7 @@ class AMZN(Service):
if self.manifest_type == "ISM" and not self.device:
self.log.error(
" - ISM/SmoothStreaming requires a configured device in config.yaml. "
" - ISM/SmoothStreaming requires a configured device in config.yaml (device: profile). "
"Add one or use --manifest DASH."
)
raise SystemExit(1)
@@ -327,6 +336,15 @@ class AMZN(Service):
params={"deviceTypeID": self.device["device_type"], "deviceID": "Web"}
)
self._apply_verified_territory(res_cfg)
if self.playlisted and self.device_token:
if self._ensure_actor_token():
self.living_room = True
self.log.info(" + LivingRoomPlayer mode active (actor-token envelope + licensing)")
else:
self.log.warning(
" - Could not acquire an actor token; --playlisted will use the classic "
"envelope/licensing context"
)
def _apply_verified_territory(self, res_cfg) -> None:
if res_cfg.status_code != 200:
@@ -342,8 +360,8 @@ class AMZN(Service):
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)."
f"Amazon geo-locates by IP — licensing (and sometimes title lookup) may be blocked. "
f"If the license step fails, use a {account_region} IP (e.g. --proxy {account_region} or a matching VPN)."
)
return
if territory:
@@ -353,6 +371,25 @@ class AMZN(Service):
elif marketplace and self.no_true_region:
self.log.debug(" + --no-true-region set; keeping marketplace from config, ignoring IP-geo value")
@staticmethod
def _clean_show_name(*candidates: Optional[str]) -> str:
season_suffix = re.compile(
r"[\s:\-–—]+(?:Season|Series|Staffel|Saison|Temporada|Stagione|Seizoen)\s+\d+\s*$",
re.IGNORECASE,
)
cleaned_fallback = ""
for candidate in candidates:
if not candidate:
continue
cleaned = season_suffix.sub("", candidate).strip()
if cleaned and cleaned == candidate.strip():
return cleaned
if cleaned and not cleaned_fallback:
cleaned_fallback = cleaned
if cleaned_fallback:
return cleaned_fallback
return next((c.strip() for c in candidates if c), "")
def get_titles(self) -> Titles_T:
res = self.session.get(
url=self.endpoints["details"],
@@ -413,12 +450,18 @@ class AMZN(Service):
continue
product_details_season = res["productDetails"]["detail"]
show_name = self._clean_show_name(
product_details_season.get("parentTitle"),
product_details.get("parentTitle"),
product_details_season.get("title"),
product_details.get("title"),
)
for episode in episode_data_list:
details = episode["detail"]
episodes_list.append(Episode(
id_=details["catalogId"],
title=product_details["title"],
title=show_name,
name=details["title"],
season=product_details_season["seasonNumber"],
number=episode["self"]["sequenceNumber"],
@@ -445,7 +488,7 @@ class AMZN(Service):
ep_num = int(item.get("self", {}).get("sequenceNumber", 0))
episodes_list.append(Episode(
id_=item["detail"]["catalogId"],
title=product_details["title"],
title=show_name,
name=item["detail"]["title"],
season=product_details_season["seasonNumber"],
number=ep_num,
@@ -508,52 +551,95 @@ class AMZN(Service):
self.log.debug(f"SPA season discovery failed: {e}")
return []
def _codec_fallback_chain(self, codec: str) -> list:
order = ["AV1", "H265", "H264"]
if codec not in order:
return [codec]
return order[order.index(codec):]
def _sync_vcodec_filter(self, codec: str) -> None:
enum_map = {"AV1": Video.Codec.AV1, "H265": Video.Codec.HEVC, "H264": Video.Codec.AVC}
target = enum_map.get(codec)
if not target:
return
params = getattr(getattr(self.ctx, "parent", None), "params", None)
if not isinstance(params, dict):
return
vlist = params.get("vcodec")
if isinstance(vlist, list) and vlist and target not in vlist:
vlist.append(target)
def _bitrate_for_codec(self, codec: str) -> str:
if self.bitrate_source == ParameterSource.COMMANDLINE:
return self.orig_bitrate
if self.manifest_type == "ISM":
return "CBR"
if self.vquality == "UHD" and self.range != "SDR":
return "CBR"
if codec == "AV1":
return "CVBR"
if codec == "H265" and self.range == "SDR":
return "CVBR+CBR"
return self.orig_bitrate
def get_tracks(self, title: Title_T) -> Tracks:
if self.chapters_only:
return Tracks([])
self._check_entitlement(title)
self._warn_cdm_quality_mismatch()
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
def _fetch_for_codec(codec: str) -> dict:
bmode = self._bitrate_for_codec(codec)
m = self.get_manifest(
title, video_codec=codec, bitrate_mode=bmode, quality=self.vquality,
hdr=effective_range, ignore_errors=True, 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 (self.range == "DV" or is_hybrid) and not m.get("vodPlaybackUrls"):
m = self.get_manifest(
title, video_codec=codec, bitrate_mode=bmode, quality=self.vquality,
hdr="HDR10", ignore_errors=True, 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 not self._usable_manifest(m) and self.device_token and not self.playlisted:
fb = self.get_manifest(
title, video_codec=codec, bitrate_mode=bmode, 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 self._usable_manifest(fb):
m = fb
return m
requested_codec = self.requested_vcodec
self.vcodec = requested_codec
self.bitrate = self._bitrate_for_codec(requested_codec)
codec_chain = self._codec_fallback_chain(requested_codec)
manifest = {}
effective_vcodec = requested_codec
for codec in codec_chain:
if len(codec_chain) > 1:
self.log.info(f" + Requesting {codec} video manifest...")
manifest = _fetch_for_codec(codec)
if self._usable_manifest(manifest):
if codec != requested_codec:
self.log.warning(f" - {requested_codec} unavailable for this title; using {codec}.")
effective_vcodec = codec
self.vcodec = codec
self.bitrate = self._bitrate_for_codec(codec)
self._sync_vcodec_filter(codec)
break
if len(codec_chain) > 1:
self.log.warning(f" - {codec} not available for this title.")
if not self._usable_manifest(manifest):
self.log.error(
f" - No usable manifest for this title with any codec ({', '.join(codec_chain)})."
)
raise SystemExit(1)
if "rightsException" in manifest.get("returnedTitleRendition", {}).get("selectedEntitlement", {}):
self.log.error(" - The profile used does not have the rights to this title.")
@@ -788,7 +874,7 @@ class AMZN(Service):
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")
self.log.warning(" - HDR10 manifest unavailable for hybrid and DV-only tracks will be used")
for sub in manifest.get("timedTextUrls", {}).get("result", {}).get("subtitleUrls", []) + \
manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []):
@@ -928,6 +1014,163 @@ class AMZN(Service):
chapters.append(Chapter(name=chapter_title, timestamp=timecode))
return chapters
def _check_entitlement(self, title) -> None:
try:
base = self.region.get("base_manifest")
if not base:
return
params = {
"itemId": title.id,
"presentationScheme": "android-tv-react",
"deviceTypeID": self.device.get("device_type"),
"deviceID": self.device_id,
}
headers = {"Accept": "application/json"}
_bearer = self.actor_token or self.device_token
if _bearer:
params["roles"] = "playback-envelope-supported"
headers["Authorization"] = f"Bearer {_bearer}"
else:
params["firmware"] = ""
params["roles"] = "prime-offer-supported,svod-supported"
params["clientFeatures"] = "EnableBuyBoxV2"
res = self.session.get(
url=f"https://{base}/lrcedge/getDataByJavaTransform/v1/lr/detailsPage/detailsPageATF",
params=params,
headers=headers,
timeout=15,
)
if res.status_code != 200:
self.log.debug(f"Entitlement pre-check unavailable ({res.status_code})")
return
resource = (res.json() or {}).get("resource", {}) or {}
message = (
resource.get("entitlementMessaging", {})
.get("ENTITLEMENT_MESSAGE_SLOT_DETAIL", {})
.get("message")
)
if message:
if any(k in message.lower() for k in ("join", "trial", "subscribe", "rent", "buy", "add-on", "add on")):
self.log.warning(f" - Entitlement: {message} (this title may need a purchase/add-on)")
else:
self.log.info(f" + Entitlement: {message}")
apply_hdr = resource.get("applyHdr")
apply_uhd = resource.get("applyUhd")
bits = []
if apply_uhd is not None:
bits.append(f"UHD {'available' if apply_uhd else 'not available'}")
if apply_hdr is not None:
bits.append(f"HDR {'available' if apply_hdr else 'not available'}")
if bits:
self.log.info(f" + Amazon advertises: {', '.join(bits)} for this title")
except Exception as e:
self.log.debug(f"Entitlement pre-check failed (non-fatal): {e}")
def _bearer(self) -> Optional[str]:
if self.living_room and self.actor_token:
return self.actor_token
return self.device_token
def _get_primary_profile(self) -> Optional[str]:
try:
base = self.region.get("base_manifest")
if not base or not self.device_token:
return None
res = self.session.get(
url=f"https://{base}/lrcedge/getDataByJavaTransform/v1/lr/profiles/profileSelection",
params={"deviceTypeID": self.device.get("device_type"), "deviceID": self.device_id},
headers={"Authorization": f"Bearer {self.device_token}", "Accept": "application/json"},
timeout=15,
).json()
profiles = (res.get("resource") or {}).get("profiles") or []
default = next((p for p in profiles if p.get("isDefaultProfile")), None) or (profiles[0] if profiles else None)
return default.get("profileId") if default else None
except Exception as e:
self.log.debug(f"Profile lookup failed: {e}")
return None
def _ensure_actor_token(self) -> Optional[str]:
if self.actor_token:
return self.actor_token
if not self.device_token or not self.device_refresh_token:
return None
_profile = self.profile or "default"
cache = Cacher("AMZN").get(f"actor_token_{_profile}")
if cache and cache.data and cache.data.get("token") and cache.data.get("expires_in", 0) > int(time.time()):
self.actor_token = cache.data["token"]
self.profile_id = cache.data.get("profile_id")
self.log.debug(" + Using cached actor token")
return self.actor_token
try:
profile_id = self._get_primary_profile()
if not profile_id:
self.log.debug(" - No primary profile found for actor token")
return None
res = self.session.post(
url=self.endpoints["token"],
headers={"Content-Type": "application/json"},
json={
"actor_id": profile_id,
"app_name": "AIV",
"requested_token_type": "actor_access_token",
"source_token_type": "refresh_token",
"source_device_tokens": [{
"device_type": self.device.get("device_type"),
"account_refresh_token": {"token": self.device_refresh_token},
}],
},
).json()
device_tokens = res.get("device_tokens") or []
token = (device_tokens[0].get("actor_access_token") or {}).get("token") if device_tokens else None
if not token:
self.log.debug(f" - Actor token exchange returned no token (Keys: {list(res.keys())})")
return None
self.actor_token = token
self.profile_id = profile_id
cache.set(
{"token": token, "profile_id": profile_id, "expires_in": int(time.time()) + 3000},
int(time.time()) + 3000,
)
self.log.info(" + Acquired LivingRoomPlayer actor token")
return token
except Exception as e:
self.log.debug(f"Actor token acquisition failed: {e}")
return None
def _livingroom_envelope(self, title) -> Optional[str]:
token = self._ensure_actor_token()
if not token:
return None
try:
base = self.region.get("base_manifest")
res = self.session.get(
url=f"https://{base}/lrcedge/getDataByJavaTransform/v1/lr/detailsPage/detailsPageATF",
params={
"itemId": title.id,
"presentationScheme": "android-tv-react",
"deviceTypeID": self.device.get("device_type"),
"deviceID": self.device_id,
"roles": "playback-envelope-supported",
},
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=20,
).json()
resource = (res or {}).get("resource") or {}
for action in resource.get("actions", []) or []:
pem = (action.get("metadata") or {}).get("playbackExperienceMetadata") or {}
if pem.get("playbackEnvelope"):
self.playbackInfo = {"titleID": title.id, "playbackExperienceMetadata": pem}
return pem["playbackEnvelope"]
self.log.debug(" - LivingRoom detailsPage returned no playback envelope")
except Exception as e:
self.log.debug(f"LivingRoom envelope fetch failed: {e}")
return None
def playbackEnvelope_data(self, title):
try:
res = self.session.get(
@@ -1249,13 +1492,19 @@ class AMZN(Service):
def get_manifest(self, title, video_codec, bitrate_mode, quality, hdr, ignore_errors=False,
protocol="DASH", use_playlisted=False, retries=3):
use_playlisted = use_playlisted or self.living_room
if use_playlisted and not self.device_token:
self.log.warning(" - Playlisted (LivingRoomPlayer) path needs a registered device; using classic path")
self.log.warning(" - Playlisted (LivingRoomPlayer) path needs a registered device. Trying classic run.")
use_playlisted = False
for attempt in range(retries):
if attempt == 0 or not self.playbackInfo:
self.playbackEnvelope = self.playbackEnvelope_data(title)
lr_env = self._livingroom_envelope(title) if self.living_room else None
if self.living_room and not lr_env:
self.log.warning(" - LivingRoom envelope unavailable. Trying classic run.")
self.living_room = False
use_playlisted = self.playlisted
self.playbackEnvelope = lr_env or self.playbackEnvelope_data(title)
else:
self.log.debug(f" + Manifest retry {attempt + 1}/{retries}; refreshing playback envelope")
try:
@@ -1286,7 +1535,7 @@ class AMZN(Service):
},
data=json.dumps(data_dict),
headers={
"Authorization": f"Bearer {self.device_token}" if self.device_token else None,
"Authorization": f"Bearer {self._bearer()}" if self._bearer() else None,
},
)
@@ -1336,64 +1585,56 @@ class AMZN(Service):
return self._get_license(challenge, title, track, widevine=False)
def _get_license(self, challenge: bytes, title: Title_T, track, widevine: bool):
if not self.device_token:
try:
self.register_device()
except Exception:
pass
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
is_ism = isinstance(getattr(track, "data", None), dict) and "ism" in track.data
endpoint = self.endpoints["license_wv"] if widevine else self.endpoints["license_pr"]
if self.device_token:
data_lic = {
"licenseChallenge": encoded_challenge,
"playbackEnvelope": envelope,
"licenseChallenge": encoded_challenge,
"deviceCapabilityFamily": "LivingRoomPlayer",
"packagingFormat": "SMOOTH_STREAMING" if is_ism else "MPEG_DASH",
}
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
params = {
"deviceID": self.device_id,
"deviceTypeID": self.device.get("device_type", self.config["device_types"]["browser"]),
"firmware": "1",
"marketplaceID": self.region["marketplace_id"],
"titleId": title.id,
"uxLocale": "en_US",
}
else:
if not handoff:
self.log.error(" - No sessionHandoffToken available."); raise SystemExit(1)
data_lic.update({
self.log.error(" - No device token and no sessionHandoffToken; cannot license."); raise SystemExit(1)
data_lic = {
"playbackEnvelope": envelope,
"licenseChallenge": encoded_challenge,
"deviceCapabilityFamily": "WebPlayer",
"sessionHandoffToken": handoff,
})
endpoint = self.endpoints["license_wv"] if widevine else self.endpoints["license_pr"]
res = self.session.post(
url=endpoint,
params={
}
if widevine:
data_lic["includeHdcpTestKey"] = True
params = {
"deviceID": self.device_id,
"deviceTypeID": self.device["device_type"],
"gascEnabled": str(self.pv).lower(),
@@ -1402,40 +1643,103 @@ class AMZN(Service):
"firmware": 1,
"titleId": title.id,
"nerid": self.generate_nerid(),
},
}
try:
resp = self.session.post(
url=endpoint,
params=params,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {self.device_token}" if self.device_token else None,
"Authorization": f"Bearer {self._bearer()}" if self._bearer() else None,
},
json=data_lic
).json()
json=data_lic,
)
resp.raise_for_status()
res = resp.json()
except requests.exceptions.HTTPError as e:
msg = "Failed to license"
status = e.response.status_code if e.response is not None else None
if e.response is not None:
try:
msg += f": {e.response.json()}"
except Exception:
msg += f": {e.response.text[:200]}"
self.log.error(f" - {msg}")
if status in (403, 500):
self.log.error(f" - {self._license_denial_hint(track)}")
raise SystemExit(1)
except Exception as e:
self.log.error(f" - Failed to license: {e}"); raise SystemExit(1)
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)
self.log.error(f" - Amazon reported an error during the License request: [{code}]")
self.log.error(f" - {self._license_denial_hint(track)}")
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"]
self.log.error(f" - License Error: {res['error'].get('message', 'Unknown')}"); raise SystemExit(1)
primary_key = "widevineLicense" if widevine else "playReadyLicense"
lic = None
if isinstance(res.get(primary_key), dict):
lic = res[primary_key].get("license")
if not lic:
for key, val in res.items():
if "license" in key.lower() and isinstance(val, dict) and val.get("license"):
return val["license"]
lic = val["license"]; break
if "license" in key.lower() and isinstance(val, str) and val:
return val
lic = val; break
if not lic:
self.log.error(
f" - License response did not contain a '{primary_key}'. "
f"Response keys: {list(res.keys())}. Raw (truncated): {json.dumps(res)[:600]}"
)
self.log.error(f" - {self._license_denial_hint(track)}")
raise SystemExit(1)
return base64.b64decode(lic) if isinstance(lic, str) else lic
def _requested_uhd(self) -> bool:
q = self.quality[0] if isinstance(self.quality, list) and self.quality else self.quality
return self.vquality == "UHD" or (isinstance(q, int) and q > 1080)
def _license_denial_hint(self, track=None) -> str:
level = None
try:
level = getattr(self.cdm, "security_level", None)
except Exception:
pass
cdm_bit = ""
if level == 3:
cdm_bit = " Your CDM is Widevine L3 (Amazon licenses only SD to L3)."
elif level in (2000, 3000):
cdm_bit = f" Your CDM is PlayReady SL{level}."
want = []
if self._requested_uhd() or (isinstance(getattr(track, 'height', None), int) and track.height > 1080):
want.append("UHD")
if self.range and self.range != "SDR":
want.append(self.range)
want_bit = f" (you requested {'/'.join(want)})" if want else ""
return (
f"License denied/failed — this is typically a CDM robustness limit{want_bit}.{cdm_bit} "
"UHD/HDR/DV needs Widevine L1 or PlayReady SL3000. Re-run at a lower quality "
"(e.g. -q 1080) or SDR to get a licensable stream."
)
def _warn_cdm_quality_mismatch(self) -> None:
try:
level = getattr(self.cdm, "security_level", None)
if level == 3 and (self._requested_uhd() or (self.range and self.range != "SDR")):
self.log.warning(
" - Widevine L3 CDM with a UHD/HDR request. Amazon only licenses SD to L3, so the "
"license will most likely be denied. For UHD/HDR use Widevine L1 / PlayReady SL3000, or -q 1080 / SDR."
)
except Exception:
pass
def _tag_amzn_tracks(self, track_iterable, manifest: dict) -> None:
token = (manifest.get("sessionization") or {}).get("sessionHandoffToken")
@@ -1620,7 +1924,9 @@ class AMZN(Service):
if cdn:
cdn = cdn.lower()
return next((x for x in url_sets if (x.get("cdn") or "").lower() == cdn), {})
akamai = [x for x in url_sets if "akamai" in (x.get("cdn") or "").lower()]
if akamai:
return secrets.choice(akamai)
return secrets.choice(url_sets)
@staticmethod
@@ -1738,13 +2044,15 @@ class AMZN(Service):
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(
_reg = self.DeviceRegistration(
device=self.device,
endpoints=self.endpoints,
log=self.log,
cache_path=device_cache_path,
session=self.session
).bearer
)
self.device_token = _reg.bearer
self.device_refresh_token = _reg.refresh_token
self.device_id = self.device.get("device_serial")
if not self.device_id:
@@ -1761,6 +2069,7 @@ class AMZN(Service):
self.device = {k: str(v) if not isinstance(v, str) else v for k, v in self.device.items()}
self.bearer = None
self.refresh_token = None
cached_data = self.cache.get(self.cache_path)
@@ -1768,6 +2077,7 @@ class AMZN(Service):
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"]
self.refresh_token = cached_data.data.get("refresh_token")
else:
self.log.info("Cached device bearer expired, refreshing...")
refresh_token = cached_data.data.get("refresh_token")
@@ -1778,6 +2088,7 @@ class AMZN(Service):
refreshed_tokens["expires_in"] = int(time.time()) + expires_seconds
cached_data.set(refreshed_tokens, refreshed_tokens["expires_in"])
self.bearer = refreshed_tokens["access_token"]
self.refresh_token = refreshed_tokens.get("refresh_token")
else:
self.log.info(" + Re-registering device bearer")
self.bearer = self.register(self.device)
@@ -1789,8 +2100,8 @@ class AMZN(Service):
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)")
self.log.info(f" + Visit https://www.primevideo.com/mytv and enter code: {public_code}")
self.log.info(" Waiting for authorisation (May take up to 5 minutes)")
interval = 10
deadline = int(time.time()) + 300
@@ -1833,6 +2144,7 @@ class AMZN(Service):
keyed_cache = self.cache.get(self.cache_path)
keyed_cache.set(bearer_data, int(time.time()) + int(expires_val))
self.refresh_token = bearer_data["refresh_token"]
self.log.info(" + Device registered and token cached successfully")
return bearer_data["access_token"]