upstream-dev

This commit is contained in:
VineFeeder
2026-07-30 14:03:26 +01:00
parent f6aede1325
commit b57d459216
285 changed files with 25064 additions and 8184 deletions
+6 -1
View File
@@ -4,11 +4,16 @@ Downloads
Temp/
packages/envied/src/envied/logs/
packages/envied/src/envied/envied.yaml
packages/envied/src/envied/cache/TVNZ/local_storage.jsonv
packages/envied/src/envied/cache/TVNZ/local_storage.json
packages/envied/src/envied/services
packages/envied/src/Cookies/*
images/*
tools/
batch.txt
*.json
key_store.db-shm
key_store.db-wal
key_store.db
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
@@ -0,0 +1 @@
1785411859.1868708
@@ -244,7 +244,7 @@ class ALL4(Service):
tracks.videos[0].data = data
# All4 video carries a stale encrypted sample entry beside the
# All 4 video carries a stale encrypted sample entry beside the
# decrypted one; repack so mkvmerge does not mux it as encrypted.
for video in tracks.videos:
video.needs_repack = True
@@ -0,0 +1,897 @@
from __future__ import annotations
import base64
import json
import re
import secrets
import struct
import subprocess
import time
import uuid
from http.cookiejar import CookieJar
from typing import Any, Optional
from urllib.parse import unquote, urlparse
import click
import requests
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
from pywidevine.pssh import PSSH as WidevinePSSH
from envied.core import binaries
from envied.core.cdm.detect import is_playready_cdm
from envied.core.config import config
from envied.core.credential import Credential
from envied.core.drm import PlayReady, Widevine
from envied.core.music import MusicTrackOption
from envied.core.service import Service
from envied.core.titles import Music, Song, Titles_T
from envied.core.tracks import Audio, Chapters, Tracks
from envied.core.tracks.track import Track
_INVISIBLE = re.compile(r"[--]")
class AMZM(Service):
"""
Service code for Amazon Music (https://music.amazon.com).
www.nostalgic.cc
Authorization: Credentials, Cookies
Security: FLAC@L3/SL2K
"""
ALIASES = ("AMZM", "amazonmusic", "amusic")
GROUP_AUDIO_DOWNLOADS = True
TITLE_RE = r"^(?:https?://)?(?:music\.)?amazon\.(?:com|co\.uk|de|co\.jp|com\.mx|com\.br|fr)/.*?/(?:albums|tracks)/(?P<id>[A-Z0-9]{10,})"
@staticmethod
@click.command(name="AMZM", short_help="https://music.amazon.com", help=__doc__)
@click.argument("title", type=str)
@click.option("-c", "--codec", "codec", default=None,
type=click.Choice(["FLAC", "AAC", "EC3", "AC4", "OPUS", "MP3"], case_sensitive=False),
help="Force an audio codec instead of picking the best available.")
@click.option("-r", "--region", "region", default=None,
help="Account region, one of the keys under 'regions' in config.yaml. "
"Defaults to config 'region', else us.")
@click.option("--single", is_flag=True, default=False,
help="For a /tracks/ URL, get just that track instead of its whole album.")
@click.pass_context
def cli(ctx, **kwargs):
return AMZM(ctx, **kwargs)
def __init__(self, ctx, title: str, codec: Optional[str], region: Optional[str], single: bool):
super().__init__(ctx)
self.title = title
self.forced_codec = (codec or "").lower() or None
self.single = single
if not self.config:
self.log.error(" - Config is missing or empty.")
raise SystemExit(1)
regions = self.config.get("regions") or {}
region = self._resolve_region(region, regions)
if region not in regions:
self.log.error(f" - Unknown region {region!r}. Choose from: {', '.join(sorted(regions)) or 'none'}")
raise SystemExit(1)
self.region = region
region_info = regions[region]
self.base_url = region_info["base"]
self.activation_url = region_info["activation"]
self.api_location = region_info["location"]
self.tvmesk_host = (self.config.get("tvmesk_hosts") or {})[self.api_location]
self.marketplace_id = region_info["marketplace"]
self.territory_id = region.upper()
self.device = self.config.get("device") or {}
self.device_type_id = self.device["type_id"]
self.timeout = self.config.get("request_timeout") or 30
self.registration_timeout = self.config.get("registration_timeout") or 60
self.codec_priority = self.config.get("codec_priority") or []
self.device_id: Optional[str] = None
self.customer_id: Optional[str] = None
self.access_token: Optional[str] = None
self.video_player_token: Optional[str] = None
self.session_handoff_token: Optional[str] = None
self.quality: str = ""
self._mpd_cache: dict[str, str] = {}
self.cdm = getattr(ctx.obj, "cdm", None)
self.is_playready = is_playready_cdm(self.cdm) if self.cdm else False
def _resolve_region(self, flag: Optional[str], regions: dict) -> str:
if flag:
return flag.strip().lower()
by_domain = self.config.get("region_from_domain") or {}
host = (urlparse(self.title if "//" in self.title else f"https://{self.title}").hostname or "").lower()
from_url = by_domain.get(host.removeprefix("www."))
configured = str(self.config.get("region") or "").strip().lower()
if from_url and configured and from_url != configured:
self.log.info(f" + Using region {from_url.upper()} from the URL "
f"(Config says {configured.upper()}). Pass -r to override.")
return from_url or configured or "us"
def _endpoint(self, name: str, **extra: str) -> str:
template = (self.config.get("endpoints") or {}).get(name)
if not template:
self.log.error(f" - config.yaml is missing endpoints.{name}")
raise SystemExit(1)
return template.format(base=self.base_url, location=self.api_location,
tvmesk=self.tvmesk_host, activation=self.activation_url, **extra)
def _target(self, name: str, **extra: str) -> str:
target = (self.config.get("amz_targets") or {}).get(name)
if not target:
self.log.error(f" - config.yaml is missing amz_targets.{name}")
raise SystemExit(1)
return target.format(**extra) if extra else target
def _music_agent(self, asin: str) -> str:
agent = self.device.get("music_agent") or ""
return agent.format(request_id=uuid.uuid4(), asin=asin)
@property
def tokens_path(self):
return self.cache_dir / f"tokens_{self.region}.json"
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
self.session.headers.update(self.device.get("headers") or {})
region_name = (self.config["regions"][self.region].get("name") or "").strip()
self.log.info(f" + Region: {self.region.upper()} ({region_name})")
tokens = self._load_tokens()
if not tokens:
self.log.info(" + No cached tokens, registering a new device.")
tokens = self._register_device()
if self._token_expired(tokens):
self.log.info(" + Access token expired, refreshing.")
if not self._refresh_token(tokens):
self.log.warning(" - Refresh failed, re-registering device.")
tokens = self._register_device()
self.device_id = tokens.get("device_id")
self.access_token = tokens.get("x-amz-access-token")
self.marketplace_id = tokens.get("marketplaceId") or self.marketplace_id
self.territory_id = tokens.get("musicTerritory") or self.territory_id
self.video_player_token = self._extract_video_player_token(tokens)
claims = self._player_token_claims(self.video_player_token)
self.customer_id = claims.get("customerId")
self.device_id = claims.get("deviceId") or self.device_id
self.marketplace_id = claims.get("marketplaceId") or self.marketplace_id
self.territory_id = claims.get("territoryId") or self.territory_id
if claims.get("deviceTypeId") and claims["deviceTypeId"] != self.device_type_id:
self.log.debug(f"Registered deviceTypeId is {claims['deviceTypeId']}, "
f"config says {self.device_type_id}; using the registered one")
self.device_type_id = claims["deviceTypeId"]
if not self.access_token:
self.log.error(" - No Amazon Music access token."); raise SystemExit(1)
self.session.headers.update({
"x-amz-access-token": self.access_token,
"x-amzn-device-id": self.device_id or "",
})
if self.video_player_token:
self.session.headers["x-amzn-video-player-token"] = self.video_player_token
self.log.info(f" + Authenticated with Amazon Music ({self.territory_id})")
self.log.info(f" + DRM: {'PlayReady' if self.is_playready else 'Widevine'}")
_PLAYER_TOKEN_CLAIMS = ("customerId", "marketplaceId", "territoryId",
"deviceId", "deviceTypeId")
@classmethod
def _player_token_claims(cls, token: Optional[str]) -> dict:
if not token or token.count(".") < 2:
return {}
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
try:
decoded = base64.urlsafe_b64decode(payload.encode()).decode("latin-1", errors="ignore")
except Exception:
return {}
claims = {}
for name in cls._PLAYER_TOKEN_CLAIMS:
match = re.search(rf'"{name}"\s*:\s*"([^"]+)"', decoded)
if match:
claims[name] = match.group(1)
return claims
def _load_tokens(self) -> Optional[dict]:
if not self.tokens_path.exists():
return None
try:
tokens = json.loads(self.tokens_path.read_text(encoding="utf-8"))
except Exception as e:
self.log.warning(f" - Could not read cached tokens: {e}")
return None
if not tokens.get("service_token") or not tokens.get("device_id"):
return None
if isinstance(tokens["service_token"], dict):
tokens["service_token"] = json.dumps(tokens["service_token"])
return tokens
def _save_tokens(self, tokens: dict) -> None:
try:
self.tokens_path.parent.mkdir(parents=True, exist_ok=True)
self.tokens_path.write_text(json.dumps(tokens, indent=2), encoding="utf-8")
except Exception as e:
self.log.warning(f" - Could not cache tokens: {e}")
@staticmethod
def _token_expired(tokens: dict) -> bool:
try:
expires_at = int(tokens.get("expires_at") or 0)
if not expires_at and tokens.get("service_token"):
expires_at = int(json.loads(tokens["service_token"]).get("expiresAtMillis") or 0)
except Exception:
return True
return not expires_at or expires_at <= int(time.time() * 1000) + 60_000
@staticmethod
def _extract_video_player_token(tokens: dict) -> Optional[str]:
header = tokens.get("x-amzn-video-player-token")
if not header:
return None
if isinstance(header, str):
try:
return json.loads(header).get("token")
except Exception:
return header
return None
def _tvmesk_headers(self, device_id: str, **extra: Optional[str]) -> dict:
headers = {
"x-amzn-request-id": str(uuid.uuid4()),
"x-amzn-timestamp": str(int(time.time() * 1000)),
"x-amzn-device-id": device_id,
}
headers.update({k: v for k, v in extra.items() if v})
return headers
@staticmethod
def _find_method(payload: dict, interface: str, key: str) -> Optional[str]:
for item in payload.get("methods") or []:
if item.get("interface") == interface and item.get(key):
return item[key]
return None
def _interface(self, name: str) -> str:
interface = (self.config.get("interfaces") or {}).get(name)
if not interface:
self.log.error(f" - config.yaml is missing interfaces.{name}")
raise SystemExit(1)
return interface
def _store_service_token(self, tokens: dict, service_token: Any, video_token: Optional[str]) -> dict:
if isinstance(service_token, str):
token_data = json.loads(service_token)
else:
token_data = service_token
service_token = json.dumps(service_token)
tokens["service_token"] = service_token
tokens["x-amz-access-token"] = token_data.get("accessToken")
if token_data.get("marketplaceId"):
tokens["marketplaceId"] = token_data["marketplaceId"]
if token_data.get("expiresAtMillis"):
tokens["expires_at"] = token_data["expiresAtMillis"]
if video_token:
tokens["x-amzn-video-player-token"] = video_token
return tokens
def _register_device(self) -> dict:
device_id = secrets.token_hex(8)
try:
code_res = self.session.post(
self._endpoint("show_home"),
json={"userHash": ""},
headers=self._tvmesk_headers(device_id),
timeout=self.registration_timeout,
)
code_res.raise_for_status()
code_json = code_res.json()
except requests.RequestException as e:
self.log.error(f" - Could not reach Amazon to start device pairing: {e}")
raise SystemExit(1)
template = ((code_json.get("methods") or [{}])[0]).get("template") or {}
public_code = template.get("code")
if not public_code:
self.log.error(f" - No pairing code in response: {json.dumps(code_json)[:300]}")
raise SystemExit(1)
register_code = public_code
try:
poll_url = template["onPollingIntervalElapsed"][0]["url"]
if "code=" in poll_url:
register_code = unquote(poll_url.rsplit("code=", 1)[-1])
except Exception:
pass
self.log.info("")
self.log.info(" + DEVICE REGISTRATION")
self.log.info(f" 1. Open: {self._endpoint('activation')}")
self.log.info(f" 2. Enter: {public_code}")
self.log.info(" 3. Press Enter")
try:
input()
except EOFError:
self.log.error(" - Registration needs an interactive terminal for the pairing step.")
raise SystemExit(1)
reg_json = None
for attempt in range(1, 4):
try:
reg_res = self.session.post(
self._endpoint("show_home"),
data=json.dumps({"code": register_code}, separators=(",", ":")),
headers=self._tvmesk_headers(device_id),
timeout=self.registration_timeout,
)
reg_res.raise_for_status()
reg_json = reg_res.json()
break
except requests.RequestException as e:
if attempt == 3:
self.log.error(f" - Registration failed after {attempt} attempts: {e}")
self.log.error(f" - Code {public_code} may still be valid.")
raise SystemExit(1)
self.log.warning(f" - Registration attempt {attempt} failed ({e}), retrying.")
service_token = self._find_method(reg_json, self._interface("authentication"), "authentication")
if not service_token:
self.log.error(" - Registration did not return a service token.")
raise SystemExit(1)
video_token = self._find_method(reg_json, self._interface("video_player"), "header")
tokens = self._store_service_token(
{"device_id": device_id, "musicTerritory": self.territory_id}, service_token, video_token
)
self._save_tokens(tokens)
self.log.info(" + Device registered")
return tokens
def _refresh_token(self, tokens: dict) -> bool:
device_id, service_token = tokens.get("device_id"), tokens.get("service_token")
if not device_id or not service_token:
return False
try:
res = self.session.post(
self._endpoint("transfer_playback"),
json={"showNowPlaying": "false", "newMediaRequired": "true", "userHash": ""},
headers=self._tvmesk_headers(device_id, **{"x-amzn-authentication": service_token}),
timeout=self.registration_timeout,
)
if res.status_code != 200:
self.log.warning(f" - Token refresh returned HTTP {res.status_code}")
return False
data = res.json()
new_token = self._find_method(data, self._interface("authentication"), "authentication")
if not new_token:
return False
video_token = self._find_method(data, self._interface("video_player"), "header")
self._save_tokens(self._store_service_token(tokens, new_token, video_token))
return True
except Exception as e:
self.log.warning(f" - Token refresh failed: {e}")
return False
def _muse(self, target: str, endpoint: str, payload: dict) -> Optional[dict]:
res = self.session.post(
self._endpoint("muse", operation=endpoint),
json=payload,
headers={
"x-amzn-requestid": str(uuid.uuid4()),
"X-Amz-Target": self._target("muse", operation=target),
},
timeout=self.timeout,
)
if res.status_code != 200:
self.log.debug(f"muse/{endpoint} -> HTTP {res.status_code}: {res.text[:200]}")
return None
return res.json()
def _lookup_album(self, asin: str) -> Optional[dict]:
data = self._muse("lookup", "lookup", {
"asins": [asin],
"features": ["popularity", "expandTracklist", "trackLibraryAvailability",
"collectionLibraryAvailability"],
"requestedContent": "MUSIC_SUBSCRIPTION",
"musicTerritory": self.territory_id,
"deviceId": self.device_id or "",
"deviceType": self.device_type_id,
})
if not data:
return None
return (data.get("albumList") or [None])[0]
def get_titles(self) -> Titles_T:
asin = self._extract_asin(self.title)
if not asin:
self.log.error(" - Could not find an ASIN in that URL."); raise SystemExit(1)
album = self._lookup_album(asin)
if album and album.get("tracks") and not self.single:
return self._album_titles(asin, album)
return self._single_title(asin)
def _album_titles(self, asin: str, album: dict) -> Music:
album_title = self._clean(album.get("title") or album.get("name")) or asin
album_artist = self._clean((album.get("artist") or {}).get("name")
or album.get("artistName")) or "Unknown Artist"
year = self._year(album)
if not year:
self.log.debug(f"No release year on album. Keys: {sorted(album)}")
artwork = self._cover(album.get("image"))
entries = [t for t in album.get("tracks") or [] if isinstance(t, dict) and t.get("asin")]
songs = []
for index, track in enumerate(entries, 1):
songs.append(self._build_song(track, album, album_title, album_artist, year,
artwork, index, len(entries)))
if not songs:
self.log.error(" - Album has no playable tracks."); raise SystemExit(1)
return Music(songs, kind="album", title=album_title, artist=album_artist,
year=year or None, total_tracks=len(songs), artwork_url=artwork)
def _single_title(self, asin: str) -> Music:
data = self._muse("catalog", "catalog", {
"asin": asin, "features": ["trackMetadata"], "musicTerritory": self.territory_id,
})
track = (data or {}).get("track")
if not track:
self.log.error(f" - Track {asin} not found in the {self.territory_id} catalogue.")
raise SystemExit(1)
album_title = self._clean((track.get("album") or {}).get("name")) or self._clean(track.get("title"))
artist = self._clean((track.get("artist") or {}).get("name") or track.get("artistName")
or track.get("primaryArtistName")) or "Unknown Artist"
year = self._year(track) or self._year(track.get("album") or {})
artwork = self._cover(track.get("image") or (track.get("album") or {}).get("image"))
song = self._build_song(track, track.get("album") or {}, album_title, artist, year, artwork, 1, 1)
return Music([song], kind="single", title=album_title, artist=artist,
year=year or None, total_tracks=1, artwork_url=artwork)
def _build_song(self, track: dict, album: dict, album_title: str, album_artist: str,
year: int, artwork: Optional[str], position: int, total: int) -> Song:
title = self._clean(track.get("title") or track.get("name")) or "Unknown"
artist = self._clean((track.get("artist") or {}).get("name")
or track.get("artistName")) or album_artist
genre = self._clean(track.get("primaryGenre") or album.get("primaryGenre")) or None
isrc = track.get("isrc") or None
label = self._clean(track.get("label") or album.get("label")) or None
track_number = int(track.get("trackNum") or position)
disc_number = int(track.get("discNum") or track.get("discNumber") or 1)
asin = track["asin"]
year = self._year(track) or self._year(album) or year or 1
data = {
"service": self.ALIASES[0],
"source": self.ALIASES[0],
"track_id": asin,
"track_url": f"{self.base_url}albums/{album.get('asin') or asin}",
"title": title,
"artist": artist,
"performer": artist,
"album": album_title,
"album_artist": album_artist,
"track_number": track_number,
"total_tracks": total,
"disc_number": disc_number,
"genre": genre,
"isrc": isrc,
"label": label,
"year": year,
"copyright": self._clean(album.get("copyright")) or None,
"artwork_url": artwork,
"duration": int(track.get("duration") or 0) or None,
"channels": 2,
}
if config.tag:
data["comment"] = config.tag
return Song(
id_=asin,
service=self.__class__,
name=title,
artist=artist,
album=album_title,
track=track_number,
disc=disc_number,
year=year,
album_artist=album_artist,
release_type="album" if total > 1 else "single",
total_tracks=total if total > 1 else None,
genre=genre,
isrc=isrc if isinstance(isrc, str) else None,
label=label,
artwork_url=artwork,
data=data,
)
def _get_mpd(self, asin: str) -> str:
if asin in self._mpd_cache:
return self._mpd_cache[asin]
manifest_cfg = self.config.get("manifest") or {}
res = self.session.post(
self._endpoint("dmls"),
json={
"deviceToken": {"deviceTypeId": self.device_type_id, "deviceId": self.device_id or ""},
"appInfo": {"musicAgent": self._music_agent(asin)},
**({"customerId": self.customer_id} if self.customer_id else {}),
"contentIdList": [{"identifier": asin, "identifierType": "ASIN"}],
"musicDashVersionList": manifest_cfg.get("dash_versions") or [],
"contentProtectionList": manifest_cfg.get("content_protection") or [],
"customerInfo": {"marketplaceId": self.marketplace_id, "territoryId": self.territory_id},
"try3dAsinSubstitution": True,
"tryAsinSubstitution": True,
},
headers={
"X-Amz-RequestId": str(uuid.uuid4()),
"X-Amz-Target": self._target("manifest"),
"x-amz-access-token": self.access_token,
"x-amzn-timestamp": str(int(time.time() * 1000)),
"x-amzn-requestid": str(uuid.uuid4()),
"Content-Encoding": "amz-1.0",
},
timeout=self.timeout,
)
if res.status_code != 200:
if res.status_code == 403:
self.log.error(" - Manifest denied (HTTP 403). Check your subscription.")
else:
self.log.error(f" - Manifest request failed: HTTP {res.status_code} {res.text[:200]}")
return ""
try:
data = res.json()
except Exception as e:
self.log.error(f" - Could not parse manifest response: {e}")
return ""
if data.get("sessionHandoffToken"):
self.session_handoff_token = data["sessionHandoffToken"]
mpd = ((data.get("contentResponseList") or [{}])[0]).get("manifest") or ""
self._mpd_cache[asin] = mpd
return mpd
def _representations(self, mpd: str) -> list[dict]:
reps = []
for block in re.findall(r"<AdaptationSet\b[\s\S]*?</AdaptationSet>", mpd):
kid = self._search(r'cenc:default_KID="([^"]+)"', block)
psshs = self._content_protection(block)
track_type = self._search(r'schemeIdUri="amz-music:trackType"\s+value="([^"]+)"', block) or "UNKNOWN"
for rep in re.findall(r"<Representation\b[\s\S]*?</Representation>", block):
base_url = (self._search(r"<BaseURL>([\s\S]*?)</BaseURL>", rep) or "").strip()
if not base_url:
continue
codec = self._search(r'codecs="([^"]+)"', rep) or "unknown"
bandwidth = int(self._search(r'bandwidth="(\d+)"', rep) or 0)
sample_rate = int(self._search(r'audioSamplingRate="(\d+)"', rep) or 0)
bit_depth = int(self._search(r'schemeIdUri="amz-music:bitDepth"\s+value="(\d+)"', rep) or 0)
reps.append({
"url": base_url,
"codec": codec,
"family": self._codec_family(codec),
"bandwidth": bandwidth,
"sample_rate": sample_rate,
"bit_depth": bit_depth,
"kid": (kid or "").replace("-", ""),
"pssh": psshs,
"track_type": track_type,
})
reps.sort(key=self._rep_rank, reverse=True)
return reps
def _rep_rank(self, rep: dict) -> tuple:
try:
codec_score = len(self.codec_priority) - self.codec_priority.index(rep["family"])
except ValueError:
codec_score = 0
return (codec_score, rep["bit_depth"], rep["sample_rate"], rep["bandwidth"])
@staticmethod
def _codec_family(codec: str) -> str:
codec = codec.lower()
if "flac" in codec:
return "flac"
if codec.startswith("ec-3") or "eac3" in codec:
return "ec-3"
if codec.startswith("ac-4"):
return "ac-4"
if codec.startswith("mp4a.40.34") or codec == "mp3":
return "mp3"
if codec.startswith("mp4a"):
return "mp4a"
if "opus" in codec:
return "opus"
return codec
_CODEC_ALIASES = {"aac": "mp4a", "ec3": "ec-3", "ac4": "ac-4",
"flac": "flac", "opus": "opus", "mp3": "mp3"}
def _pick_representation(self, reps: list[dict]) -> Optional[dict]:
if self.forced_codec:
wanted = self._CODEC_ALIASES.get(self.forced_codec, self.forced_codec)
matches = [r for r in reps if r["family"] == wanted]
if not matches:
available = ", ".join(sorted({r["family"] for r in reps})) or "none"
self.log.error(f" - No {self.forced_codec.upper()} stream for this track. Available: {available}")
return None
return matches[0]
return reps[0] if reps else None
def get_music_track_options(self, song: Song) -> list[MusicTrackOption]:
reps = self._representations(self._get_mpd(str(song.id)))
options = []
for rep in reps:
family = rep["family"]
options.append(MusicTrackOption(
codec={"flac": "FLAC", "mp4a": "AAC", "ec-3": "EC3",
"ac-4": "AC4", "opus": "OPUS", "mp3": "MP3"}.get(family, family.upper()),
bit_depth=rep["bit_depth"] or None,
sample_rate=rep["sample_rate"] or None,
bitrate=rep["bandwidth"] or None,
channels=5.1 if family in ("ec-3", "ac-4") else 2.0,
lossless=family == "flac",
hires=family == "flac" and (rep["bit_depth"] > 16 or rep["sample_rate"] > 48000),
duration=(song.data or {}).get("duration"),
quality_label=self._quality_label(rep),
))
return options
def get_tracks(self, title: Song) -> Tracks:
asin = str(title.id)
mpd = self._get_mpd(asin)
if not mpd:
self.log.error(f" - No manifest for track {asin}."); raise SystemExit(1)
reps = self._representations(mpd)
if not reps:
self.log.error(f" - No audio in the manifest for {asin}."); raise SystemExit(1)
rep = self._pick_representation(reps)
if not rep:
raise SystemExit(1)
self.quality = self._quality_label(rep)
self.log.debug(f" + Selected {rep['codec']} @ {rep['bandwidth']}bps "
f"({rep['bit_depth'] or '?'}-bit/{rep['sample_rate'] or '?'}Hz)")
drm = self._drm_for(rep)
family = rep["family"]
audio = Audio(
rep["url"],
language=title.language or "en",
codec={"flac": Audio.Codec.FLAC, "mp4a": Audio.Codec.AAC, "ec-3": Audio.Codec.EC3,
"ac-4": Audio.Codec.AC4, "opus": Audio.Codec.OPUS}.get(family),
bitrate=rep["bandwidth"] or None,
channels=6 if family in ("ec-3", "ac-4") else 2,
descriptor=Track.Descriptor.URL,
id_=asin,
drm=[drm] if drm else None,
data={"ext": "flac" if family == "flac" else "m4a", "rep": rep},
)
return Tracks([audio])
def get_chapters(self, title: Song) -> Chapters:
return Chapters()
def _content_protection(self, block: str) -> dict:
system_ids = ((self.config.get("drm") or {}).get("system_ids") or {})
widevine_id = (system_ids.get("widevine") or "").lower()
playready_id = (system_ids.get("playready") or "").lower()
found: dict[str, str] = {}
for cp in re.findall(r"<ContentProtection\b[\s\S]*?(?:/>|</ContentProtection>)", block):
scheme = (self._search(r'schemeIdUri="([^"]+)"', cp) or "").lower()
pssh = self._search(r"<cenc:pssh[^>]*>([\s\S]*?)</cenc:pssh>", cp)
if not pssh:
continue
if widevine_id and widevine_id in scheme:
found["widevine"] = pssh.strip()
elif (playready_id and playready_id in scheme) or "playready" in scheme:
found["playready"] = pssh.strip()
return found
def _drm_for(self, rep: dict):
psshs = rep.get("pssh") or {}
system = "playready" if self.is_playready else "widevine"
pssh_b64 = psshs.get(system)
if pssh_b64:
try:
if self.is_playready:
return PlayReady(pssh=PlayReadyPSSH(pssh_b64), pssh_b64=pssh_b64)
return Widevine(pssh=WidevinePSSH(pssh_b64))
except Exception as e:
self.log.debug(f"Manifest {system} PSSH unusable ({e}), building one from the KID")
if not rep.get("kid"):
self.log.error(" - Track is encrypted but the manifest exposed no KID or PSSH.")
return None
if self.is_playready:
pro = self._build_playready_object(rep["kid"])
return PlayReady(pssh=PlayReadyPSSH(pro), pssh_b64=base64.b64encode(pro).decode())
return Widevine(pssh=WidevinePSSH.new(
system_id=WidevinePSSH.SystemId.Widevine, key_ids=[rep["kid"]], version=1
))
@staticmethod
def _build_playready_object(kid_hex: str) -> bytes:
kid = uuid.UUID(hex=kid_hex)
kid_b64 = base64.b64encode(kid.bytes_le).decode()
xml = (
'<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" '
'version="4.0.0.0"><DATA><PROTECTINFO><KEYLEN>16</KEYLEN>'
f"<ALGID>AESCTR</ALGID></PROTECTINFO><KID>{kid_b64}</KID></DATA></WRMHEADER>"
)
record = xml.encode("utf-16-le")
body = struct.pack("<HH", 1, len(record)) + record
return struct.pack("<IH", len(body) + 6, 1) + body
DENIAL_HINTS = {
"BLOCKLISTED_DEVICE": "Amazon has revoked this CDM's device certificate.",
}
def get_playready_license(self, *, challenge: Any, title: Song, track: Any = None,
**_) -> Optional[bytes]:
return self._request_license(challenge, title, "PLAYREADY")
def get_widevine_license(self, *, challenge: Any, title: Song, track: Any = None,
**_) -> Optional[bytes]:
return self._request_license(challenge, title, "WIDEVINE")
def _request_license(self, challenge: Any, title: Song, drm_type: str) -> Optional[bytes]:
challenge_bytes = challenge if isinstance(challenge, bytes) else str(challenge).encode("utf-8")
body = {
"deviceToken": {"deviceTypeId": self.device_type_id, "deviceId": self.device_id or ""},
"appInfo": {"musicAgent": self._music_agent(str(title.id))},
"DrmType": drm_type,
"licenseChallenge": base64.b64encode(challenge_bytes).decode(),
}
if self.customer_id:
body["customerId"] = self.customer_id
if self.session_handoff_token:
body["sessionHandoffToken"] = self.session_handoff_token
res = self.session.post(
self._endpoint("dmls"),
json=body,
headers={
"x-amzn-requestid": str(uuid.uuid4()),
"X-Amz-Target": self._target("license"),
"x-amz-access-token": self.access_token,
"x-amzn-timestamp": str(int(time.time() * 1000)),
"Content-Encoding": "amz-1.0",
},
timeout=self.timeout,
)
try:
data = res.json()
except Exception:
data = {}
denial = str(data.get("denialReason") or "")
if denial or str(data.get("__type", "")).endswith("DrmLicenseDeniedException"):
hint = self.DENIAL_HINTS.get(denial, "Check the subscription level and the CDM.")
request_id = data.get("requestId") or "?"
raise ValueError(f"{drm_type} licence denied by Amazon "
f"[{denial or 'no reason given'}]. {hint} (requestId {request_id})")
if res.status_code != 200:
raise ValueError(f"{drm_type} licence request failed: "
f"HTTP {res.status_code} {res.text[:300]}")
if not data.get("license"):
raise ValueError(f"No licence in {drm_type} response: {json.dumps(data)[:300]}")
return base64.b64decode(data["license"])
def on_track_downloaded(self, track: Any) -> None:
if getattr(track, "drm", None):
return
try:
path = getattr(track, "path", None)
tdata = getattr(track, "data", None)
if not path or not path.exists() or not isinstance(tdata, dict):
return
ext = tdata.get("ext")
if not ext or path.suffix.lower() == f".{ext}":
return
new_path = path.with_suffix(f".{ext}")
if new_path.exists():
new_path.unlink()
if ext == "flac" and not self._remux(path, new_path):
self.log.warning(" - Could not remux to FLAC.")
return
if not new_path.exists():
path.rename(new_path)
elif path.exists():
path.unlink()
track.path = new_path
except Exception as e:
self.log.debug(f"Container fix-up skipped: {e}")
def _remux(self, src, dst) -> bool:
if not binaries.FFMPEG:
self.log.warning(" - ffmpeg not found, cannot remux.")
return False
proc = subprocess.run(
[str(binaries.FFMPEG), "-nostdin", "-hide_banner", "-loglevel", "error",
"-y", "-i", str(src), "-map", "0:a:0", "-c:a", "copy", str(dst)],
capture_output=True, text=True,
)
if proc.returncode != 0 or not dst.exists() or dst.stat().st_size <= 3:
self.log.debug(f"ffmpeg remux failed ({proc.returncode}): {proc.stderr[:300]}")
if dst.exists():
dst.unlink()
return False
return True
@staticmethod
def _search(pattern: str, text: str) -> Optional[str]:
match = re.search(pattern, text)
return match.group(1) if match else None
@staticmethod
def _extract_asin(url: str) -> Optional[str]:
match = re.search(r"/(?:albums|tracks)/([A-Z0-9]{10,})", url, re.I)
if match:
return match.group(1).upper()
return url.upper() if re.fullmatch(r"[A-Z0-9]{10,}", url, re.I) else None
@staticmethod
def _clean(value: Any) -> str:
if not value:
return ""
return _INVISIBLE.sub("", str(value)).strip()
@staticmethod
def _year(obj: dict) -> int:
for key in ("releaseYear", "originalReleaseDate", "releaseDate",
"albumReleaseDate", "publishDate"):
value = obj.get(key)
if not value:
continue
if isinstance(value, (int, float)):
value = int(value)
if 1000 <= value <= 2999:
return value
try:
return int(time.gmtime(value / 1000 if value > 1e11 else value).tm_year)
except Exception:
continue
match = re.search(r"(\d{4})", str(value))
if match:
return int(match.group(1))
return 0
@staticmethod
def _cover(url: Optional[str]) -> Optional[str]:
if not url:
return None
return re.sub(r"\._[A-Z0-9_,]+_\.", ".", url)
@staticmethod
def _quality_label(rep: dict) -> str:
family = rep["family"]
if family == "flac":
bits, rate = rep["bit_depth"] or 16, (rep["sample_rate"] or 44100) / 1000
return f"FLAC {bits}-bit/{rate:g} kHz"
name = {"mp4a": "AAC", "ec-3": "EC-3", "ac-4": "AC-4", "opus": "Opus", "mp3": "MP3"}.get(
family, family.upper())
kbps = (rep["bandwidth"] or 0) // 1000
return f"{name} {kbps} kb/s" if kbps else name
@@ -0,0 +1,125 @@
region: 'us'
request_timeout: 30
registration_timeout: 60
endpoints:
dmls: "{base}{location}/api/dmls/"
muse: "{base}{location}/api/muse/legacy/{operation}"
show_home: "https://{tvmesk}/api/showHome"
transfer_playback: "https://{tvmesk}/api/transferPlayback"
activation: "{activation}"
license_legacy: "{base}{location}/api/dmls/getLicenseForPlaybackV2"
web_endpoints:
config_json: "{base}config.json?referrer={referrer}"
force_sign_in: "{base}forceSignIn?useHorizonte=true"
weblab: "{base}api/weblab"
panda_token: "{base}pandaToken?profileId={customer_id}"
amz_targets:
manifest: "com.amazon.digitalmusiclocator.DigitalMusicLocatorServiceExternal.getDashManifestsV2"
license: "com.amazon.digitalmusiclocator.DigitalMusicLocatorServiceExternal.getLicenseForPlaybackV2"
muse: "com.amazon.musicensembleservice.MusicEnsembleService.{operation}"
interfaces:
authentication: "PlaybackAuthenticationInterface.v1_0.SetAuthenticationMethod"
video_player: "VideoPlayerAuthenticationInterface.v1_0.SetVideoPlayerTokenMethod"
drm:
system_ids:
widevine: "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
playready: "9a04f079-9840-4286-ab92-e65be0885f95"
manifest:
dash_versions: ["SIREN_KATANA"]
content_protection: ["TRACK_PSSH"]
# A1I3OANZGDNGEE "Harley" Android TV (PlayReady + Widevine)
# A16ZV8BU3SN1N3 WebCP Browser (Cookies) (Widevine)
# A1KAXIG6VXSG8Y NVIDIA Shield
# A2E0SNTXJVT7WK Fire TV
# A2SNKIF736WF4T Generic TV
# AOAGZA014O5RE Browser
# A1MPSLFC7L5AFK Hardware Device
device:
type_id: "A1I3OANZGDNGEE"
app_version: "3.12.11.183"
music_agent: "Harley/3.12.11.183 Harley/24.10.1 ({request_id} {asin})"
headers:
origin: "https://music.amazon.com"
referer: "https://music.amazon.com/"
user-agent: "Harley/3.12.11.183 A1I3OANZGDNGEE/24.10.1"
x-amzn-device-type-id: "A1I3OANZGDNGEE"
x-amzn-hardware-device-type-id: "A1MPSLFC7L5AFK"
x-amzn-device-family: "AndroidTV"
x-amzn-device-manufacturer: "NVIDIA"
x-amzn-device-model: "A1I3OANZGDNGEE"
x-amzn-device-language: "en_US"
x-amzn-device-height: "1080"
x-amzn-device-width: "1920"
x-amzn-os-version: "11"
x-amzn-application-version: "3.12.11.183"
x-amzn-device-time-zone: "America/Detroit"
x-amzn-user-agent: "Dalvik/2.1.0 (Linux; U; Android 9; Smart TV Build/PPR1.180610.011)"
codec_priority: ["flac", "ec-3", "ac-4", "mp4a", "opus", "mp3"]
regions:
us:
name: "United States"
marketplace: "ATVPDKIKX0DER"
location: "NA"
base: "https://music.amazon.com/"
activation: "https://www.amazon.com/code"
gb:
name: "United Kingdom"
marketplace: "A1F83G8C2ARO7P"
location: "EU"
base: "https://music.amazon.co.uk/"
activation: "https://www.amazon.co.uk/code"
de:
name: "Germany"
marketplace: "A1PA6795UKMFR9"
location: "EU"
base: "https://music.amazon.de/"
activation: "https://www.amazon.de/code"
fr:
name: "France"
marketplace: "A13V1IB3VIYZZH"
location: "EU"
base: "https://music.amazon.fr/"
activation: "https://www.amazon.fr/code"
jp:
name: "Japan"
marketplace: "A1VC38T7YXB528"
location: "FE"
base: "https://music.amazon.co.jp/"
activation: "https://www.amazon.co.jp/code"
mx:
name: "Mexico"
marketplace: "A1AM78C64UM0Y8"
location: "NA"
base: "https://music.amazon.com.mx/"
activation: "https://www.amazon.com.mx/code"
br:
name: "Brazil"
marketplace: "A2Q3Y263D00KWC"
location: "NA"
base: "https://music.amazon.com.br/"
activation: "https://www.amazon.com.br/code"
tvmesk_hosts:
NA: "na.tvmesk.skill.music.a2z.com"
EU: "eu.tvmesk.skill.music.a2z.com"
FE: "fe.tvmesk.skill.music.a2z.com"
region_from_domain:
music.amazon.com: "us"
music.amazon.co.uk: "gb"
music.amazon.de: "de"
music.amazon.fr: "fr"
music.amazon.co.jp: "jp"
music.amazon.com.mx: "mx"
music.amazon.com.br: "br"
@@ -0,0 +1,150 @@
certificate: |
CAUSwgUKvAIIAxIQCuQRtZRasVgFt7DIvVtVHBi17OSpBSKOAjCCAQoCggEBAKU2UrYVOSDlcXajWhpEgGhqGraJtFdUPgu6plJGy9ViaRn5mhyXON5PXm
w1krQdi0SLxf00FfIgnYFLpDfvNeItGn9rcx0RNPwP39PW7aW0Fbqi6VCaKWlR24kRpd7NQ4woyMXr7xlBWPwPNxK4xmR/6UuvKyYWEkroyeIjWHAqgCjC
mpfIpVcPsyrnMuPFGl82MMVnAhTweTKnEPOqJpxQ1bdQvVNCvkba5gjOTbEnJ7aXegwhmCdRQzXjTeEV2dO8oo5YfxW6pRBovzF6wYBMQYpSCJIA24ptAP
/2TkneyJuqm4hJNFvtF8fsBgTQQ4TIhnX4bZ9imuhivYLa6HsCAwEAAToPYW1hem9uLmNvbS1wcm9kEoADETQD6R0H/h9fyg0Hw7mj0M7T4s0bcBf4fMhA
Rpwk2X4HpvB49bJ5Yvc4t41mAnXGe/wiXbzsddKMiMffkSE1QWK1CFPBgziU23y1PjQToGiIv/sJIFRKRJ4qMBxIl95xlvSEzKdt68n7wqGa442+uAgk7C
XU3uTfVofYY76CrPBnEKQfad/CVqTh48geNTb4qRH1TX30NzCsB9NWlcdvg10pCnWSm8cSHu1d9yH+2yQgsGe52QoHHCqHNzG/wAxMYWTevXQW7EPTBeFy
SPY0xUN+2F2FhCf5/A7uFUHywd0zNTswh0QJc93LBTh46clRLO+d4RKBiBSj3rah6Y5iXMw9N9o58tCRc9gFHrjfMNubopWHjDOO3ATUgqXrTp+fKVCmsG
uGl1ComHxXV9i1AqHwzzY2JY2vFqo73jR3IElr6oChPIwcNokmNc0D4TXtjE0BoYkbWKJfHvJJihzMOvDicWUsemVHvua9/FBtpbHgpbgwijFPjtQF9Ldb
8Swf
dtid_dict: [
"A3EFHJ9BGBJ8L2", "A3VN4E5F7BBC7S", "A28RQHJKHM2A2W", "AFOQV1TK6EU6O","A1IJNVP3L4AY8B", "A2Z1NVLU6LCAUO",
"A2E50Q8IVZ79QG", "A17PYKRA4ES6YB", "AFRP7VQQ7US69", "A2VZ790DVVI91K", "A2IK56KYGDHUVQ", "A1EXS4KWCX7GYC",
"ANSTXZRNSPRXT", "A1KF4O3GA2MPZU", "A2XEBUI9EJ55OK", "A7YWW3KUHA54O", "A3DOA4P2WFIK9D", "ABJRG3JXAY5JL",
"AI00TLC8V1PCT", "A2YWQEZX3UC3J1", "AM08S97P8ESGT", "A234HDVPYTUVNS", "AJ3B6LDE2HP5J", "A2GZIBBOG0DCV4",
"A3MEKX9EL7SW8T", "A1GXILNJBV9CU7", "ANHPQG9GCMO4A", "A2CBAN119017AE", "A3FO6QR7E7PFQX", "A27OOP63XLO9TI",
"A2EOVT31LL6KPV", "A1FNA83TYYU3QK", "A3JB7490MR9K86", "A2TIAYXTNQWU3T", "ADP5BND5THPTX", "A1H8RTR0E3Y362",
"AUNIXHOL9EVMI", "AZKAGPPWORIRY", "A324X3KDTS7NYA", "A390CW53E1P0G4", "A2D0X18EHNKEOJ", "A3JN21B5ZOWUAN",
"A1TG8VNKP4DSQR", "A2XZMRZUFPEDN4", "A2LJ4A527WOX9J", "A2RJLFEH0UEKI9", "AE5DW8GVLP9NX", "AIE8AMJ60B7OK",
"A3ZKCWKG4097P", "A2H1I0AR67NWAC", "A3QXXOBP9MU5LY", "A25521KS9QCAMD", "AGQHFIWNI20PO", "A27XSKZJJPVQA4",
"A1LCAPNEM1C36Z", "A31POKKHZJR1J4", "AOLDXB6WYN0UM", "A3SSWQ04XYPXBH", "A1BSQJM6E77NJE", "A2TX61L00VISA5",
"AO4A5QLO9663Q", "A15MU3EQ4XZ3Y5", "A3IWJ2DYJQRA3T", "A1Q878J3NE8P81", "AAJB0R7QJO84W", "A2M1CHUCI6RHN8",
"A6IUL9CVJZXRR", "A2RGJ95OVLR12U", "A1G2XVSR1VA5DI", "A1S15DUFSI8AUG", "ALYWZPYF4JAIT", "A2M4YX06LWP8WI",
"A2O85NMVNLPKVN", "A3L0T0VL9A921N", "A1J16TEDOYCZTN", "A1Q7QCGNMXAKYW", "A38EHHIB10L47V", "A3R9S4ZZECZ6YL",
"A1C66CX2XD756O", "A1ZB65LA390I4K", "AVU7CPPF2ZRAS", "ATNLRCEBX3W4P", "A2N49KXGVA18AR", "A271DR1789MXDS",
"A1TD5Z1R8IWBHA", "A1DOD0Z74XEFYC", "A17AIVOKIKR4QQ", "A1S310WB67VFPY", "A2QCPPMSOLGVZE", "A1NPAGU1M4PA7Z",
"A3ORLONYQTBTOZ", "A3M7PA8JXKE627", "A71I8788P1ZV8", "A2V9UEGZ82H4KZ", "A3URJAABOST7NW", "A2E0SNTXJVT7WK",
"ADVBD696BHNV5", "A12GXV8XMS007S", "A2LWARUGJLBYEW", "A2GFL5ZMWNE0PX", "AKPGW064GI9HE", "A3HF4YRA2L7XGC",
"AGHZIK8D6X7QR", "A1F8D55J0FWDTN", "A1P7E7V3FCZKU6", "A1NL4BVLQ4L3N3", "A10A33FOX2NUBK", "AWZZ5CVHX2CD",
"A4ZP7ZC4PI6TO", "A1Z88NGR2BK6A2", "A2MDL5WTJIU8SZ", "AP4RS91ZQ0OOI", "AFF5OAL5E3DIU", "A2HYAJ0FEWP6N3",
"A3SUJTTQGF9GNF", "A346DYAAR4WSNS", "A93SQJNJQLDSS", "A2JKHJ0PX4J3L3", "A2WJI2JG7UW2O1", "ARJHEDRXLP6DM",
"A1AGU0A4YA5RF9", "A2WV8TTM37P2CB", "AN630UQPG2CA4", "A30OJ8LMIAF6GP", "A8MCGN45KMHDH", "A33S43L213VSHQ",
"A2NYIDFQSJW39B", "A31DTMEEVDDOIV", "A2FDUYD6UQ1BQ", "A3MTL1JKF2IXY3", "AK6OCP5ZLUJI1", "A3JTVZS31ZJ340",
"A43PXU4ZN2AL1", "A1OTX5GMM5144Z", "A2Z1NVLU6LCAUO", "A43PXU4ZN2AL1", "A1OTX5GMM5144Z", "A2Z1NVLU6LCAUO",
"A2SNKIF736WF4T"
]
device:
default:
manufacturer: WV
device_chipset: MediaTek
domain: Device
app_name: AIV
os_name: Android
app_version: '3.19.5'
device_model: WV_ELPTK514KT22_541264
os_version: '9'
device_serial: '95047855cf573479341d134104cfa312'
device_type: A2SNKIF736WF4T
device_name: "Zos Android TV"
software_version: '248'
firmware: 'Android 9'
firmware_version: '9.0 | Android | TV | armeabi-v7a | CE CDM 14.0.0'
user_agent: 'Dalvik/2.1.0 (Linux; U; Android 9; WV_ELPTK514KT22_541264 Build/PPR1.180610.011)'
device_types:
browser: 'AOAGZA014O5RE'
tv_generic: 'A2SNKIF736WF4T'
pc_app: 'A1RTAM01W29CUP'
mobile_app: 'A43PXU4ZN2AL1'
echo: 'A7WXQPH584YP'
echo_dot: 'A32DOYMUN6DTXA'
echo_studio: 'A3RBAYBE7VM004'
fire_7: 'A2M4YX06LWP8WI'
fire_hd_8: 'A1C66CX2XD756O'
fire_hd_8_plus_2020: 'AVU7CPPF2ZRAS'
fire_hd_10: 'A1ZB65LA390I4K'
fire_tv: 'A2E0SNTXJVT7WK'
fire_tv_gen2: 'A12GXV8XMS007S'
fire_tv_cube: 'A2JKHJ0PX4J3L3'
fire_tv_stick_gen1: 'ADVBD696BHNV5'
fire_tv_stick_gen2: 'A2LWARUGJLBYEW'
fire_tv_stick_4k: 'A2GFL5ZMWNE0PX'
fire_tv_stick_4k_gen3: 'AKPGW064GI9HE'
nvidia_shield: 'A1KAXIG6VXSG8Y'
endpoints:
configuration: '/acm/GetConfiguration/WebClient'
details: '/gp/video/api/getDetailPage'
getDetailWidgets: '/gp/video/api/getDetailWidgets'
playback: '/playback/prs/GetVodPlaybackResources'
metadata: '/api/enrichItemMetadata'
refreshplayback: '/playback/tags/getRefreshedPlaybackEnvelope'
license_wv: '/playback/drm-vod/GetWidevineLicense'
license_pr: '/playback/drm-vod/GetPlayReadyLicense'
opensession: '/cdp/playback/pes/StartSession'
updatesession: '/cdp/playback/pes/UpdateSession'
closesession: '/cdp/playback/pes/StopSession'
xray: '/swift/page/xray'
ontv: '/gp/video/ontv/code'
devicelink: '/gp/video/api/codeBasedLinking'
codepair: '/auth/create/codepair'
register: '/auth/register'
token: '/auth/token'
regions:
us:
base: 'www.amazon.com'
base_api: 'api.amazon.com'
base_manifest: 'atv-ps.amazon.com'
marketplace_id: 'ATVPDKIKX0DER'
fr:
base: 'www.amazon.fr'
base_api: 'api.amazon.fr'
base_manifest: 'atv-ps-eu.primevideo.com'
marketplace_id: 'A13V1IB3VIYZZH'
gb:
base: 'www.amazon.co.uk'
base_api: 'api.amazon.co.uk'
base_manifest: 'atv-ps-eu.amazon.co.uk'
marketplace_id: 'A1F83G8C2ARO7P' # A2IR4J4NTCP2M5
es:
base: 'www.amazon.es'
base_api: 'api.amazon.es'
base_manifest: 'atv-ps-eu.primevideo.com'
marketplace_id: 'A1RKKUPIHCS9HS'
it:
base: 'www.amazon.it'
base_api: 'api.amazon.it'
base_manifest: 'atv-ps-eu.primevideo.com'
marketplace_id: 'A3K6Y4MI8GDYMT'
de:
base: 'www.amazon.de'
base_api: 'api.amazon.de'
base_manifest: 'atv-ps-eu.amazon.de'
marketplace_id: 'A1PA6795UKMFR9'
au:
base: 'www.amazon.com.au'
base_api: 'api.amazon.com.au'
base_manifest: 'atv-ps-fe.amazon.com.au'
marketplace_id: 'A3K6Y4MI8GDYMT'
jp:
base: 'www.amazon.co.jp'
base_api: 'api.amazon.co.jp'
base_manifest: 'atv-ps-fe.amazon.co.jp'
marketplace_id: 'A1VC38T7YXB528'
pl:
base: 'www.amazon.com'
base_api: 'api.amazon.com'
base_manifest: 'atv-ps-eu.primevideo.com'
marketplace_id: 'A3K6Y4MI8GDYMT'
@@ -0,0 +1,503 @@
from __future__ import annotations
import hashlib
import re
from http.cookiejar import CookieJar
from pathlib import Path
from typing import Any, Optional
import click
from Cryptodome.Cipher import Blowfish
from envied.core.credential import Credential
from envied.core.music import MusicTrackOption
from envied.core.service import Service
from envied.core.titles import Music, Song, Titles_T
from envied.core.tracks import Audio, Chapters, Tracks
from envied.core.tracks.track import Track
class DEZR(Service):
"""
Service code for Deezer (https://deezer.com)
www.nostalgic.cc
Authorization: Credentials, ARLs
Security: None
"""
ALIASES = ("DEZR", "deezer", "DEEZ")
GROUP_AUDIO_DOWNLOADS = True
TITLE_RE = (
r"^(?:https?://(?:www\.)?deezer\.com/(?:[a-z]{2}/)?(?P<type>track|album|playlist|artist)/)?"
r"(?P<id>\d+)"
)
GW_LIGHT = "https://www.deezer.com/ajax/gw-light.php"
GET_URL = "https://media.deezer.com/v1/get_url"
BLOWFISH_SECRET = b"g4el58wc0zvf9na1"
FORMATS = {
"FLAC": ("FLAC", "FLAC 16-bit/44.1kHz"),
"MP3_320": ("MP3_320", "MP3 320 kb/s"),
"MP3_128": ("MP3_128", "MP3 128 kb/s"),
}
QUALITY_MAP = {
"FLAC": "FLAC", "LOSSLESS": "FLAC", "FUCK": "FLAC",
"MP3_320": "MP3_320", "320": "MP3_320", "MP3": "MP3_320",
"MP3_128": "MP3_128", "128": "MP3_128",
}
FALLBACK_ORDER = ["FLAC", "MP3_320", "MP3_128"]
@staticmethod
@click.command(name="DEZR", short_help="https://deezer.com", help=__doc__)
@click.argument("title", type=str)
@click.option("-q", "--quality", "quality",
type=click.Choice(["FLAC", "MP3_320", "MP3_128", "320", "128", "MP3", "LOSSLESS"],
case_sensitive=False),
default=None,
help="Audio quality (default: config default_quality, or FLAC). "
"FLAC needs a Deezer HiFi subscription.")
@click.pass_context
def cli(ctx, **kwargs):
return DEZR(ctx, **kwargs)
def __init__(self, ctx, title: str, quality: Optional[str]):
super().__init__(ctx)
self.title = title
if quality:
self.quality = self.QUALITY_MAP[quality.upper()]
else:
cfg_q = str(self.config.get("default_quality", "FLAC")).upper()
self.quality = self.QUALITY_MAP.get(cfg_q, "FLAC")
self.arl: Optional[str] = None
self.api_token: str = ""
self.license_token: Optional[str] = None
self.lossless_allowed: bool = False
m = re.search(self.TITLE_RE, self.title)
if not m:
self.log.error("Could not parse a Deezer track/album/playlist/artist ID from the input.")
raise SystemExit(1)
self.item_type = m.group("type") or "album"
self.item_id = m.group("id")
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
self.session.headers.update({
"User-Agent": self.config.get(
"user_agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
),
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json; charset=UTF-8",
"Origin": "https://www.deezer.com",
"Referer": "https://www.deezer.com/",
})
self.arl = self._resolve_arl(cookies, credential)
if not self.arl:
self.log.error(
"No Deezer ARL found. Set it in your unshackle config under "
"services: DEZR: arl: \"YOUR_ARL\", or provide an 'arl' "
"cookie, or credentials as 'arl:YOUR_ARL'."
)
raise SystemExit(1)
self.session.cookies.set("arl", self.arl, domain=".deezer.com")
user = self._gw("deezer.getUserData")
options = (user.get("USER") or {}).get("OPTIONS") or {}
user_id = (user.get("USER") or {}).get("USER_ID")
if not user_id or user_id == 0:
self.log.error("Deezer ARL is invalid or expired. Refresh your ARL.")
raise SystemExit(1)
self.api_token = user.get("checkForm") or ""
self.license_token = options.get("license_token")
wsq = options.get("web_sound_quality") or {}
self.lossless_allowed = bool(wsq.get("lossless"))
if self.quality == "FLAC" and not self.lossless_allowed:
self.log.warning("FLAC requested but this account has no HiFi/lossless plan.")
self.log.info(
f" + Authenticated with Deezer (Lossless {'available' if self.lossless_allowed else 'unavailable'})"
)
def _resolve_arl(self, cookies: Optional[CookieJar], credential: Optional[Credential]) -> Optional[str]:
if credential:
user = (credential.username or "").strip()
pw = (credential.password or "").strip()
if user.lower() in ("arl", "token", "deezer") and pw:
return pw
if pw and not user:
return pw
if user and not pw:
return user
if cookies:
for cookie in cookies:
if cookie.name.lower() == "arl" and cookie.value:
return cookie.value
for key in ("arl", "token"):
value = str(self.config.get(key) or "").strip()
if value:
return value
return None
def _gw(self, method: str, payload: Optional[dict] = None) -> dict:
resp = self.session.post(
self.GW_LIGHT,
params={"method": method, "input": "3", "api_version": "1.0", "api_token": self.api_token},
json=payload or {},
)
resp.raise_for_status()
data = resp.json()
error = data.get("error")
results = data.get("results")
if not results:
raise ValueError(f"Deezer gateway '{method}' failed: {error or 'empty response'}")
return results
def get_titles(self) -> Titles_T:
if self.item_type == "track":
return self._titles_from_track(self.item_id)
if self.item_type == "playlist":
return self._titles_from_playlist(self.item_id)
if self.item_type == "artist":
return self._titles_from_artist(self.item_id)
return self._titles_from_album(self.item_id)
def _titles_from_track(self, sng_id: str) -> Music:
song_data = self._gw("song.getData", {"sng_id": sng_id})
album_data = {}
alb_id = song_data.get("ALB_ID")
if alb_id:
try:
album_data = self._gw("album.getData", {"alb_id": alb_id})
except Exception as e:
self.log.debug(f"Album lookup for track {sng_id} failed: {e}")
song = self._build_song(song_data, album_data)
return Music(
[song],
kind="single",
title=song.album,
artist=song.album_artist or song.artist,
year=song.year,
total_tracks=1,
artwork_url=song.artwork_url,
)
def _titles_from_album(self, alb_id: str) -> Music:
page = self._gw("deezer.pageAlbum", {"alb_id": alb_id, "lang": "en", "tab": 0})
album_data = page.get("DATA") or {}
songs_raw = (page.get("SONGS") or {}).get("data") or []
songs = [self._build_song(s, album_data) for s in songs_raw]
if not songs:
self.log.error(f" - No tracks found for album {alb_id}."); raise SystemExit(1)
return Music(
songs,
kind=self._release_kind(album_data, len(songs)),
title=album_data.get("ALB_TITLE"),
artist=album_data.get("ART_NAME"),
year=self._year(album_data),
total_tracks=len(songs),
total_discs=max((s.disc for s in songs), default=1),
artwork_url=self._cover_url(album_data.get("ALB_PICTURE")),
total_duration=sum(int(s.data.get("duration") or 0) for s in songs) or None,
)
def _titles_from_playlist(self, playlist_id: str) -> Music:
page = self._gw("deezer.pagePlaylist", {
"playlist_id": playlist_id, "lang": "en", "nb": 2000, "start": 0, "tab": 0, "header": True,
})
pl_data = page.get("DATA") or {}
songs_raw = (page.get("SONGS") or {}).get("data") or []
songs = []
for position, s in enumerate(songs_raw, start=1):
songs.append(self._build_song(s, {}, playlist_position=position))
if not songs:
self.log.error(f"No tracks found for playlist {playlist_id}."); raise SystemExit(1)
return Music(
songs,
kind="playlist",
title=pl_data.get("TITLE"),
artist=(pl_data.get("PARENT_USERNAME") or None),
total_tracks=len(songs),
owner=(pl_data.get("PARENT_USERNAME") or None),
artwork_url=self._cover_url(pl_data.get("PLAYLIST_PICTURE"), kind="playlist"),
total_duration=int(pl_data.get("DURATION") or 0) or None,
)
def _titles_from_artist(self, artist_id: str) -> Music:
page = self._gw("artist.getTopTrack", {"art_id": artist_id, "nb": 100})
songs_raw = page.get("data") or []
songs = []
for position, s in enumerate(songs_raw, start=1):
songs.append(self._build_song(s, {}, playlist_position=position))
if not songs:
self.log.error(f"No top tracks found for artist {artist_id}."); raise SystemExit(1)
artist_name = songs[0].artist
return Music(
songs,
kind="playlist",
title=f"{artist_name} - Top Tracks",
artist=artist_name,
total_tracks=len(songs),
)
def _build_song(self, s: dict, album: dict, playlist_position: Optional[int] = None) -> Song:
album = album or {}
title = (s.get("SNG_TITLE") or "").strip() or "Unknown"
version = (s.get("VERSION") or "").strip()
if version:
title = f"{title} {version}".strip()
artist = (s.get("ART_NAME") or "").strip() or "Unknown Artist"
album_title = (s.get("ALB_TITLE") or album.get("ALB_TITLE") or "").strip() or "Unknown Album"
album_artist = (album.get("ART_NAME") or s.get("ART_NAME") or artist).strip()
year = self._year(album) or self._year(s) or 1
cover_md5 = s.get("ALB_PICTURE") or album.get("ALB_PICTURE")
artwork = self._cover_url(cover_md5)
disc = self._to_int(s.get("DISK_NUMBER"), 1)
track_num = playlist_position or self._to_int(s.get("TRACK_NUMBER"), 1)
isrc = (s.get("ISRC") or "").strip() or None
explicit = str(s.get("EXPLICIT_LYRICS", "0")) == "1"
data = {
"service": self.ALIASES[0],
"sng_id": str(s.get("SNG_ID")),
"track_token": s.get("TRACK_TOKEN"),
"album_id": str(album.get("ALB_ID") or s.get("ALB_ID") or "") or None,
"title": title,
"artist": artist,
"album": album_title,
"album_artist": album_artist,
"duration": self._to_int(s.get("DURATION"), 0),
"isrc": isrc,
"artwork_url": artwork,
"fallback_id": ((s.get("FALLBACK") or {}).get("SNG_ID") if isinstance(s.get("FALLBACK"), dict) else None),
}
return Song(
id_=str(s.get("SNG_ID")),
service=self.__class__,
name=title,
artist=artist,
album=album_title,
track=int(track_num),
disc=int(disc),
year=int(year),
album_artist=album_artist,
release_type=self._release_kind(album, None),
total_tracks=self._to_int(album.get("NUMBER_TRACK"), None),
total_discs=self._to_int(album.get("NUMBER_DISK"), None),
explicit=explicit,
isrc=isrc,
label=(album.get("LABEL_NAME") or None),
artwork_url=artwork,
data=data,
)
def get_music_track_options(self, song: Song) -> list[MusicTrackOption]:
fmt = self._effective_format()
deezer_fmt = self.FORMATS[fmt][0]
data = song.data if isinstance(song.data, dict) else {}
if deezer_fmt == "FLAC":
option = MusicTrackOption(
codec="FLAC", bit_depth=16, sample_rate=44100, channels=2.0,
lossless=True, hires=False,
)
else:
option = MusicTrackOption(
codec="MP3", bitrate=320000 if deezer_fmt == "MP3_320" else 128000,
channels=2.0, lossless=False,
)
option.explicit = bool(data.get("explicit")) if "explicit" in data else song.explicit or False
option.duration = int(data.get("duration")) if data.get("duration") else None
option.quality_label = self.FORMATS[fmt][1]
return [option]
def get_tracks(self, song: Song) -> Tracks:
sng_id = str(song.id)
try:
fresh = self._gw("song.getData", {"sng_id": sng_id})
track_token = fresh.get("TRACK_TOKEN") or (song.data or {}).get("track_token")
fallback_id = (fresh.get("FALLBACK") or {}).get("SNG_ID") if isinstance(fresh.get("FALLBACK"), dict) else None
except Exception as e:
self.log.debug(f"Fresh token fetch failed for {sng_id}: {e}")
track_token = (song.data or {}).get("track_token")
fallback_id = (song.data or {}).get("fallback_id")
if not track_token:
self.log.error(f"No track token for '{song.name}' (Not streamable?)."); raise SystemExit(1)
url, used_fmt, used_id = self._get_stream_url(sng_id, track_token, fallback_id)
deezer_fmt = self.FORMATS[used_fmt][0]
is_flac = deezer_fmt == "FLAC"
audio = Audio(
url,
language=song.language or "en",
codec=Audio.Codec.FLAC if is_flac else None,
bitrate=None if is_flac else (320000 if deezer_fmt == "MP3_320" else 128000),
channels=2,
descriptor=Track.Descriptor.URL,
id_=sng_id,
data={
"dezr_sng_id": str(used_id),
"dezr_ext": "flac" if is_flac else "mp3",
"dezr_encrypted": True,
},
)
return Tracks([audio])
def get_chapters(self, song: Song) -> Chapters:
return Chapters()
def _effective_format(self) -> str:
if self.quality == "FLAC" and not self.lossless_allowed:
return "MP3_320"
return self.quality
def _get_stream_url(self, sng_id: str, track_token: str, fallback_id: Optional[str]):
start = self._effective_format()
order = self.FALLBACK_ORDER[self.FALLBACK_ORDER.index(start):]
last_error = ""
for fmt in order:
url, err = self._request_url(track_token, self.FORMATS[fmt][0])
if url:
if fmt != self.quality:
self.log.warning(f" - {self.quality} unavailable for this track. Using: {fmt}.")
return url, fmt, sng_id
last_error = err
if fallback_id and str(fallback_id) != str(sng_id):
self.log.warning(f"Track unavailable ({last_error}). Trying fallback {fallback_id}.")
try:
fb = self._gw("song.getData", {"sng_id": str(fallback_id)})
fb_token = fb.get("TRACK_TOKEN")
if fb_token:
return self._get_stream_url(str(fallback_id), fb_token, None)
except Exception as e:
self.log.debug(f"Fallback track fetch failed: {e}")
self.log.error(f"Could not get a stream URL for track {sng_id}. {last_error}")
raise SystemExit(1)
def _request_url(self, track_token: str, deezer_fmt: str):
try:
resp = self.session.post(self.GET_URL, json={
"license_token": self.license_token,
"media": [{
"type": "FULL",
"formats": [{"cipher": "BF_CBC_STRIPE", "format": deezer_fmt}],
}],
"track_tokens": [track_token],
})
resp.raise_for_status()
data = resp.json()
except Exception as e:
return None, f"request failed: {e}"
entries = data.get("data") or []
if not entries:
return None, "no data in get_url response"
entry = entries[0]
if entry.get("errors"):
msg = entry["errors"][0].get("message", "unknown error")
return None, msg
media = entry.get("media") or []
if not media or not media[0].get("sources"):
return None, f"no {deezer_fmt} media available"
return media[0]["sources"][0]["url"], ""
def on_track_downloaded(self, track: Any) -> None:
try:
data = getattr(track, "data", None)
path = getattr(track, "path", None)
if not isinstance(data, dict) or not data.get("dezr_encrypted"):
return
if data.get("dezr_done"):
return
if not path or not Path(path).exists():
return
path = Path(path)
sng_id = str(data.get("dezr_sng_id"))
key = self._blowfish_key(sng_id)
if path.stat().st_size < 2048:
head = path.read_bytes()[:1]
if head in (b"{", b"["):
self.log.error(
f"Deezer returned an error instead of audio for track {sng_id} "
"(token/geo/quality). File left in place for inspection."
)
data["dezr_done"] = True
return
ext = data.get("dezr_ext") or "flac"
out_path = path.with_suffix(f".{ext}")
tmp_path = path.with_suffix(path.suffix + ".dec")
iv = bytes(range(8)) # 00 01 02 03 04 05 06 07
block_size = 2048
with path.open("rb") as fi, tmp_path.open("wb") as fo:
index = 0
while True:
chunk = fi.read(block_size)
if not chunk:
break
if index % 3 == 0 and len(chunk) == block_size:
chunk = Blowfish.new(key, Blowfish.MODE_CBC, iv).decrypt(chunk)
fo.write(chunk)
index += 1
path.unlink()
if out_path.exists():
out_path.unlink()
tmp_path.rename(out_path)
track.path = out_path
data["dezr_done"] = True
except Exception as e:
self.log.error(f"Failed to decrypt Deezer track: {e}")
raise
def _blowfish_key(self, sng_id: str) -> bytes:
md5_hex = hashlib.md5(sng_id.encode()).hexdigest()
return bytes(
ord(md5_hex[i]) ^ ord(md5_hex[i + 16]) ^ self.BLOWFISH_SECRET[i]
for i in range(16)
)
@staticmethod
def _to_int(value: Any, default: Optional[int]) -> Optional[int]:
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _year(obj: dict) -> int:
for key in ("DIGITAL_RELEASE_DATE", "PHYSICAL_RELEASE_DATE", "DATE_ADD", "ORIGINAL_RELEASE_DATE"):
value = obj.get(key)
if value:
match = re.match(r"(\d{4})", str(value))
if match and int(match.group(1)) > 0:
return int(match.group(1))
return 0
@staticmethod
def _cover_url(md5: Optional[str], kind: str = "cover") -> Optional[str]:
if not md5:
return None
return f"https://e-cdns-images.dzcdn.net/images/{kind}/{md5}/1400x0-000000-100-0-0.jpg"
@staticmethod
def _release_kind(album: dict, track_count: Optional[int]) -> str:
rt = str((album or {}).get("TYPE") or (album or {}).get("RECORD_TYPE") or "").lower()
if rt in ("single", "ep", "compile", "compilation", "album"):
return "compilation" if rt == "compile" else rt
if track_count is not None and track_count <= 3:
return "single"
return "album"
@@ -0,0 +1,5 @@
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
arl: ''
default_quality: 'flac'
@@ -0,0 +1,76 @@
# DisneyPlus(디즈니플러스)
```
uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 1080,2160 -v h.264,h.265 -r SDR,HDR10,DV DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b
```
## Information(정보)
- Authorization: Credentials, Web Token
- Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000
- Working Client Agent: AndroidTV
- Support Codec
- Video: H264, H265
- Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX)
- Range: SDR, HDR10, HDR10, DV
## Support Args(지원하는 명령어 인자)
- `-i`, `--imax`: Prefer IMAX Enhanced version if available.
- `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available.
- `-e`, `--extras`: Select a extras video if available.
- `-tu`, `--tier-unlimits`: Remove stream quality restrictions for a specific account.
## Tips
- To enable the web refresh token-based login method, please comment out or delete the DSNP section under credentials in `envied.yaml`.
웹 리프레시 토큰 기반 로그인 방식을 활성화하려면 `envied.yaml`의 credentials에서 DSNP부분을 주석 처리하거나 삭제하세요.
```
credentials:
...
# DSNP: example@example.com:example
```
- Configure user settings within the `envied.yaml` file.
사용자 설정은 `envied.yaml`에서 다음과 같이 사용하세요.
```
services:
DSNP:
## 사용자 환경설정
## User configuration
# 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다.
# If these settings are commented out, values will be selected automatically.
preferences:
# 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등)
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
# 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다.
# If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected.
profile: 0
# 서비스 내에서 표시되는 메타데이터 언어를 선택합니다.
# Selects the metadata language displayed within the service.
# 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다.
# Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en").
# 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다.
# If no value is set, the language settings of the current profile will be used.
# language: "ko"
# 매니페스트 로그 출력 레벨을 설정합니다.
# Sets the manifest log output level.
# 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오.
# Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible.
# 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다.
# If no value is set, the default level is "debug".
# manifest_log: "info"
```
- To enable the tier_unlimits command by default, add the following to `envied.yaml`.
tier_unlimits 명령을 기본값으로 활성화하려면 `envied.yaml`에 다음을 추가하세요.
```
dl:
...
DSNP:
tier_unlimits: True
```
@@ -0,0 +1,13 @@
from __future__ import annotations
from .dsnp import DSNP
"""
Service code for Disney+ Streaming Service (https://disneyplus.com).\n
Version: 26.06.14
Author: Made by CodeName393 and Improvement by sp4rk.y with Special Thanks to narakama, Hugov, Sam\n
Authorization: Credentials, Web Token\n
Security: UHD@L1/SL3000 FHD@L1/SL3000 HD@L3/SL2000
"""
__all__ = ("DSNP",)
@@ -0,0 +1,85 @@
## DO NOT EDIT THIS FILE
# 해당 config 파일은 개발자 외에는 수정하지 마세요.
# This configuration file should not be modified by anyone other than developers.
# 사용자 환경설정은 반드시 "envied.yaml"에서만 사용하세요.
# User configuration must be performed only in "envied.yaml".
certificate: |
CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml
ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa
xsU4v8tQDA8PcjzzV9ICodl6crcFZhAy4QMNXfbWOv/ZrGFx5blSXrzP1sMQ64IY8bjUYw4coZM34NDhu8aCA692g8k2mTz2494x7u3Is8v7RKC9ZNiETE
K5/4oeVclXPpelNQokR4uvggnCD1L2EULG/pp6wnk1yWNNLxcCAwEAAToHYmFtdGVjaBKAA2FqHlqkE7EUmdOLiCi0hy5jRgBDJrU1CWNHfH6r2i6s5T5k
6LK7ZfD65Tv6uyqq1k82PsDz4++kxbpfJDZaypFbae4XPc6lZxRCc5X0toX/x9TftOQQ4N82l5Hxoha569EPRkrnNy7rO7xrRILa3ZVj1alttEnEEjxEuw
SV8usdlUg8/LvLA2C59T/HA2I77k7yVbTrVdy0f81r2l+E2SslivCy1JD3xKlgoaKl4xBnRxItWt8+DCw1Xm2lemYl2LGoh1Wk9gvlXQvr2Jv2+dFX3RNs
i5sd00KS9sePszfjoTkQ6fmpRd7ZgFCGFWYB9JZ92aGUFQRE14OTST2uwSf32YCfsoATDNs4V6dB8YDoTGKFGrcoc4gtHPKySGNt7z/fOW4/01ZGzKqoVY
Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw==
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json
# Android Phone : /android/v18.0.0/google/handset/prod.json
# Android TV : /android/v18.0.0/google/tv/prod.json
# Amazon Fire TV : /android/v18.0.0/amazon/tv/prod.json
# Apple Iphone(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/iphone/prod.json
# Apple Ipad(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/ipad/prod.json
endpoints:
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v22.0.0/google/tv/prod.json"
## user_agent (okhttp/5.0.0-alpha.14) ##
# android-phone : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; phone)
# android-tv : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; tv)
## api_key ##
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA
# apple : ZGlzbmV5JmFwcGxlJjEuMC4w.H9L7eJvc2oPYwDgmkoar6HzhBJRuUUzt_PcaC3utBI4
## yp_service_id ##
# browser : 63626081279ebe65eb50fb54
# android : 624b805dafc5c73635b1a216
bamsdk:
sdk_version: "22.0.0"
application_version: "26.9.2+rc1-2026.06.12.0"
explore_version: "v1.18"
client: "disney-svod-3d9324fc"
user_agent: "BAMSDK/v22.0.0 (disney-svod-3d9324fc 26.9.2+rc1-2026.06.12.0; v7.0/v22.0.0; android; tv)"
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
yp_service_id: "624b805dafc5c73635b1a216"
device:
family: "android"
profile: "tv"
platform: "android/google/tv" # {deviceFamily}/{applicationRuntime}/{deviceProfile}
platform_id: "android-tv"
applicationRuntime: "android"
manufacturer: "Google"
operatingSystem: "Android"
operatingSystemVersion: "16"
# ## 사용자 환경설정
# ## User configuration
# # 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다.
# # If these settings are commented out, values will be selected automatically.
# preferences:
# # 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등)
# # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
# # 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다.
# # If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected.
# profile: 0
# # 서비스 내에서 표시되는 메타데이터 언어를 선택합니다.
# # Selects the metadata language displayed within the service.
# # 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다.
# # Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en").
# # 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다.
# # If no value is set, the language settings of the current profile will be used.
# language: "ko"
# # 매니페스트 로그 출력 레벨을 설정합니다.
# # Sets the manifest log output level.
# # 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오.
# # Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible.
# # 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다.
# # If no value is set, the default level is "debug".
# manifest_log: "debug"
@@ -0,0 +1,14 @@
# REQUEST_DEVICE_CODE = """mutation requestLicensePlate($input: RequestLicensePlateInput!) { requestLicensePlate(requestLicensePlate: $input) { licensePlate expirationTime expiresInSeconds } }"""
CHECK_EMAIL = """query check($email: String!) { check(email: $email) { operations nextOperation } }"""
LOGIN = """mutation login($input: LoginInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { login(login: $input) { account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled isGeminiOnboarded profileLinked languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } linkedProfile { profileLinkType pinProtected } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
LOGIN_ACTION_GRANT = """mutation loginWithActionGrant($input: LoginWithActionGrantInput!, $includeAccountConsentToken: Boolean!) { loginWithActionGrant(login: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } actionGrant } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction passwordRules { __typename ...passwordRulesFragment } } } fragment passwordRulesFragment on PasswordRules { minLength charTypes }"""
ME = """query me($includeAccountConsentToken: Boolean!) { me { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
REFRESH_TOKEN = """mutation refreshToken($refreshToken: RefreshTokenInput!) { refreshToken(refreshToken: $refreshToken) { activeSession { sessionId } } }"""
REGISTER_DEVICE = """mutation registerDevice($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } }"""
REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SET_APP_LANGUAGE = """mutation updateProfileAppLanguage($input: UpdateProfileAppLanguageInput!, $includeProfile: Boolean!) { updateProfileAppLanguage(updateProfileAppLanguage: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled isGeminiOnboarded profileLinked languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } linkedProfile { pinProtected } } }"""
SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}"""
@@ -0,0 +1,638 @@
import base64
import json
import re
from datetime import datetime, timezone
from http.cookiejar import CookieJar
from typing import List, Optional
from collections.abc import Generator
import click
import jwt
from langcodes import Language
from envied.core.constants import AnyTrack
from envied.core.credential import Credential
from envied.core.manifests import DASH
from envied.core.search_result import SearchResult
from envied.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.tracks import Subtitle, Tracks
class KNPY(Service):
"""
Service code for Kanopy (https://kanopy.com).
www.nostalgic.cc
Authorization: Cookies, Credentials
Security: FHD@L3
Geofence: US, CA, UK, AU, NZ
"""
TITLE_RE = r"^https?://(?:www\.)?kanopy\.com/.+/(?P<id>\d+)$"
GEOFENCE = ()
NO_SUBTITLES = False
@staticmethod
@click.command(name="KNPY", short_help="https://kanopy.com")
@click.argument("title", type=str)
@click.pass_context
def cli(ctx, **kwargs):
return KNPY(ctx, **kwargs)
def __init__(self, ctx, title: str):
super().__init__(ctx)
if not self.config:
raise ValueError("KNPY configuration not found.")
self.cdm = ctx.obj.cdm
match = re.match(self.TITLE_RE, title)
if match:
self.content_id = match.group("id")
else:
self.content_id = None
self.search_query = title
self.API_VERSION = self.config["client"]["api_version"]
self.USER_AGENT = self.config["client"]["user_agent"]
self.WIDEVINE_UA = self.config["client"]["widevine_ua"]
self.session.headers.update({
"x-version": self.API_VERSION,
"user-agent": self.USER_AGENT,
})
subdomain_match = re.search(r'kanopy\.com/[a-z]{2}/([^/]+)', title)
self._subdomain = subdomain_match.group(1) if subdomain_match else None
try:
from pyplayready.cdm import Cdm as PlayReadyCdm
self.use_playready: bool = isinstance(ctx.obj.cdm, PlayReadyCdm)
except ImportError:
self.use_playready = False
self._jwt = None
self._visitor_id = None
self._user_id = None
self._domain_id = None
self.widevine_license_url = None
self.playready_license_url = None
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
if cookies:
jwt_token = None
cookie_visitor_id = None
cookie_uid = None
for cookie in cookies:
if cookie.name == "kapi_token":
jwt_token = cookie.value
elif cookie.name == "visitor_id":
cookie_visitor_id = cookie.value
elif cookie.name == "uid":
cookie_uid = cookie.value
if jwt_token:
self.log.info("Attempting cookie-based authentication.")
self._jwt = jwt_token
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
try:
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
exp_timestamp = decoded_jwt.get("exp")
if exp_timestamp and exp_timestamp < datetime.now(timezone.utc).timestamp():
self.log.warning("Cookie token has expired.")
if credential:
self.log.info("Falling back to credential-based authentication.")
else:
raise ValueError("Cookie token expired and no credentials provided.")
else:
jwt_data = decoded_jwt.get("data", {})
identity_id = jwt_data.get("identity_id")
uid = jwt_data.get("uid")
self._user_id = (identity_id if identity_id and str(identity_id) != "0" else None) \
or (uid if uid and str(uid) != "0" else None) \
or cookie_uid
self._visitor_id = jwt_data.get("visitor_id") or cookie_visitor_id
self.log.info(f"Successfully authenticated via cookies (user_id: {self._user_id or 0})")
self._fetch_user_details()
return
except jwt.DecodeError as e:
self.log.error(f"Failed to decode cookie token: {e}")
if credential:
self.log.info("Falling back to credential-based authentication.")
else:
raise ValueError(f"Invalid kapi_token cookie: {e}")
except KeyError as e:
self.log.error(f"Missing expected field in cookie token: {e}")
if credential:
self.log.info("Falling back to credential-based authentication.")
else:
raise ValueError(f"Invalid kapi_token structure: {e}")
else:
self.log.info("No kapi_token found in cookies.")
if not credential:
raise ValueError("No kapi_token cookie found and no credentials provided.")
self.log.info("Falling back to credential-based authentication.")
if not self._jwt:
if not credential or not credential.username or not credential.password:
raise ValueError("Kanopy requires either cookies (with kapi_token) or email/password for authentication.")
cache = self.cache.get("auth_token")
if cache and not cache.expired:
cached_data = cache.data
valid_token = None
if isinstance(cached_data, dict) and "token" in cached_data:
if cached_data.get("username") == credential.username:
valid_token = cached_data["token"]
self.log.info("Using cached authentication token")
else:
self.log.info(f"Cached token belongs to '{cached_data.get('username')}', but logging in as '{credential.username}'.")
elif isinstance(cached_data, str):
self.log.info("Found legacy cached token format.")
if valid_token:
self._jwt = valid_token
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
if not self._user_id or not self._domain_id or not self._visitor_id:
try:
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
self._user_id = decoded_jwt["data"]["uid"]
self._visitor_id = decoded_jwt["data"]["visitor_id"]
self.log.info("Extracted user_id and visitor_id from cached token.")
self._fetch_user_details()
return
except (KeyError, jwt.DecodeError) as e:
self.log.error(f"Could not decode cached token: {e}.")
self.log.info("Performing handshake to get visitor token.")
r = self.session.get(self.config["endpoints"]["handshake"])
r.raise_for_status()
handshake_data = r.json()
self._visitor_id = handshake_data["visitorId"]
initial_jwt = handshake_data["jwt"]
self.log.info(f"Logging in as {credential.username}.")
login_payload = {
"credentialType": "email",
"emailUser": {
"email": credential.username,
"password": credential.password,
},
}
r = self.session.post(
self.config["endpoints"]["login"],
json=login_payload,
headers={"authorization": f"Bearer {initial_jwt}"},
)
r.raise_for_status()
login_data = r.json()
self._jwt = login_data["jwt"]
self._user_id = login_data["userId"]
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
self.log.info(f"Successfully authenticated as {credential.username}")
self._fetch_user_details()
try:
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
exp_timestamp = decoded_jwt.get("exp")
cache_payload = {"token": self._jwt, "username": credential.username}
if exp_timestamp:
expiration_in_seconds = int(exp_timestamp - datetime.now(timezone.utc).timestamp())
self.log.info(f"Caching token for {expiration_in_seconds / 60:.2f} minutes.")
cache.set(data=cache_payload, expiration=expiration_in_seconds)
else:
self.log.warning("JWT has no 'exp' claim, caching for 1 hour as a fallback.")
cache.set(data=cache_payload, expiration=3600)
except Exception as e:
self.log.error(f"Failed to decode JWT for caching: {e}. Caching for 1 hour as a fallback.")
cache.set(data={"token": self._jwt, "username": credential.username}, expiration=3600)
def _fetch_user_details(self):
if not self._user_id or str(self._user_id) == "0":
if not self._subdomain:
raise ValueError(
"Cannot determine library domain."
)
self.log.info(f"Looking up institution by subdomain: {self._subdomain}")
r = self.session.get(self.config["endpoints"]["institutions"].format(subdomain=self._subdomain))
r.raise_for_status()
inst = r.json()
self._domain_id = str(inst["domainId"])
self.log.info(f"Found library: {inst.get('sitename', self._subdomain)} (domain ID: {self._domain_id})")
return
self.log.info("Fetching user library memberships...")
r = self.session.get(self.config["endpoints"]["memberships"].format(user_id=self._user_id))
r.raise_for_status()
memberships = r.json()
for membership in memberships.get("list", []):
if membership.get("status") == "active" and membership.get("isDefault", False):
self._domain_id = str(membership["domainId"])
self.log.info(f"Using default library: {membership.get('sitename', 'Unknown')} (ID: {self._domain_id})")
return
for membership in memberships.get("list", []):
if membership.get("status") == "active":
self._domain_id = str(membership["domainId"])
self.log.warning(f"No default library found. Using first active domain: {self._domain_id}")
return
if memberships.get("list"):
self._domain_id = str(memberships["list"][0]["domainId"])
self.log.warning(f"No active library found. Using first available domain: {self._domain_id}")
else:
raise ValueError("No library memberships found for this user.")
def get_titles(self) -> Titles_T:
if not self.content_id:
raise ValueError("A content ID is required to get titles.")
if not self._domain_id:
raise ValueError("Domain ID not set.")
r = self.session.get(self.config["endpoints"]["video_info"].format(video_id=self.content_id, domain_id=self._domain_id))
r.raise_for_status()
content_data = r.json()
content_type = content_data.get("type")
def parse_lang(taxonomies_data: dict) -> Language:
try:
langs = taxonomies_data.get("languages", [])
if langs:
lang_name = langs[0].get("name")
if lang_name:
return Language.find(lang_name)
except (IndexError, AttributeError, TypeError):
pass
return Language.get("en")
if content_type == "video":
video_data = content_data["video"]
return Movies([Movie(
id_=str(video_data["videoId"]),
service=self.__class__,
name=video_data["title"],
year=video_data.get("productionYear"),
description=video_data.get("descriptionHtml", ""),
language=parse_lang(video_data.get("taxonomies", {})),
data=video_data,
)])
elif content_type == "playlist":
playlist_data = content_data.get("playlist")
if not playlist_data:
raise ValueError("Could not find 'playlist' data dictionary.")
series_title = playlist_data["title"]
series_year = playlist_data.get("productionYear")
season_match = re.search(r'(?:Season|S)\s*(\d+)', series_title, re.IGNORECASE)
season_num = int(season_match.group(1)) if season_match else 1
r_items = self.session.get(self.config["endpoints"]["video_items"].format(video_id=self.content_id, domain_id=self._domain_id))
r_items.raise_for_status()
items_data = r_items.json()
episodes = []
for i, item in enumerate(items_data.get("list", [])):
if item.get("type") != "video":
continue
video_data = item["video"]
ep_num = i + 1
ep_match = re.search(r'Ep(?:isode)?\.?\s*(\d+)', video_data.get("title", ""), re.IGNORECASE)
if ep_match:
ep_num = int(ep_match.group(1))
episodes.append(Episode(
id_=str(video_data["videoId"]),
service=self.__class__,
title=series_title,
season=season_num,
number=ep_num,
name=video_data["title"],
description=video_data.get("descriptionHtml", ""),
year=video_data.get("productionYear", series_year),
language=parse_lang(video_data.get("taxonomies", {})),
data=video_data,
))
series = Series(episodes)
series.name = series_title
series.description = playlist_data.get("descriptionHtml", "")
series.year = series_year
return series
elif content_type == "collection":
collection_data = content_data.get("collection")
if not collection_data:
raise ValueError("Could not find 'collection' data dictionary.")
series_title_main = collection_data["title"]
series_description_main = collection_data.get("descriptionHtml", "")
series_year_main = collection_data.get("productionYear")
r_seasons = self.session.get(self.config["endpoints"]["video_items"].format(video_id=self.content_id, domain_id=self._domain_id))
r_seasons.raise_for_status()
seasons_data = r_seasons.json()
all_episodes = []
self.log.info(f"Processing collection '{series_title_main}', found {len(seasons_data.get('list', []))} seasons.")
season_counter = 1
for season_item in seasons_data.get("list", []):
if season_item.get("type") != "playlist":
self.log.warning(f"Skipping unexpected item of type '{season_item.get('type')}' in collection.")
continue
season_playlist_data = season_item["playlist"]
season_id = season_playlist_data["videoId"]
season_title = season_playlist_data["title"]
self.log.info(f"Fetching episodes for season: {season_title}")
season_match = re.search(r'(?:Season|S)\s*(\d+)', season_title, re.IGNORECASE)
if season_match:
season_num = int(season_match.group(1))
else:
self.log.warning(f"Could not parse season number from '{season_title}'. Using sequential number {season_counter}.")
season_num = season_counter
season_counter += 1
r_episodes = self.session.get(self.config["endpoints"]["video_items"].format(video_id=season_id, domain_id=self._domain_id))
r_episodes.raise_for_status()
episodes_data = r_episodes.json()
for i, episode_item in enumerate(episodes_data.get("list", [])):
if episode_item.get("type") != "video":
continue
video_data = episode_item["video"]
ep_num = i + 1
ep_match = re.search(r'Ep(?:isode)?\.?\s*(\d+)', video_data.get("title", ""), re.IGNORECASE)
if ep_match:
ep_num = int(ep_match.group(1))
all_episodes.append(Episode(
id_=str(video_data["videoId"]),
service=self.__class__,
title=series_title_main,
season=season_num,
number=ep_num,
name=video_data["title"],
description=video_data.get("descriptionHtml", ""),
year=video_data.get("productionYear", series_year_main),
language=parse_lang(video_data.get("taxonomies", {})),
data=video_data,
))
if not all_episodes:
self.log.error(f"Collection '{series_title_main}' did not show any episodes.")
return Series([])
series = Series(all_episodes)
series.name = series_title_main
series.description = series_description_main
series.year = series_year_main
return series
else:
raise ValueError(f"Unsupported content type: {content_type}")
def get_tracks(self, title: Title_T) -> Tracks:
play_payload = {
"videoId": int(title.id),
"domainId": int(self._domain_id),
"visitorId": self._visitor_id,
}
self.session.headers.setdefault("authorization", f"Bearer {self._jwt}")
self.session.headers.setdefault("x-version", self.API_VERSION)
self.session.headers.setdefault("user-agent", self.USER_AGENT)
r = self.session.post(self.config["endpoints"]["plays"], json=play_payload)
response_json = None
try:
response_json = r.json()
except Exception:
pass
if r.status_code == 403:
if response_json and response_json.get("errorSubcode") == "playRegionRestricted":
self.log.error("This video is not available in your country.")
raise PermissionError(
"Playback blocked by region restriction."
)
else:
self.log.error(f"Access forbidden. Response: {response_json}")
raise PermissionError("Kanopy denied access to this video.")
r.raise_for_status()
play_data = response_json or r.json()
manifest_url = None
manifest_type = None
drm_info = {}
for manifest in play_data.get("manifests", []):
manifest_type_raw = manifest["manifestType"]
url = manifest["url"].strip()
if url.startswith("/"):
url = f"https://www.kanopy.com{url}"
drm_type = manifest.get("drmType")
if manifest_type_raw == "dash":
manifest_url = url
manifest_type = "dash"
if drm_type in ("kanopyDrm", "studioDrm"):
license_id = manifest.get("drmLicenseID") or f"{play_data.get('playId')}-0"
self.widevine_license_url = self.config["endpoints"]["widevine_license"].format(
license_id=license_id
)
self.playready_license_url = self.config["endpoints"]["playready_license"].format(
license_id=license_id
)
else:
self.log.warning(f"Unknown DASH drmType: {drm_type}")
self.widevine_license_url = None
self.playready_license_url = None
break
elif manifest_type_raw == "hls" and not manifest_url:
manifest_url = url
manifest_type = "hls"
if drm_type == "fairplay":
self.log.warning("HLS with FairPlay DRM is not supported.")
self.widevine_license_url = None
drm_info["fairplay"] = True
else:
self.widevine_license_url = None
drm_info["clear"] = True
if not manifest_url:
raise ValueError("Could not find a DASH or HLS manifest for this title.")
if manifest_type == "dash" and not self.widevine_license_url and not self.playready_license_url:
raise ValueError("Could not construct a license URL for DASH manifest.")
self.log.info(f"Fetching {manifest_type.upper()} manifest from: {manifest_url}")
r = self.session.get(manifest_url)
r.raise_for_status()
if manifest_type == "dash":
if not self.use_playready:
import xml.etree.ElementTree as ET
ET.register_namespace('', 'urn:mpeg:dash:schema:mpd:2011')
ET.register_namespace('cenc', 'urn:mpeg:cenc:2013')
ET.register_namespace('mspr', 'urn:microsoft:playready')
root = ET.fromstring(r.text)
for adaptation_set in root.findall('.//{urn:mpeg:dash:schema:mpd:2011}AdaptationSet'):
for cp in list(adaptation_set.findall('{urn:mpeg:dash:schema:mpd:2011}ContentProtection')):
if '9a04f079-9840-4286-ab92-e65be0885f95' in cp.get('schemeIdUri', ''):
adaptation_set.remove(cp)
mpd_text = ET.tostring(root, encoding='unicode')
else:
mpd_text = r.text
tracks = DASH.from_text(mpd_text, url=manifest_url).to_tracks(language=title.language)
elif manifest_type == "hls":
try:
from envied.core.manifests import HLS
tracks = HLS.from_text(r.text, url=manifest_url).to_tracks(language=title.language)
self.log.info("Successfully parsed HLS manifest")
except ImportError:
self.log.error(
"HLS manifest parser not available in envied. "
"Ensure your unshackle installation supports HLS."
)
raise
except Exception as e:
self.log.error(f"Failed to parse HLS manifest: {e}")
raise
else:
raise ValueError(f"Unsupported manifest type: {manifest_type}")
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://www.kanopy.com",
"Referer": "https://www.kanopy.com/",
})
self.session.headers.pop("x-version", None)
self.session.headers.pop("authorization", None)
for caption_data in play_data.get("captions", []):
lang = caption_data.get("language", "en")
label = caption_data.get("label", lang)
slug = label.lower()
slug = re.sub(r'[\s\[\]\(\)]+', '-', slug)
slug = re.sub(r'[^a-z0-9-]', '', slug)
slug = slug.strip('-')
track_id = f"caption-{lang}-{slug}"
for file_info in caption_data.get("files", []):
if file_info.get("type") == "webvtt":
tracks.add(Subtitle(
id_=track_id,
name=label,
url=file_info["url"].strip(),
codec=Subtitle.Codec.WebVTT,
language=Language.get(lang),
))
break
return tracks
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.widevine_license_url:
raise ValueError("Widevine license URL was not set.")
r = self.session.post(
self.widevine_license_url,
data=challenge,
headers={
"Content-Type": "application/octet-stream",
"User-Agent": self.WIDEVINE_UA,
"Authorization": f"Bearer {self._jwt}",
"X-Version": self.API_VERSION,
},
)
r.raise_for_status()
return r.content
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.playready_license_url:
raise ValueError("PlayReady license URL was not set.")
self.log.info(f"Requesting PlayReady license from: {self.playready_license_url}")
r = self.session.post(
self.playready_license_url,
data=challenge,
headers={
"Content-Type": "text/xml; charset=utf-8",
"User-Agent": self.WIDEVINE_UA,
"Authorization": f"Bearer {self._jwt}",
"X-Version": self.API_VERSION,
},
)
self.log.info(f"PlayReady license response: HTTP {r.status_code}")
if not r.ok:
self.log.error(f"PlayReady license error body: {r.text[:500]}")
r.raise_for_status()
return r.content
def search(self) -> Generator[SearchResult, None, None]:
if not hasattr(self, 'search_query') or not self.search_query:
self.log.error("Search query not set.")
return
self.log.info(f"Searching for '{self.search_query}'...")
if not self._domain_id:
self._fetch_user_details()
params = {
"query": self.search_query,
"sort": "relevance",
"domainId": self._domain_id,
"isKids": "false",
"page": 0,
"perPage": 40,
}
r = self.session.get(self.config["endpoints"]["search"], params=params)
r.raise_for_status()
search_data = r.json()
results_list = search_data.get("list", [])
if not results_list:
self.log.warning(f"No results found for '{self.search_query}'")
return
for item in results_list:
video_id = item.get("videoId")
if not video_id:
continue
title = item.get("title", "Unknown Title")
yield SearchResult(
id_=str(video_id),
title=title,
label="VIDEO/SERIES",
url=f"https://www.kanopy.com/video/{video_id}",
)
def get_chapters(self, title: Title_T) -> list:
return []
@@ -0,0 +1,16 @@
client:
api_version: "Android/com.kanopy/6.21.0/952 (SM-A525F; Android 15)"
user_agent: "okhttp/5.2.1"
widevine_ua: "KanopyApplication/6.21.0 (Linux;Android 15) AndroidXMedia3/1.8.0"
endpoints:
handshake: "https://www.kanopy.com/kapi/handshake"
login: "https://www.kanopy.com/kapi/login"
memberships: "https://www.kanopy.com/kapi/memberships?userId={user_id}"
institutions: "https://www.kanopy.com/kapi/institutions/alias/{subdomain}"
video_info: "https://www.kanopy.com/kapi/videos/{video_id}?domainId={domain_id}"
video_items: "https://www.kanopy.com/kapi/videos/{video_id}/items?domainId={domain_id}"
search: "https://www.kanopy.com/kapi/search/videos"
plays: "https://www.kanopy.com/kapi/plays"
widevine_license: "https://www.kanopy.com/kapi/licenses/widevine/{license_id}"
playready_license: "https://www.kanopy.com/kapi/licenses/playready/{license_id}"
@@ -16,8 +16,7 @@ from envied.core.tracks import Chapters, Tracks
class NFBC(Service):
"""
Service code for National Film Board of Canada (https://www.nfb.ca)
Author: n0stal6ic
www.nostalgic.cc
Authorization: None
Geofence: CA, US
"""
@@ -139,4 +138,4 @@ class NFBC(Service):
return tracks
def get_chapters(self, title: Title_T) -> Chapters:
return Chapters()
return Chapters()
@@ -18,8 +18,7 @@ from envied.core.tracks import Chapters, Tracks
class PBS(Service):
"""
Service code for PBS (https://www.pbs.org)
Author: n0stal6ic
www.nostalgic.cc
Authorization: Cookies
Geofence: US
"""
@@ -100,8 +99,53 @@ class PBS(Service):
m3u8_url = self._resolve_encoding(encodings[0])
self.log.debug(f"HLS master: {m3u8_url}")
tracks = HLS.from_url(url=m3u8_url, session=self.session).to_tracks(language=title.language)
kept_subs = []
for sub in tracks.subtitles:
if self._subtitle_has_cues(sub):
kept_subs.append(sub)
else:
self.log.warning(
f" - Dropping empty subtitle track ({sub.language}{' SDH' if sub.sdh else ''})."
)
tracks.subtitles = kept_subs
return HLS.from_url(url=m3u8_url, session=self.session).to_tracks(language=title.language)
return tracks
def _subtitle_has_cues(self, sub: Any) -> bool:
from urllib.parse import urljoin
url = sub.url[0] if isinstance(sub.url, list) and sub.url else sub.url
if not isinstance(url, str):
return True
try:
resp = self.session.get(url, timeout=15)
if not resp.ok:
return True
text = resp.text
except Exception:
return True
if "-->" in text:
return True
if "#EXTM3U" not in text:
return False
checked = 0
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
try:
seg = self.session.get(urljoin(url, line), timeout=15)
except Exception:
return True
if seg.ok and "-->" in seg.text:
return True
checked += 1
if checked >= 40:
break
return False
def get_chapters(self, title: Title_T) -> Chapters:
return Chapters()
@@ -337,4 +381,4 @@ class PBS(Service):
try:
return int(date_str[:4])
except (ValueError, TypeError):
return None
return None
@@ -22,8 +22,7 @@ from envied.core.tracks import Chapters, Tracks, Video
class PCOK(Service):
"""
Service code for Peacock TV (https://peacocktv.com)
Author: n0stal6ic
www.nostalgic.cc
Authorization: Cookies, Credentials
Geofence: US
"""
@@ -35,6 +34,8 @@ class PCOK(Service):
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/tv/[a-z0-9_./-]+/[a-f0-9-]{36})",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/tv/[a-z0-9_./-]+/\d+)",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/news/[a-z0-9_./-]+/[a-f0-9-]{36})",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/sports/[a-z0-9_./-]+/[a-f0-9-]{36})",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/sports/[a-z0-9_./-]+/\d+)",
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/-/[a-z0-9_./-]+/\d+)",
r"(?:https?://(?:www\.)?peacocktv\.com/stream-tv/)?(?P<id>[a-z0-9-]+)$",
]
@@ -42,30 +43,29 @@ class PCOK(Service):
@staticmethod
@click.command(name="PCOK", short_help="https://peacocktv.com")
@click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Title is a movie.")
@click.pass_context
def cli(ctx, **kwargs):
return PCOK(ctx, **kwargs)
def __init__(self, ctx, title: str, movie: bool):
def __init__(self, ctx, title: str):
super().__init__(ctx)
self.title = title
self.movie = movie
self.movie = False
range_param = ctx.parent.params.get("range_")
self.range = range_param[0].name if range_param else "SDR"
vcodec_param = ctx.parent.params.get("vcodec")
self.vcodec = vcodec_param if vcodec_param else "H264"
self.vcodec = vcodec_param[0] if vcodec_param else "h264"
self.profile_name = ctx.parent.params.get("profile") or "default"
prof_key = self.config["client"].get("profile", "tv")
self.prof_key = self.config["client"].get("profile", "tv")
profiles = self.config.get("profiles", {})
if prof_key not in profiles:
raise ValueError(f"Unknown device profile {prof_key!r}. Valid: {list(profiles)}")
self.prof = profiles[prof_key]
if self.prof_key not in profiles:
raise ValueError(f"Unknown device profile {self.prof_key!r}. Valid: {list(profiles)}")
self.prof = profiles[self.prof_key]
self.hmac_key: bytes = self.prof["hmac_key"].encode()
try:
@@ -75,7 +75,6 @@ class PCOK(Service):
self.use_playready = False
self.tokens: Optional[dict] = None
self.license_url: Optional[str] = None
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
@@ -113,9 +112,9 @@ class PCOK(Service):
.get("categoryErrors", [{}])[0]
.get("code", "unknown")
)
raise EnvironmentError(f"Login failed: {code}")
except (ValueError, KeyError, IndexError):
raise EnvironmentError(f"Login failed with HTTP {r.status_code}.")
code = f"HTTP {r.status_code}"
raise EnvironmentError(f"Login failed: {code}")
def _sky_headers(self, extra: Optional[dict] = None) -> dict:
h = {
@@ -165,8 +164,7 @@ class PCOK(Service):
return f'SkyOTT client="{sdk}",signature="{sig}",timestamp="{ts}",version="1.0"'
def _get_tokens(self) -> dict:
prof_key = self.config["client"].get("profile", "tv")
cache_key = f"tokens_{self.profile_name}_{prof_key}"
cache_key = f"tokens_{self.profile_name}_{self.prof_key}"
cache = self.cache.get(cache_key)
if cache and cache.data:
@@ -301,6 +299,7 @@ class PCOK(Service):
name=res["attributes"]["title"],
year=res["attributes"].get("year"),
data=res,
description=res["attributes"].get("synopsis"),
)
])
@@ -320,6 +319,7 @@ class PCOK(Service):
name=ep["attributes"].get("title"),
year=ep["attributes"].get("year"),
data=ep,
description=ep["attributes"].get("synopsis"),
)
for ep in episodes
])
@@ -328,7 +328,7 @@ class PCOK(Service):
attrs = title.data["attributes"]
formats = attrs.get("formats", {})
want_uhd = self.vcodec == "H265"
want_uhd = self.vcodec.lower() in ("hevc", "h.265")
if want_uhd and "UHD" in formats:
content_id = formats["UHD"]["contentId"]
elif "HD" in formats:
@@ -353,11 +353,21 @@ class PCOK(Service):
"container": "ISOBMFF",
"transport": "DASH",
"acodec": "AAC",
"vcodec": self.vcodec,
"vcodec": "H265" if want_uhd else "H264",
}
]
sky_h = self._sky_headers({"X-SkyOTT-UserToken": self.tokens["userToken"]})
sky_h = {
"X-SkyOTT-Agent": ".".join([
self.config["client"]["proposition"],
self.prof["device"],
self.prof["platform"],
]).lower(),
"X-SkyOTT-PinOverride": "false",
"X-SkyOTT-Provider": self.config["client"]["provider"],
"X-SkyOTT-Territory": self.config["client"]["territory"],
"X-SkyOTT-UserToken": self.tokens["userToken"],
}
body = json.dumps(
{
"device": {
@@ -393,7 +403,7 @@ class PCOK(Service):
f"Playout error: {manifest.get('description', 'unknown')} [{manifest['errorCode']}]"
)
self.license_url = manifest["protection"]["licenceAcquisitionUrl"]
license_url = manifest["protection"]["licenceAcquisitionUrl"]
endpoints = manifest["asset"]["endpoints"]
dash_url = next(
@@ -415,17 +425,23 @@ class PCOK(Service):
for audio in tracks.audio:
if audio.language.territory == "AD":
audio.language.territory = None
audio.descriptive = True
audio.language = Language.make(language=audio.language.language)
audio.name = None
for track in tracks:
track.data["license_url"] = license_url
return tracks
def get_chapters(self, title: Title_T) -> Chapters:
return Chapters()
def _license_request(self, challenge: bytes) -> bytes:
path = urlparse(self.license_url).path
def _license_request(self, challenge: bytes, track: AnyTrack) -> bytes:
license_url = track.data["license_url"]
path = urlparse(license_url).path
r = self.session.post(
url=self.license_url,
url=license_url,
data=challenge,
headers={
"X-Sky-Signature": self._sign("POST", path, {}, challenge),
@@ -435,11 +451,11 @@ class PCOK(Service):
return r.content
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
if not self.license_url:
if not track.data.get("license_url"):
return None
return self._license_request(challenge)
return self._license_request(challenge, track)
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
if not self.license_url:
if not track.data.get("license_url"):
return None
return self._license_request(challenge)
return self._license_request(challenge, track)
@@ -27,7 +27,12 @@ client:
endpoints:
login: https://rango.id.peacocktv.com/signin/service/international
personas: https://persona.id.peacocktv.com/persona-store/personas
tokens: https://ovp.peacocktv.com/auth/tokens
me: https://ovp.peacocktv.com/auth/users/me
tokens: https://play.ovp.peacocktv.com/auth/tokens
me: https://play.ovp.peacocktv.com/auth/users/me
node: https://atom.peacocktv.com/adapter-calypso/v3/query/node
vod: https://ovp.peacocktv.com/video/playouts/vod
vod: https://play.ovp.peacocktv.com/video/playouts/vod
legacy_endpoints:
tokens: https://ovp.peacocktv.com/auth/tokens
me: https://ovp.peacocktv.com/auth/users/me
vod: https://ovp.peacocktv.com/video/playouts/vod
@@ -0,0 +1,757 @@
from __future__ import annotations
import base64
import json
import re
import uuid
from http.cookiejar import CookieJar
from typing import Any, Iterable, Optional, Union
import click
from envied.core.constants import AnyTrack
from envied.core.credential import Credential
from envied.core.manifests import DASH
from envied.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.tracks import Chapter, Chapters, Tracks
class PHLO(Service):
"""
Service code for Philo (https://www.philo.com).
www.nostalgic.cc
Authorization: Cookies
Security: FHD@L3
"""
ALIASES = ("PHLO", "philo")
TITLE_RE = (
r"^(?:https?://(?:www\.)?philo\.com/(?:[^?#]+/)?)?"
r"(?P<id>[A-Za-z0-9_\-=]{10,})(?:[/?#].*)?$"
)
LANGUAGE = "en"
CAPABILITIES = [
"COLLECTION_TILE_GROUPS", "HERO_PROMOTION", "MOVIE_SHOWINGS", "GUIDE_FILTERS",
"SEARCH_PAGE_RECS", "UNIFIED_SHOWS_MOVIES_SEARCH_RESULTS", "EXTERNAL_CONTENT",
"SHOW_PAGE_V2", "COLLECTION_GROUPS", "OUT_OF_PLAN_CONTENT", "CHANNEL_TILE_GROUPS_V2",
]
@staticmethod
@click.command(name="PHLO", short_help="https://www.philo.com", help=__doc__)
@click.argument("title", type=str)
@click.option("--no-ads", is_flag=True, default=False,
help="Skip the ad-break chapter markers.")
@click.option("--single", is_flag=True, default=False,
help="Only take the single title the URL points at.")
@click.pass_context
def cli(ctx, **kwargs):
return PHLO(ctx, **kwargs)
def __init__(self, ctx, title: str, no_ads: bool, single: bool):
super().__init__(ctx)
self.title = title
self.no_ads = no_ads
self.single = single
if not self.config:
self.log.error(" - config.yaml is missing or empty")
raise SystemExit(1)
self.timeout = self.config.get("request_timeout") or 30
self.ccextract = bool(int(self.config.get("ccextract") or 0))
self.player_id: Optional[str] = None
self.session_data: dict = {}
self._session_cache: dict[str, dict] = {}
self._manifest_cache: dict[str, Any] = {}
@property
def player_path(self):
return self.cache_dir / "player.json"
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not cookies:
self.log.error(" - Philo needs browser cookies.")
raise SystemExit(1)
self.session.headers.update(self.config.get("headers") or {})
user = self.session.get(self.config["endpoints"]["user"], timeout=self.timeout)
if user.status_code != 200:
self.log.error(f" - Could not read the Philo Account (HTTP {user.status_code}). "
"The cookies may be expired.")
raise SystemExit(1)
subscription = self._graphql("userSubscription", {})
if subscription:
has_access = subscription.get("hasContentAccess")
self.log.info(f" + Subscription: {subscription.get('state', 'Unknown')} "
f"(Access: {'Yes' if has_access else 'No'})")
if has_access is False:
self.log.warning(" - This account has no content access.")
self.player_id = self._register_player()
self.log.info(" + Authenticated with Philo")
def _register_player(self) -> str:
cached = {}
if self.player_path.exists():
try:
cached = json.loads(self.player_path.read_text(encoding="utf-8"))
except Exception as e:
self.log.debug(f"Could not read cached player: {e}")
device_ident = cached.get("deviceIdent") or str(uuid.uuid4())
profile = dict(self.config.get("player") or {})
profile["deviceIdent"] = device_ident
data = self._graphql("registerPlayerV2", profile)
player = (data or {}).get("player") or {}
player_id = player.get("id")
if not player_id:
self.log.error(f" - registerPlayerV2 returned no player id: {json.dumps(data)[:300]}")
raise SystemExit(1)
try:
self.player_path.parent.mkdir(parents=True, exist_ok=True)
self.player_path.write_text(
json.dumps({"deviceIdent": device_ident, "playerId": player_id}, indent=2),
encoding="utf-8")
except Exception as e:
self.log.debug(f"Could not cache player: {e}")
return player_id
def _graphql(self, operation: str, variables: dict, data_key: Optional[str] = None,
soft: bool = False) -> Optional[dict]:
def fail(message: str) -> Optional[dict]:
if soft:
self.log.debug(f"{operation}: {message}")
return None
self.log.error(f" - {message}")
raise SystemExit(1)
queries = self.config.get("persisted_queries") or {}
sha = queries.get(operation)
if not sha:
return fail(f"config.yaml has no persisted_queries.{operation}")
res = self.session.post(
self.config["endpoints"]["graphql"],
json=[{
"operationName": operation,
"variables": variables,
"extensions": {"persistedQuery": {"version": 1, "sha256Hash": sha}},
}],
timeout=self.timeout,
)
if res.status_code != 200:
return fail(f"GraphQL {operation} failed: HTTP {res.status_code} {res.text[:200]}")
try:
payload = res.json()
except Exception as e:
return fail(f"GraphQL {operation} returned non-JSON: {e}")
entry = (payload[0] if isinstance(payload, list) and payload else payload) or {}
for error in entry.get("errors") or []:
code = ((error.get("extensions") or {}).get("code") or "").upper()
if code == "PERSISTED_QUERY_NOT_FOUND":
return fail(f"Philo no longer recognises the {operation} query hash. ")
return fail(f"GraphQL {operation} error: {error.get('message') or error}")
return (entry.get("data") or {}).get(data_key or operation)
_DECODED_ID = re.compile(r"^(?P<kind>[A-Z][A-Za-z0-9]{1,30}):(?P<value>[A-Za-z0-9_.:+/=-]+)$")
@classmethod
def _extract_id(cls, title: str) -> Optional[str]:
text = (title or "").strip()
if not text:
return None
for part in reversed([p for p in re.split(r"[/\\]", text.split("#")[0].split("?")[0]) if p]):
if cls._decode_kind(part):
return part
return None
@classmethod
def _decode_kind(cls, node_id: str) -> Optional[str]:
if not re.fullmatch(r"[A-Za-z0-9_\-=]{10,}", node_id or ""):
return None
match = cls._DECODED_ID.match(cls._decode_node_id(node_id))
return match.group("kind") if match else None
@classmethod
def _decode_value(cls, node_id: str) -> Optional[str]:
match = cls._DECODED_ID.match(cls._decode_node_id(node_id))
return match.group("value") if match else None
@staticmethod
def _decode_node_id(node_id: str) -> str:
try:
padded = node_id + "=" * (-len(node_id) % 4)
return base64.urlsafe_b64decode(padded.encode()).decode("utf-8", errors="ignore")
except Exception:
return ""
@staticmethod
def _first_int(*values: Any) -> Optional[int]:
for value in values:
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _playback_session(self, title_id: str) -> dict:
if title_id in self._session_cache:
return self._session_cache[title_id]
data = self._graphql("createPlaybackSessionV2", {
"id": title_id,
"playerId": self.player_id,
"idfa": None,
"lat": None,
"givn": None,
"tileGroupId": None,
"broadcastAt": None,
"startAtOverride": None,
"isPreload": False,
})
if not data:
self.log.error(f" - No playback session for {title_id}. The title may not be in "
"your plan, or the ID is wrong.")
raise SystemExit(1)
self._session_cache[title_id] = data
return data
def get_titles(self) -> Titles_T:
title_id = self._extract_id(self.title)
if not title_id:
self.log.error(" - Could not find a Philo title ID in that URL. "
"Expected a player link or a Bare ID.")
raise SystemExit(1)
kind = self._decode_kind(title_id) or "?"
self.log.debug(f" + Title ID {title_id} ({kind})")
if kind == "Show" and not self.single:
titles = self._show_titles(title_id)
if titles:
return titles
self.log.debug("Falling back to the playback session for this Show ID.")
return self._single_title(title_id)
def _single_title(self, title_id: str) -> Titles_T:
session = self._playback_session(title_id)
node = session.get("node") or {}
holder = self._presentation(node)
show = holder.get("show") or {}
episode = holder.get("episode") or {}
name = show.get("title") or holder.get("title") or "Unknown"
description = show.get("longDescription") or show.get("shortDescription")
year = self._year(show)
show_type = (show.get("type") or "").upper()
if show_type == "MOVIE" or (not show_type and not episode):
return Movies([
Movie(
id_=title_id,
service=self.__class__,
name=name,
year=year,
description=description,
language=self.LANGUAGE,
data={"session": session},
)
])
season = self._first_int(episode.get("seasonNum"), episode.get("seasonNumber"), episode.get("season"))
number = self._first_int(episode.get("episodeNum"), episode.get("episodeNumber"), episode.get("number"))
air_date = episode.get("originalAirDate") or holder.get("startsAt")
return Series([
Episode(
id_=title_id,
service=self.__class__,
title=name,
season=season or 0,
number=number or 0,
name=episode.get("subtitle") or episode.get("title") or episode.get("name"),
description=episode.get("longDescription") or description,
year=year,
air_date=air_date if number is None else None,
language=self.LANGUAGE,
data={"session": session},
)
])
@staticmethod
def _presentation(node: dict) -> dict:
current = node or {}
for _ in range(4):
if current.get("show") or current.get("episode"):
return current
for key in ("broadcast", "recording", "vod", "presentation", "node"):
nested = current.get(key)
if isinstance(nested, dict):
current = nested
break
else:
break
return current or {}
@staticmethod
def _year(show: dict) -> Optional[int]:
for key in ("movieReleaseYear", "releaseYear", "year", "originalAirDate"):
value = show.get(key)
if not value:
continue
match = re.search(r"(\d{4})", str(value))
if match:
return int(match.group(1))
return None
_SEASON_FILTER = re.compile(r"^SeasonPageFilter:(?P<show>[^:]+):(?P<season>-?\d+)$")
def _show_titles(self, show_id: str) -> Optional[Titles_T]:
page = self._graphql("page", {
"pageType": "SHOW",
"typeId": show_id,
"filterId": None,
"filter": None,
"sorterId": None,
"endCursor": None,
"startCursor": None,
"firstGroups": 5,
"initialTiles": 12,
"lastGroups": None,
"numSparseGroups": None,
"includeTileChannel": False,
"iconFormat": "SVG",
"capabilities": self.CAPABILITIES,
"startTime": None,
"endTime": None,
}, soft=True)
if not page:
return None
tile = page.get("tile") or {}
show = tile.get("show") or {}
show_type = (show.get("type") or "").upper()
name = page.get("title") or tile.get("title") or show.get("title") or "Unknown"
description = (page.get("node") or {}).get("longDescription") or tile.get("longDescription")
year = self._year(show)
if show_type == "MOVIE":
return Movies([
Movie(
id_=tile.get("playableAssetId") or show_id,
service=self.__class__,
name=name,
year=year,
description=description,
language=self.LANGUAGE,
)
])
episodes = self._season_episodes(show_id, page, name, year, description)
if not episodes:
return None
seasons = sorted({episode.season for episode in episodes})
self.log.info(f" + {len(episodes)} episodes across {len(seasons)} "
f"season{'s'[:len(seasons) ^ 1]}")
return Series(episodes)
def _season_episodes(self, show_id: str, page: dict, name: str, year: Optional[int],
description: Optional[str]) -> list[Episode]:
show_value = self._decode_value(show_id)
episodes: list[Episode] = []
seen: set[str] = set()
seasons: list[int] = []
for season_filter in page.get("filters") or []:
match = self._SEASON_FILTER.match(self._decode_node_id(season_filter.get("id") or ""))
if not match:
continue
season = int(match.group("season"))
if season < 0 or not season_filter.get("hasPlayablePresentations"):
continue
show_value = show_value or match.group("show")
seasons.append(season)
for season in sorted(seasons):
if not show_value:
break
group = self._graphql("tileGroup", {
"tileGroupId": self._season_tile_group_id(show_value, season),
"initialTiles": 100,
"includeTileChannel": False,
"iconFormat": "SVG",
"startTime": None,
"endTime": None,
}, data_key="node", soft=True)
tiles = self._all_tiles(group)
if not tiles:
self.log.debug(f"Season {season} returned no tiles.")
continue
episodes += self._episodes_from_tiles(tiles, name, year, description, seen)
if not episodes:
for edge in ((page.get("groups") or {}).get("edges") or []):
node = edge.get("node") or {}
if (node.get("type") or "").upper() != "PLAYABLE":
continue
if "Season" not in self._tile_group_name(node.get("id") or ""):
continue
episodes += self._episodes_from_tiles(
self._all_tiles(node), name, year, description, seen)
return episodes
def _all_tiles(self, group: Optional[dict]) -> list[dict]:
tiles = (group or {}).get("tiles") or {}
nodes = [edge.get("node") or {} for edge in (tiles.get("edges") or [])]
page_info = tiles.get("pageInfo") or {}
cursor = page_info.get("endCursor")
while page_info.get("hasNextPage") and cursor:
more = self._graphql("tiles", {
"endCursor": cursor,
"startCursor": None,
"first": 50,
"last": None,
"initialCursor": None,
"includeTileDescription": True,
"iconFormat": "SVG",
}, soft=True)
if not more:
break
nodes += [edge.get("node") or {} for edge in (more.get("edges") or [])]
page_info = more.get("pageInfo") or {}
next_cursor = page_info.get("endCursor")
if next_cursor == cursor:
break
cursor = next_cursor
return nodes
def _episodes_from_tiles(self, tiles: Iterable[dict], name: str, year: Optional[int],
description: Optional[str], seen: set[str]) -> list[Episode]:
episodes = []
for tile in tiles:
asset = tile.get("playableAssetId")
if not asset or not tile.get("hasPlayable") or tile.get("isInPlan") is False:
continue
if asset in seen:
continue
seen.add(asset)
episode = tile.get("episode") or {}
season = self._first_int(episode.get("seasonNum"), episode.get("seasonNumber"))
number = self._first_int(episode.get("episodeNum"), episode.get("episodeNumber"))
episodes.append(Episode(
id_=asset,
service=self.__class__,
title=tile.get("title") or name,
season=season or 0,
number=number or 0,
name=tile.get("subtitle"),
description=tile.get("longDescription") or tile.get("description") or description,
year=year,
air_date=episode.get("originalAirDate") if number is None else None,
language=self.LANGUAGE,
))
return episodes
@staticmethod
def _season_tile_group_id(show_value: str, season: int) -> str:
inner = json.dumps(
{"name": "Season V2", "id": f"{show_value}:{season}", "includeExternalAssets": True},
separators=(",", ":"))
encoded = base64.urlsafe_b64encode(inner.encode()).decode().rstrip("=")
return base64.urlsafe_b64encode(f"TileGroup:{encoded}".encode()).decode().rstrip("=")
@classmethod
def _tile_group_name(cls, tile_group_id: str) -> str:
decoded = cls._decode_node_id(tile_group_id)
if not decoded.startswith("TileGroup:"):
return ""
try:
return json.loads(cls._decode_node_id(decoded.split(":", 1)[1])).get("name") or ""
except Exception:
return ""
def get_tracks(self, title: Title_T) -> Tracks:
title_id = str(title.id)
session = self._playback_session(title_id)
dash_url = session.get("dashURL")
if not dash_url:
self.log.error(" - The playback session carried no dashURL. Returns: "
f"hlsURL/dashJSONURL: {sorted(session)}")
raise SystemExit(1)
self.session_data = session.get("drmProvider") or {}
manifest_text = self._fetch_manifest(dash_url)
dash = DASH.from_text(manifest_text, dash_url) if manifest_text \
else DASH.from_url(url=dash_url, session=self.session)
self._manifest_cache[title_id] = dash.manifest
ad_periods = self._ad_period_ids(dash.manifest, session)
tracks = dash.to_tracks(
language=title.language or self.LANGUAGE,
period_filter=(lambda period: (period.get("id") or "").strip() in ad_periods)
if ad_periods else None,
)
if ad_periods:
for track in tracks:
data = track.data.get("dash")
if isinstance(data, dict):
data["filtered_period_ids"] = sorted(ad_periods)
self.log.info(f" + Dropped {len(ad_periods)} ad periods")
for track in tracks.audio:
track.language = track.language or title.language or self.LANGUAGE
if not self.ccextract:
for track in tracks.videos:
self._disable_ccextractor(track)
self.log.info(" + Closed captions disabled.")
return tracks
def _disable_ccextractor(self, track) -> None:
track.closed_captions = []
def skipped(*_: Any, **__: Any) -> None:
self.log.debug(f"ccextractor skipped for {getattr(track, 'id', '?')} ")
return None
track.ccextractor = skipped
def _fetch_manifest(self, dash_url: str) -> Optional[str]:
try:
res = self.session.get(dash_url, timeout=self.timeout)
if res.status_code != 200:
self.log.debug(f"Could not fetch the MPD: HTTP {res.status_code}")
return None
except Exception as e:
self.log.debug(f"Could not fetch the MPD: {e}")
return None
try:
path = self.cache_dir / "last_manifest.mpd"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(res.text, encoding="utf-8")
self.log.debug(f" + Saved MPD to {path}")
except Exception as e:
self.log.debug(f"Could not save the MPD: {e}")
return res.text
_AD_PERIOD_ID = re.compile(r"^\d+(?:\.\d+)+$")
def _ad_period_ids(self, manifest, session: dict) -> set:
periods = manifest.findall("Period")
if len(periods) < 2:
return set()
windows = self._ad_windows(session)
break_ids = self._ad_break_ids(session)
protected = [bool(period.xpath(".//ContentProtection")) for period in periods]
any_protected = any(protected)
ads: set = set()
content = 0
for period, is_protected in zip(periods, protected):
period_id = (period.get("id") or "").strip()
start = self._period_start(period)
reason = None
if period_id and self._AD_PERIOD_ID.match(period_id):
reason = "dotted period id"
elif period_id and "." in period_id and period_id.split(".")[0] in break_ids:
reason = "adBreaks id"
elif any_protected and not is_protected:
reason = "no ContentProtection"
elif start is not None and any(s - 0.5 <= start < e - 0.5 for s, e in windows):
reason = "adBreaks window"
if not reason:
content += 1
continue
if not period_id:
self.log.debug(f"Ad period at {start} has no ID and can't be filtered ({reason}).")
content += 1
continue
self.log.debug(f"Period {period_id} is an ad ({reason}).")
ads.add(period_id)
if not content:
self.log.warning(" - Every MPD period seems like an ad.")
return set()
return ads
@staticmethod
def _ad_break_ids(session: dict) -> set:
breaks = ((session.get("manifestMetadata") or {}).get("adBreaks")) or []
return {str(b.get("id")) for b in breaks if b.get("id") is not None}
@staticmethod
def _ad_windows(session: dict) -> list:
breaks = ((session.get("manifestMetadata") or {}).get("adBreaks")) or []
windows = []
for ad in breaks:
start, end = ad.get("start"), ad.get("end")
if start is None or end is None:
continue
windows.append((float(start), float(end)))
return windows
_ISO8601 = re.compile(
r"^P(?:(?P<d>[\d.]+)D)?T?(?:(?P<h>[\d.]+)H)?(?:(?P<m>[\d.]+)M)?(?:(?P<s>[\d.]+)S)?$")
@classmethod
def _duration(cls, raw: Optional[str]) -> Optional[float]:
match = cls._ISO8601.match((raw or "").strip())
if not match:
return None
parts = match.groupdict()
if not any(parts.values()):
return None
return (float(parts["d"] or 0) * 86400 + float(parts["h"] or 0) * 3600
+ float(parts["m"] or 0) * 60 + float(parts["s"] or 0))
@classmethod
def _period_start(cls, period) -> Optional[float]:
return cls._duration(period.get("start"))
def get_chapters(self, title: Title_T) -> Chapters:
if self.no_ads:
return Chapters()
title_id = str(title.id)
session = self._playback_session(title_id)
manifest = self._manifest_cache.get(title_id)
if manifest is not None:
chapters = self._chapters_from_periods(manifest, session)
if chapters is not None:
return chapters
return self._chapters_from_ad_breaks(session)
def _chapters_from_periods(self, manifest, session: dict) -> Optional[Chapters]:
periods = manifest.findall("Period")
if len(periods) < 2:
return None
ad_periods = self._ad_period_ids(manifest, session)
if not ad_periods:
return None
starts = []
for index, period in enumerate(periods):
start = self._period_start(period)
starts.append(0.0 if start is None and index == 0 else start)
if any(start is None for start in starts):
return None
total = self._duration(manifest.get("mediaPresentationDuration"))
marks: list[float] = []
elapsed = 0.0
previous_was_ad = False
for index, period in enumerate(periods):
end = starts[index + 1] if index + 1 < len(periods) else total
is_ad = (period.get("id") or "").strip() in ad_periods
if is_ad:
if not previous_was_ad and elapsed > 0:
marks.append(elapsed)
elif end is not None:
elapsed += max(0.0, end - starts[index])
previous_was_ad = is_ad
marks = [mark for mark in marks if mark < elapsed]
if not marks:
return None
chapters = Chapters()
for index, mark in enumerate(marks, 1):
chapters.add(Chapter(timestamp=mark, name=f"Ad Break {index}"))
return chapters
def _chapters_from_ad_breaks(self, session: dict) -> Chapters:
breaks = ((session.get("manifestMetadata") or {}).get("adBreaks")) or []
chapters = Chapters()
removed = 0.0
for index, ad in enumerate(breaks, 1):
start, end = ad.get("start"), ad.get("end")
if start is None:
continue
chapters.add(Chapter(timestamp=max(0.0, float(start) - removed),
name=f"Ad Break {index}"))
if end is not None:
removed += float(end) - float(start)
return chapters
def get_widevine_service_certificate(self, **_: Any) -> Optional[str]:
return self.config.get("certificate")
def get_widevine_license(self, *, challenge: bytes, title: Title_T,
track: AnyTrack = None, **_) -> Optional[Union[bytes, str]]:
drm = {}
if title is not None:
drm = self._playback_session(str(title.id)).get("drmProvider") or {}
drm = drm or self.session_data
license_url = next(
(s.get("licenseURL") for s in (drm.get("drmSystems") or [])
if (s.get("system") or "").upper() == "WIDEVINE" and s.get("licenseURL")),
self.config["endpoints"].get("widevine_license"),
)
auth_token = drm.get("authToken")
if not auth_token:
self.log.error(" - The playback session carried no DRMtoday auth token.")
raise SystemExit(1)
res = self.session.post(
license_url,
data=challenge,
headers={"x-dt-auth-token": auth_token, "content-type": "application/octet-stream"},
timeout=self.timeout,
)
if res.status_code != 200:
raise ValueError(f"Widevine licence denied: HTTP {res.status_code} {res.text[:300]}")
try:
payload = res.json()
except Exception:
return res.content
if payload.get("status") not in (None, "OK", "SUCCESS"):
raise ValueError(f"Widevine licence denied by DRMtoday: {json.dumps(payload)[:300]}")
if not payload.get("license"):
raise ValueError(f"No licence in DRMtoday response: {json.dumps(payload)[:300]}")
return payload["license"]
@@ -0,0 +1,53 @@
headers:
user-agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0"
origin: "https://www.philo.com"
referer: "https://www.philo.com/"
accept: "*/*"
accept-language: "en-US,en;q=0.9"
request_timeout: 30
ccextract: 0 # 1
endpoints:
graphql: "https://www.philo.com/graphql"
user: "https://www.philo.com/user"
profiles: "https://www.philo.com/user/profiles/list.json"
geo: "https://content-us-east-2-fastly-b.www.philo.com/geo"
widevine_license: "https://lic.drmtoday.com/license-proxy-widevine/cenc/"
persisted_queries:
createPlaybackSessionV2: "2ef315fa06929d36f1a1b332f3a46fe4edf296ad5f13491eb0329b55c06ac432"
registerPlayerV2: "8312f5c234270aa2de0f199e79667ca23e41ac6e23d8f4bc31d3007702fbe9a9"
endPlaybackSession: "3d4ae806437944478011172aac5dffb92f59cece3670b635f8246fc2d6f1100d"
sessionStatus: "6b3d0f3dbb1ef4870d38442103b1cc73a11ba3915fd93457503d6494ca6ebe34"
userSubscription: "990a8b451f87cf3cca1ce78ae3895ebc12c87f5082f48d61249ab086e2b68c10"
nextPresentations: "c0b92cd127fc00d9b837ecda68f72ff42666628e60591748dc554c1ad749f1c2"
previousPresentation: "c6438f893682924f68e5c8974a2cf383d5470a80fc872e12208521ae8e4b4e2e"
page: "0dccb4f56182daa1949f520f9d151e8af0b1e7b55a6fcad85cc4a98991ca1ad5"
tileGroup: "f976fca086cd18496de0d4b92eed6c961ac1139c139cbc47f901e0f68e451115"
tiles: "a4d626992eeede79db8afc3c7d5025af1259379f5a5f5821094e99596b553102"
hasFollow: "5706102469c7213170c799f5bc106904b038d9ff2d525a0d0ddd5b63cdf12c3f"
preferences: "6bf97e2467f3b8d1fd038e0a8cc717f6dfd3d243c5bcc33fe456999a2c2ddacb"
notifications: "71541212b5a04cbef9f483e81c38b6469010c18e4c34dfc1634d48af6a50541c"
fetchRemotePlayers: "b1abf2e8cf3bafe4e3db7ed5c53821abe2ee46d018f4aef14111ef29b8d60c24"
assignExperiment: "64e8cfaf6a2f468aae6fe2aa7480ab158a0fe0ec8a3570bbbff7fff0435da111"
clickTile: "2303363eb24a09b6a8bc52682f7f251f2d41b6e0f5659390358d386599ae6dfe"
UpdatePlayheadV2: "b28b78473f96bc3ea19f64913486264784517f022a206ef6e0370f1e914bc377"
partnerActivationDetails: "ce5020ab7475567b48600be5d7c852c15ecf4934b7313e5db002187920e7abc0"
userSubscriptionQuery: "861aaf9e182476668c8f67356943670484bf9e1d684b11e2139c4997827deef0"
player:
deviceType: "WEB"
deviceIcon: "PHONE"
deviceName: "Firefox on Windows"
deviceModel: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0"
deviceManufacturer: ""
applicationVersion: "2026.7.23-1386250"
osVersion: "153.0.0"
captionsEnabled: false
volume: 0.75
properties:
- name: "supportsChunkLoading"
value: "true"
certificate: null
@@ -0,0 +1,505 @@
from __future__ import annotations
import base64
import hashlib
import re
import time
from http.cookiejar import CookieJar
from typing import Any, Optional
import click
import requests
from envied.core.config import config
from envied.core.credential import Credential
from envied.core.music import MusicTrackOption
from envied.core.service import Service
from envied.core.titles import Music, Song, Titles_T
from envied.core.tracks import Audio, Chapters, Tracks
from envied.core.tracks.track import Track
class QOBZ(Service):
"""
Service code for Qobuz (https://qobuz.com)
www.nostalgic.cc
Authorization: Credentials, Tokens
Security: None
"""
ALIASES = ("QOBZ", "qobuz")
GROUP_AUDIO_DOWNLOADS = True
TITLE_RE = r"^(?:https?://(?:www\.|open\.|play\.)?qobuz\.com/(?:[a-z]{2}-[a-z]{2}/)?(?P<type>album|track|playlist|interpreter|artist|label)/(?:[^/]+/)*)?(?P<id>[A-Za-z0-9]+)"
FORMATS = {
5: ("MP3", "MP3 320 kb/s"),
6: ("FLAC", "FLAC 16-bit/44.1kHz"),
7: ("FLAC", "FLAC 24-bit ≤96kHz"),
27: ("FLAC", "FLAC 24-bit ≤192kHz"),
}
QUALITY_MAP = {
"MP3": 5, "CD": 6, "HIFI": 7, "HIRES": 27,
"5": 5, "6": 6, "7": 7, "27": 27,
}
@staticmethod
@click.command(name="QOBZ", short_help="https://qobuz.com", help=__doc__)
@click.argument("title", type=str)
@click.option("-q", "--quality", "quality",
type=click.Choice(["MP3", "CD", "HIFI", "HIRES", "5", "6", "7", "27"], case_sensitive=False),
default=None,
help="Quality: MP3/5=MP3 320, CD/6=FLAC 16/44.1, HIFI/7=FLAC 24/96, "
"HIRES/27=FLAC 24/192 (default).")
@click.pass_context
def cli(ctx, **kwargs):
return QOBZ(ctx, **kwargs)
def __init__(self, ctx, title: str, quality: Optional[str]):
super().__init__(ctx)
self.title = title
if quality:
self.quality = self.QUALITY_MAP[quality.upper()]
else:
self.quality = int(self.config.get("default_format_id", 27))
self.app_id: Optional[str] = None
self.secrets: list[str] = []
self.valid_secret: Optional[str] = None
self.user_auth_token: Optional[str] = None
m = re.search(self.TITLE_RE, self.title)
self.item_type = (m.group("type") if m and m.group("type") else "album")
self.item_id = m.group("id") if m else self.title
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
self.session.headers.update({"User-Agent": self.config["user_agent"]})
self.app_id = str(self.config.get("app_id") or "").strip() or None
self.secrets = [s for s in (self.config.get("secrets") or []) if s]
if not self.app_id or not self.secrets:
spoofed_id, spoofed_secrets = self._spoof_app_credentials()
self.app_id = self.app_id or spoofed_id
if not self.secrets:
self.secrets = spoofed_secrets
if not self.app_id:
self.log.error(" - Could not find a Qobuz app_id."); raise SystemExit(1)
self.session.headers["X-App-Id"] = self.app_id
token = None
if credential:
user = (credential.username or "").strip()
pw = (credential.password or "").strip()
if user.lower() in ("token", "auth", "authtoken", "user_auth_token"):
token = pw
elif user and pw:
token = self._login(user, pw)
elif pw and not user:
token = pw
elif user and not pw:
token = None if "@" in user else user
if not token:
for key in ("user_auth_token", "token", "auth_token"):
value = str(self.config.get(key) or "").strip()
if value:
token = value
break
if not token and cookies:
for cookie in cookies:
if cookie.name in ("X-User-Auth-Token", "user_auth_token", "qobuz_token"):
token = cookie.value
break
if not token:
self.log.error(
" - No Qobuz auth. Set it in your unshackle config under "
"services: QOBZ: token: \"YOUR_AUTH_TOKEN\", or use "
"credentials as 'token:YOUR_AUTH_TOKEN' or 'email:password'."
)
raise SystemExit(1)
self.user_auth_token = token
self.session.headers["X-User-Auth-Token"] = token
self.log.info(" + Authenticated with Qobuz")
def _login(self, email: str, password: str) -> str:
login_app_id, _ = self._spoof_app_credentials()
login_app_id = login_app_id or self.app_id
self.log.info(f" + Logging in with app_id={login_app_id}")
attempts = [("plain", password), ("md5", hashlib.md5(password.encode()).hexdigest())]
for label, pwd in attempts:
resp = self.session.get(
self.config["base_url"] + "user/login",
params={"email": email, "password": pwd, "app_id": login_app_id},
headers={"X-App-Id": login_app_id},
)
self.log.info(f" login[{label}] HTTP {resp.status_code}: {resp.text[:160]}")
if resp.status_code == 200:
data = resp.json()
token = data.get("user_auth_token")
if token:
self.log.info(f" + Logged in as {data.get('user', {}).get('display_name', email)}")
return token
self.log.error(
" - Qobuz password login failed. If it's a 401 'User authentication is required', the "
"login app_id couldn't be extracted from the web bundle. Use a bare user_auth_token in "
"envied.yaml credentials instead of email:password."
)
raise SystemExit(1)
def _spoof_app_credentials(self) -> tuple[Optional[str], list[str]]:
try:
login_page = self.session.get(self.config["web_url"] + "/login").text
bundle_match = re.search(
r'<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>', login_page
)
if not bundle_match:
self.log.warning(" - Could not locate Qobuz bundle.js for credential extraction.")
return None, []
bundle = self.session.get(self.config["web_url"] + bundle_match.group(1)).text
app_id_match = re.search(r'production:\{api:\{appId:"(\d+)",appSecret:', bundle)
app_id = app_id_match.group(1) if app_id_match else None
seeds: dict[str, str] = {}
for m in re.finditer(
r'[a-z]\.initialSeed\("([\w=]+)",window\.utimezone\.(?P<tz>[a-z]+)\)', bundle
):
seeds[m.group("tz").capitalize()] = m.group(1)
secrets: list[str] = []
for m in re.finditer(
r'name:"\w+/(?P<tz>[A-Z][a-z]+)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"', bundle
):
tz = m.group("tz")
if tz not in seeds:
continue
combined = seeds[tz] + m.group("info") + m.group("extras")
try:
secret = base64.standard_b64decode(combined[:-44]).decode("utf-8")
if secret:
secrets.append(secret)
except Exception:
continue
self.log.debug(f" + Extracted app_id={app_id}, {len(secrets)} secret(s)")
return app_id, secrets
except Exception as e:
self.log.warning(f" - Qobuz credential extraction failed: {e}")
return None, []
def _api(self, endpoint: str, params: dict, allow_error: bool = False) -> Optional[dict]:
resp = self.session.get(self.config["base_url"] + endpoint, params=params)
if resp.status_code != 200:
if allow_error:
return None
self.log.error(f" - Qobuz API error on {endpoint}: {resp.status_code} {resp.text[:200]}")
raise SystemExit(1)
return resp.json()
def get_titles(self) -> Titles_T:
if self.item_type == "track":
track = self._api("track/get", params={"track_id": self.item_id})
album = self._api("album/get", params={"album_id": track["album"]["id"]})
songs = [self._build_song(track, album)]
return self._build_music(album, songs, kind="single")
if self.item_type == "playlist":
return self._get_playlist()
album = self._get_album_full(self.item_id)
tracks = album.get("tracks", {}).get("items", [])
songs = [self._build_song(t, album) for t in tracks]
return self._build_music(album, songs, kind=self._release_kind(album))
def _get_album_full(self, album_id: str) -> dict:
album = self._api("album/get", params={"album_id": album_id, "limit": 500, "offset": 0})
items = album.get("tracks", {}).get("items", [])
total = album.get("tracks", {}).get("total", len(items))
offset = 500
while len(items) < total:
page = self._api("album/get", params={"album_id": album_id, "limit": 500, "offset": offset})
page_items = page.get("tracks", {}).get("items", [])
if not page_items:
break
items.extend(page_items)
offset += 500
album.setdefault("tracks", {})["items"] = items
return album
def _get_playlist(self) -> Music:
playlist = self._api("playlist/get", params={
"playlist_id": self.item_id, "extra": "tracks", "limit": 500, "offset": 0,
})
items = playlist.get("tracks", {}).get("items", [])
total = playlist.get("tracks", {}).get("total", len(items))
offset = 500
while len(items) < total:
page = self._api("playlist/get", params={
"playlist_id": self.item_id, "extra": "tracks", "limit": 500, "offset": offset,
})
page_items = page.get("tracks", {}).get("items", [])
if not page_items:
break
items.extend(page_items)
offset += 500
songs = []
for position, track in enumerate(items, start=1):
album = track.get("album") or {}
song = self._build_song(track, album, playlist_position=position)
songs.append(song)
music = Music(
songs,
kind="playlist",
title=playlist.get("name"),
artist=(playlist.get("owner") or {}).get("name"),
total_tracks=len(songs) or None,
owner=(playlist.get("owner") or {}).get("name"),
description=(playlist.get("description") or None),
)
return music
def _build_music(self, album: dict, songs: list[Song], kind: str) -> Music:
artwork = self._cover_url(album)
year = self._year(album)
return Music(
songs,
kind=kind,
title=self._album_title(album),
artist=(album.get("artist") or {}).get("name"),
year=year,
total_tracks=album.get("tracks_count") or (len(songs) or None),
total_discs=album.get("media_count") or None,
artwork_url=artwork,
total_duration=int(album.get("duration") or 0) or None,
)
def _build_song(self, track: dict, album: dict, playlist_position: Optional[int] = None) -> Song:
album = album or track.get("album") or {}
album_artist = (album.get("artist") or {}).get("name") or "Various Artists"
performer = (track.get("performer") or {}).get("name") or album_artist
year = self._year(album) or self._year(track) or 1
artwork = self._cover_url(album)
release_date = self._release_date(album) or self._release_date(track)
title = track.get("title") or ""
if track.get("version"):
title = f"{title.strip()} ({track['version']})"
genre = (album.get("genre") or {}).get("name")
label = (album.get("label") or {}).get("name")
composer = (track.get("composer") or {}).get("name")
data = {
"service": self.ALIASES[0],
"source": self.ALIASES[0],
"track_id": str(track.get("id")),
"album_id": str(album.get("id")) if album.get("id") else None,
"track_url": track.get("url"),
"album_url": album.get("url"),
"title": title,
"artist": performer,
"album": self._album_title(album),
"album_artist": album_artist,
"performer": performer,
"composer": composer,
"track_number": track.get("track_number"),
"total_tracks": album.get("tracks_count"),
"disc_number": track.get("media_number") or 1,
"total_discs": album.get("media_count") or 1,
"release_date": release_date,
"year": year,
"genre": genre,
"label": label,
"isrc": track.get("isrc"),
"upc": album.get("upc"),
"copyright": album.get("copyright"),
"explicit": bool(track.get("parental_warning")),
"duration": track.get("duration"),
"channels": 2,
"artwork_url": artwork,
"quality": self.FORMATS.get(self.quality, ("", ""))[1],
"maximum_bit_depth": track.get("maximum_bit_depth") or album.get("maximum_bit_depth"),
"maximum_sampling_rate": track.get("maximum_sampling_rate") or album.get("maximum_sampling_rate"),
}
if config.tag:
data["comment"] = config.tag
return Song(
id_=str(track.get("id")),
service=self.__class__,
name=title or "Unknown",
artist=performer,
album=self._album_title(album) or "Unknown Album",
track=int(playlist_position or track.get("track_number") or 1),
disc=int(track.get("media_number") or 1),
year=int(year),
album_artist=album_artist,
release_type=self._release_kind(album),
total_tracks=int(album["tracks_count"]) if album.get("tracks_count") else None,
total_discs=int(album["media_count"]) if album.get("media_count") else None,
genre=genre,
explicit=bool(track.get("parental_warning")),
isrc=track.get("isrc") or None,
upc=str(album.get("upc")) if album.get("upc") else None,
copyright=album.get("copyright") or None,
label=label,
artwork_url=artwork,
data=data,
)
def get_music_track_options(self, song: Song) -> list[MusicTrackOption]:
data = song.data if isinstance(song.data, dict) else {}
codec = self.FORMATS.get(self.quality, ("FLAC", ""))[0]
bit_depth = None
sample_rate = None
if self.quality != 5:
max_bd = data.get("maximum_bit_depth")
max_sr = data.get("maximum_sampling_rate")
bit_depth = min(int(max_bd or 24), 16 if self.quality == 6 else 24)
sr_cap = {6: 44.1, 7: 96.0, 27: 192.0}.get(self.quality, 192.0)
sample_rate = int(min(float(max_sr or sr_cap), sr_cap) * 1000)
hires = bool(bit_depth and bit_depth > 16) or bool(sample_rate and sample_rate > 48000)
return [MusicTrackOption(
codec=codec,
bit_depth=bit_depth,
sample_rate=sample_rate,
bitrate=320000 if self.quality == 5 else None,
channels=2.0,
lossless=self.quality != 5,
hires=hires,
explicit=bool(data.get("explicit")),
duration=int(data["duration"]) if data.get("duration") else None,
quality_label=self.FORMATS.get(self.quality, ("", ""))[1],
)]
def get_tracks(self, title: Song) -> Tracks:
track_id = str(title.id)
file_info = self._get_file_url(track_id, self.quality)
url = file_info.get("url")
if not url:
self.log.error(f" - No file URL for track {track_id}."); raise SystemExit(1)
actual_format = int(file_info.get("format_id") or self.quality)
is_mp3 = actual_format == 5 or (file_info.get("mime_type") or "").endswith("mpeg")
codec = Audio.Codec.FLAC if not is_mp3 else None
extension = "mp3" if is_mp3 else "flac"
bit_depth = file_info.get("bit_depth")
sampling_rate = file_info.get("sampling_rate")
bitrate = 320000 if is_mp3 else None
audio = Audio(
url,
language=title.language or "en",
codec=codec,
bitrate=bitrate,
channels=2,
descriptor=Track.Descriptor.URL,
id_=track_id,
data={
"qobuz_ext": extension,
"bit_depth": bit_depth,
"sampling_rate": sampling_rate,
},
)
return Tracks([audio])
def get_chapters(self, title: Song) -> Chapters:
return Chapters()
def _get_file_url(self, track_id: str, format_id: int) -> dict:
secrets_to_try = ([self.valid_secret] if self.valid_secret else []) + [
s for s in self.secrets if s != self.valid_secret
]
last_error = ""
for secret in secrets_to_try:
ts = int(time.time())
sig_str = f"trackgetFileUrlformat_id{format_id}intentstreamtrack_id{track_id}{ts}{secret}"
request_sig = hashlib.md5(sig_str.encode()).hexdigest()
resp = self.session.get(
self.config["base_url"] + "track/getFileUrl",
params={
"request_ts": ts,
"request_sig": request_sig,
"track_id": track_id,
"format_id": format_id,
"intent": "stream",
},
)
if resp.status_code == 200:
data = resp.json()
if data.get("url"):
self.valid_secret = secret
return data
last_error = data.get("message", "no url in response")
else:
last_error = f"HTTP {resp.status_code}: {resp.text[:120]}"
self.log.error(f" - Failed to get file URL for track {track_id}: {last_error}")
raise SystemExit(1)
def on_track_downloaded(self, track: Any) -> None:
try:
path = getattr(track, "path", None)
data = getattr(track, "data", None)
if not path or not path.exists() or not isinstance(data, dict):
return
extension = data.get("qobuz_ext")
if not extension or path.suffix.lower() == f".{extension}":
return
new_path = path.with_suffix(f".{extension}")
if new_path.exists():
new_path.unlink()
path.rename(new_path)
track.path = new_path
except Exception as e:
self.log.debug(f"Extension rename skipped: {e}")
@staticmethod
def _album_title(album: dict) -> str:
title = album.get("title") or ""
if album.get("version"):
title = f"{title.strip()} ({album['version']})"
return title.strip()
@staticmethod
def _year(obj: dict) -> int:
for key in ("release_date_original", "release_date_stream", "released_at", "release_date"):
value = obj.get(key)
if not value:
continue
if isinstance(value, (int, float)):
try:
return int(time.gmtime(int(value)).tm_year)
except Exception:
continue
match = re.match(r"(\d{4})", str(value))
if match:
return int(match.group(1))
return 0
@staticmethod
def _release_date(obj: dict) -> Optional[str]:
for key in ("release_date_original", "release_date_stream", "release_date"):
value = obj.get(key)
if value and re.match(r"\d{4}-\d{2}-\d{2}", str(value)):
return str(value)
return None
@staticmethod
def _cover_url(album: dict) -> Optional[str]:
image = album.get("image") or {}
url = image.get("large") or image.get("small") or image.get("thumbnail")
if not url:
return None
return re.sub(r"_(\d+|max|org)\.jpg", "_org.jpg", url)
@staticmethod
def _release_kind(album: dict) -> str:
release_type = (album.get("release_type") or album.get("product_type") or "").lower()
if release_type in ("single", "ep", "compilation", "album"):
return release_type
tracks_count = album.get("tracks_count") or 0
if tracks_count and tracks_count <= 3:
return "single"
return "album"
@@ -0,0 +1,16 @@
# Logins go in envied.yaml credentials and are formatted as: (email:password) or (token:X-User-Auth-Token)
# Format IDs: 5 = MP3 320 | 6 = FLAC 16-bit/44.1kHz | 7 = FLAC 24-bit ≤96kHz | 27 = FLAC 24-bit ≤192kHz
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
base_url: 'https://www.qobuz.com/api.json/0.2/'
web_url: 'https://play.qobuz.com'
app_id:
- '798273057'
secrets:
- 'abb21364945c0583309667d13ca3d93a'
- '806331c3b0b641da923b890aed01d04a'
- 'f69a7734686cb9427629378a4b7ac381'
default_format_id: 27
@@ -0,0 +1,4 @@
# twinvine-services
Service scripts

Some files were not shown because too many files have changed in this diff Show More