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.manifests import DASH, ISM
from unshackle.core.service import Service from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T 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 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). Service code for Amazon VOD (https://amazon.com) & Amazon Prime Video (https://primevideo.com).
Author: n0stal6ic www.nostalgic.cc
Authorization: Cookies Authorization: Credentials, Cookies
Security: UHD@L1 FHD@Chrome SD@L3 Security: UHD@L1 FHD@Chrome SD@L3
""" """
@@ -176,6 +176,7 @@ class AMZN(Service):
self.playlisted = playlisted self.playlisted = playlisted
assert ctx.parent is not None assert ctx.parent is not None
self.ctx = ctx
self.chapters_only = ctx.parent.params.get("chapters_only") self.chapters_only = ctx.parent.params.get("chapters_only")
self.quality = ctx.parent.params.get("quality") or 1080 self.quality = ctx.parent.params.get("quality") or 1080
@@ -184,12 +185,15 @@ class AMZN(Service):
range_ = ctx.parent.params.get("range_") range_ = ctx.parent.params.get("range_")
self.range = range_[0].name if range_ else "SDR" 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" self.vcodec = "AV1"
elif vcodec and "HEVC" in str(vcodec).upper(): elif "HEVC" in vstr:
self.vcodec = "H265" self.vcodec = "H265"
else: elif "AVC" in vstr:
self.vcodec = "H264" self.vcodec = "H264"
else:
self.vcodec = "AV1"
self.cdm = ctx.obj.cdm self.cdm = ctx.obj.cdm
self.profile = ctx.obj.profile self.profile = ctx.obj.profile
@@ -202,12 +206,16 @@ class AMZN(Service):
self.pv = False self.pv = False
self.event = False self.event = False
self.device_token = None self.device_token = None
self.device_refresh_token = None
self.device_id = None self.device_id = None
self.customer_id = None self.customer_id = None
self.client_id = "f22dbddb-ef2c-48c5-8876-bed0d47594fd" self.client_id = "f22dbddb-ef2c-48c5-8876-bed0d47594fd"
self.playbackEnvelope = None self.playbackEnvelope = None
self.playbackInfo = None self.playbackInfo = None
self.session_handoff_token = None self.session_handoff_token = None
self.actor_token = None
self.profile_id = None
self.living_room = False
if self.vquality_source != ParameterSource.COMMANDLINE: if self.vquality_source != ParameterSource.COMMANDLINE:
q_check = self.quality[0] if isinstance(self.quality, list) else self.quality 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.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.orig_bitrate = self.bitrate
self.requested_vcodec = self.vcodec
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential) super().authenticate(cookies, credential)
@@ -290,7 +299,7 @@ class AMZN(Service):
if self.manifest_type == "ISM" and not self.device: if self.manifest_type == "ISM" and not self.device:
self.log.error( 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." "Add one or use --manifest DASH."
) )
raise SystemExit(1) raise SystemExit(1)
@@ -327,6 +336,15 @@ class AMZN(Service):
params={"deviceTypeID": self.device["device_type"], "deviceID": "Web"} params={"deviceTypeID": self.device["device_type"], "deviceID": "Web"}
) )
self._apply_verified_territory(res_cfg) 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: def _apply_verified_territory(self, res_cfg) -> None:
if res_cfg.status_code != 200: if res_cfg.status_code != 200:
@@ -342,8 +360,8 @@ class AMZN(Service):
if territory and account_region and territory.lower() != account_region.lower(): if territory and account_region and territory.lower() != account_region.lower():
self.log.warning( self.log.warning(
f" - IP region '{territory}' does not match account region '{account_region}'. " 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"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 VPN)." f"If the license step fails, use a {account_region} IP (e.g. --proxy {account_region} or a matching VPN)."
) )
return return
if territory: if territory:
@@ -353,6 +371,25 @@ class AMZN(Service):
elif marketplace and self.no_true_region: elif marketplace and self.no_true_region:
self.log.debug(" + --no-true-region set; keeping marketplace from config, ignoring IP-geo value") 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: def get_titles(self) -> Titles_T:
res = self.session.get( res = self.session.get(
url=self.endpoints["details"], url=self.endpoints["details"],
@@ -413,12 +450,18 @@ class AMZN(Service):
continue continue
product_details_season = res["productDetails"]["detail"] 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: for episode in episode_data_list:
details = episode["detail"] details = episode["detail"]
episodes_list.append(Episode( episodes_list.append(Episode(
id_=details["catalogId"], id_=details["catalogId"],
title=product_details["title"], title=show_name,
name=details["title"], name=details["title"],
season=product_details_season["seasonNumber"], season=product_details_season["seasonNumber"],
number=episode["self"]["sequenceNumber"], number=episode["self"]["sequenceNumber"],
@@ -445,7 +488,7 @@ class AMZN(Service):
ep_num = int(item.get("self", {}).get("sequenceNumber", 0)) ep_num = int(item.get("self", {}).get("sequenceNumber", 0))
episodes_list.append(Episode( episodes_list.append(Episode(
id_=item["detail"]["catalogId"], id_=item["detail"]["catalogId"],
title=product_details["title"], title=show_name,
name=item["detail"]["title"], name=item["detail"]["title"],
season=product_details_season["seasonNumber"], season=product_details_season["seasonNumber"],
number=ep_num, number=ep_num,
@@ -508,52 +551,95 @@ class AMZN(Service):
self.log.debug(f"SPA season discovery failed: {e}") self.log.debug(f"SPA season discovery failed: {e}")
return [] 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: def get_tracks(self, title: Title_T) -> Tracks:
if self.chapters_only: if self.chapters_only:
return Tracks([]) return Tracks([])
self._check_entitlement(title)
self._warn_cdm_quality_mismatch()
is_hybrid = self.range == "HYBRID" is_hybrid = self.range == "HYBRID"
effective_vcodec = self.vcodec
effective_range = "DV" if is_hybrid else self.range effective_range = "DV" if is_hybrid else self.range
video_protocol = "SmoothStreaming" if self.manifest_type == "ISM" else "DASH" video_protocol = "SmoothStreaming" if self.manifest_type == "ISM" else "DASH"
manifest = self.get_manifest(
title, def _fetch_for_codec(codec: str) -> dict:
video_codec=effective_vcodec, bmode = self._bitrate_for_codec(codec)
bitrate_mode=self.bitrate, m = self.get_manifest(
quality=self.vquality, title, video_codec=codec, bitrate_mode=bmode, quality=self.vquality,
hdr=effective_range, hdr=effective_range, ignore_errors=True, protocol=video_protocol,
ignore_errors=(self.range == "DV" or is_hybrid), use_playlisted=self.playlisted,
protocol=video_protocol,
use_playlisted=self.playlisted
) )
if (self.range == "DV" or is_hybrid) and not manifest.get("vodPlaybackUrls"): if (self.range == "DV" or is_hybrid) and not m.get("vodPlaybackUrls"):
self.log.warning(" - Dolby Vision request rejected by server, retrying with HDR10...") m = self.get_manifest(
effective_range = "HDR10" title, video_codec=codec, bitrate_mode=bmode, quality=self.vquality,
manifest = self.get_manifest( hdr="HDR10", ignore_errors=True, protocol=video_protocol,
title, use_playlisted=self.playlisted,
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: if not self._usable_manifest(m) and self.device_token and not self.playlisted:
self.log.info(" + Classic manifest empty; trying newer playlisted (LivingRoomPlayer) path...") fb = self.get_manifest(
fallback = self.get_manifest( title, video_codec=codec, bitrate_mode=bmode, quality=self.vquality,
title, hdr=effective_range, ignore_errors=True, protocol=video_protocol,
video_codec=effective_vcodec, use_playlisted=True,
bitrate_mode=self.bitrate,
quality=self.vquality,
hdr=effective_range,
ignore_errors=True,
protocol=video_protocol,
use_playlisted=True
) )
if self._usable_manifest(fallback): if self._usable_manifest(fb):
manifest = fallback m = fb
self.log.info(" + Using playlisted (LivingRoomPlayer) manifest") 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", {}): if "rightsException" in manifest.get("returnedTitleRendition", {}).get("selectedEntitlement", {}):
self.log.error(" - The profile used does not have the rights to this title.") self.log.error(" - The profile used does not have the rights to this title.")
@@ -788,7 +874,7 @@ class AMZN(Service):
else: else:
self.log.warning(" - No HDR10 manifest CDN available for hybrid mode") self.log.warning(" - No HDR10 manifest CDN available for hybrid mode")
else: 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", []) + \ for sub in manifest.get("timedTextUrls", {}).get("result", {}).get("subtitleUrls", []) + \
manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []): manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []):
@@ -928,6 +1014,163 @@ class AMZN(Service):
chapters.append(Chapter(name=chapter_title, timestamp=timecode)) chapters.append(Chapter(name=chapter_title, timestamp=timecode))
return chapters 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): def playbackEnvelope_data(self, title):
try: try:
res = self.session.get( 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, def get_manifest(self, title, video_codec, bitrate_mode, quality, hdr, ignore_errors=False,
protocol="DASH", use_playlisted=False, retries=3): protocol="DASH", use_playlisted=False, retries=3):
use_playlisted = use_playlisted or self.living_room
if use_playlisted and not self.device_token: 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 use_playlisted = False
for attempt in range(retries): for attempt in range(retries):
if attempt == 0 or not self.playbackInfo: 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: else:
self.log.debug(f" + Manifest retry {attempt + 1}/{retries}; refreshing playback envelope") self.log.debug(f" + Manifest retry {attempt + 1}/{retries}; refreshing playback envelope")
try: try:
@@ -1286,7 +1535,7 @@ class AMZN(Service):
}, },
data=json.dumps(data_dict), data=json.dumps(data_dict),
headers={ 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) return self._get_license(challenge, title, track, widevine=False)
def _get_license(self, challenge: bytes, title: Title_T, track, widevine: bool): 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") challenge_bytes = challenge if isinstance(challenge, bytes) else challenge.encode("utf-8")
encoded_challenge = base64.b64encode(challenge_bytes).decode("utf-8") encoded_challenge = base64.b64encode(challenge_bytes).decode("utf-8")
amzn = track.data.get("amzn", {}) if isinstance(getattr(track, "data", None), dict) else {} amzn = track.data.get("amzn", {}) if isinstance(getattr(track, "data", None), dict) else {}
envelope = amzn.get("envelope") or self.playbackEnvelope envelope = amzn.get("envelope") or self.playbackEnvelope
handoff = amzn.get("handoff") or self.session_handoff_token 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 = { data_lic = {
"licenseChallenge": encoded_challenge,
"playbackEnvelope": envelope, "playbackEnvelope": envelope,
"licenseChallenge": encoded_challenge,
"deviceCapabilityFamily": "LivingRoomPlayer",
"packagingFormat": "SMOOTH_STREAMING" if is_ism else "MPEG_DASH",
} }
if widevine: if widevine:
data_lic["includeHdcpTestKey"] = True 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): if getattr(track, "kid", None):
try: try:
data_lic["keyId"] = str(uuid.UUID(str(track.kid))).upper() data_lic["keyId"] = str(uuid.UUID(str(track.kid))).upper()
except (ValueError, AttributeError): except (ValueError, AttributeError):
pass 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: else:
if not handoff: if not handoff:
self.log.error(" - No sessionHandoffToken available."); raise SystemExit(1) self.log.error(" - No device token and no sessionHandoffToken; cannot license."); raise SystemExit(1)
data_lic.update({ data_lic = {
"playbackEnvelope": envelope,
"licenseChallenge": encoded_challenge,
"deviceCapabilityFamily": "WebPlayer", "deviceCapabilityFamily": "WebPlayer",
"sessionHandoffToken": handoff, "sessionHandoffToken": handoff,
}) }
if widevine:
endpoint = self.endpoints["license_wv"] if widevine else self.endpoints["license_pr"] data_lic["includeHdcpTestKey"] = True
params = {
res = self.session.post(
url=endpoint,
params={
"deviceID": self.device_id, "deviceID": self.device_id,
"deviceTypeID": self.device["device_type"], "deviceTypeID": self.device["device_type"],
"gascEnabled": str(self.pv).lower(), "gascEnabled": str(self.pv).lower(),
@@ -1402,40 +1643,103 @@ class AMZN(Service):
"firmware": 1, "firmware": 1,
"titleId": title.id, "titleId": title.id,
"nerid": self.generate_nerid(), "nerid": self.generate_nerid(),
}, }
try:
resp = self.session.post(
url=endpoint,
params=params,
headers={ headers={
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "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=data_lic,
).json() )
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: if "errorsByResource" in res:
error = res["errorsByResource"] error = res["errorsByResource"]
code = error.get("errorCode") or error.get("type") or "Unknown" code = error.get("errorCode") or error.get("type") or "Unknown"
if code == "PRS.NoRights.AnonymizerIP": 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(" - 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: if "error" in res:
self.log.error(f" - License Error: {res['error']['message']}"); raise SystemExit(1) self.log.error(f" - License Error: {res['error'].get('message', 'Unknown')}"); 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"]
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(): for key, val in res.items():
if "license" in key.lower() and isinstance(val, dict) and val.get("license"): 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: if "license" in key.lower() and isinstance(val, str) and val:
return val lic = val; break
if not lic:
self.log.error( self.log.error(
f" - License response did not contain a '{primary_key}'. " f" - License response did not contain a '{primary_key}'. "
f"Response keys: {list(res.keys())}. Raw (truncated): {json.dumps(res)[:600]}" f"Response keys: {list(res.keys())}. Raw (truncated): {json.dumps(res)[:600]}"
) )
self.log.error(f" - {self._license_denial_hint(track)}")
raise SystemExit(1) 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: def _tag_amzn_tracks(self, track_iterable, manifest: dict) -> None:
token = (manifest.get("sessionization") or {}).get("sessionHandoffToken") token = (manifest.get("sessionization") or {}).get("sessionHandoffToken")
@@ -1620,7 +1924,9 @@ class AMZN(Service):
if cdn: if cdn:
cdn = cdn.lower() cdn = cdn.lower()
return next((x for x in url_sets if (x.get("cdn") or "").lower() == cdn), {}) 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) return secrets.choice(url_sets)
@staticmethod @staticmethod
@@ -1738,13 +2044,15 @@ class AMZN(Service):
device_hash = hashlib.md5(json.dumps(self.device, sort_keys=True).encode()).hexdigest()[0:6] device_hash = hashlib.md5(json.dumps(self.device, sort_keys=True).encode()).hexdigest()[0:6]
device_cache_path = f"device_tokens_{_profile}_{device_hash}" device_cache_path = f"device_tokens_{_profile}_{device_hash}"
self.device_token = self.DeviceRegistration( _reg = self.DeviceRegistration(
device=self.device, device=self.device,
endpoints=self.endpoints, endpoints=self.endpoints,
log=self.log, log=self.log,
cache_path=device_cache_path, cache_path=device_cache_path,
session=self.session session=self.session
).bearer )
self.device_token = _reg.bearer
self.device_refresh_token = _reg.refresh_token
self.device_id = self.device.get("device_serial") self.device_id = self.device.get("device_serial")
if not self.device_id: 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.device = {k: str(v) if not isinstance(v, str) else v for k, v in self.device.items()}
self.bearer = None self.bearer = None
self.refresh_token = None
cached_data = self.cache.get(self.cache_path) 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()): if cached_data.data.get("expires_in", 0) > int(time.time()):
self.log.info(" + Using cached device bearer") self.log.info(" + Using cached device bearer")
self.bearer = cached_data.data["access_token"] self.bearer = cached_data.data["access_token"]
self.refresh_token = cached_data.data.get("refresh_token")
else: else:
self.log.info("Cached device bearer expired, refreshing...") self.log.info("Cached device bearer expired, refreshing...")
refresh_token = cached_data.data.get("refresh_token") refresh_token = cached_data.data.get("refresh_token")
@@ -1778,6 +2088,7 @@ class AMZN(Service):
refreshed_tokens["expires_in"] = int(time.time()) + expires_seconds refreshed_tokens["expires_in"] = int(time.time()) + expires_seconds
cached_data.set(refreshed_tokens, refreshed_tokens["expires_in"]) cached_data.set(refreshed_tokens, refreshed_tokens["expires_in"])
self.bearer = refreshed_tokens["access_token"] self.bearer = refreshed_tokens["access_token"]
self.refresh_token = refreshed_tokens.get("refresh_token")
else: else:
self.log.info(" + Re-registering device bearer") self.log.info(" + Re-registering device bearer")
self.bearer = self.register(self.device) self.bearer = self.register(self.device)
@@ -1789,8 +2100,8 @@ class AMZN(Service):
code_pair = self.get_code_pair(device) code_pair = self.get_code_pair(device)
public_code = code_pair["public_code"] 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(f" + Visit https://www.primevideo.com/mytv and enter code: {public_code}")
self.log.info(" Waiting for authorisation (up to 5 minutes)") self.log.info(" Waiting for authorisation (May take up to 5 minutes)")
interval = 10 interval = 10
deadline = int(time.time()) + 300 deadline = int(time.time()) + 300
@@ -1833,6 +2144,7 @@ class AMZN(Service):
keyed_cache = self.cache.get(self.cache_path) keyed_cache = self.cache.get(self.cache_path)
keyed_cache.set(bearer_data, int(time.time()) + int(expires_val)) 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") self.log.info(" + Device registered and token cached successfully")
return bearer_data["access_token"] return bearer_data["access_token"]