#!/usr/bin/env python3 """Download a DRM-protected video from Apple TV+ (Widevine via HLS).""" import argparse import base64 import curses import getpass import hashlib import json import os import pickle import random import re import secrets import string import subprocess import sys import time import unicodedata from pathlib import Path from urllib.parse import urlparse, unquote import requests from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes as crypto_hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC HERE = Path(__file__).parent TOOLS = HERE / 'tools' N_M3U8 = TOOLS / 'N_m3u8DL-RE' MP4DEC = TOOLS / 'mp4decrypt' SAVE_DIR = HERE / 'downloads' TMP_DIR = '/tmp/n_m3u8dl_tmp' COOKIES_FILE = HERE / '.apple_cookies' # pickled cookie jar SESSION_FILE = HERE / '.apple_session' # JSON with token + metadata UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') AUTH_HOST = 'https://idmsa.apple.com' OLYMPUS = 'https://appstoreconnect.apple.com/olympus/v1' LICENSE_URL = 'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/fpsRequest' TV_API = 'https://tv.apple.com/api/uts' TV_HOME = 'https://tv.apple.com' MUSIC_MKT_KEY = '92f19b477c5c9be6ab17f3ec2b1b2b7db4d00a9a8c973e3d6c90dac08b91de71' BUY_APPLE = 'https://buy.itunes.apple.com' # Populated by _get_tv_page_config() from the Apple TV page; used by _api_params() _API_BASE: dict = {} STOREFRONT = { 'pl': '143478', 'us': '143441', 'gb': '143444', 'de': '143443', 'fr': '143442', 'ca': '143455', 'au': '143460', 'jp': '143462', 'it': '143450', 'es': '143454', 'nl': '143452', 'se': '143456', 'no': '143457', 'dk': '143458', 'fi': '143447', 'br': '143503', 'mx': '143468', 'kr': '143466', } WIDEVINE_KEYFORMAT = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed' LANG_NAMES = { 'ar': 'Arabic', 'bg': 'Bulgarian', 'cs': 'Czech', 'da': 'Danish', 'de': 'German', 'el': 'Greek', 'en': 'English', 'en-US': 'English (US)', 'en-GB': 'English (UK)', 'es': 'Spanish', 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', 'hr': 'Croatian', 'hu': 'Hungarian', 'it': 'Italian', 'ja': 'Japanese', 'ko': 'Korean', 'lt': 'Lithuanian', 'lv': 'Latvian', 'nl': 'Dutch', 'nb': 'Norwegian', 'pl': 'Polish', 'pt': 'Portuguese', 'pt-BR': 'Portuguese (BR)', 'ro': 'Romanian', 'ru': 'Russian', 'sk': 'Slovak', 'sl': 'Slovenian', 'sr': 'Serbian', 'sv': 'Swedish', 'tr': 'Turkish', 'uk': 'Ukrainian', 'zh': 'Chinese', 'zh-CN': 'Chinese (Simp.)', 'zh-TW': 'Chinese (Trad.)', 'und': 'Undetermined', } def lang_name(code): return LANG_NAMES.get(code, code) def _b64decode(s: str) -> bytes: """Decode base64 that may be standard or URL-safe, with or without padding.""" s = s.strip().replace('-', '+').replace('_', '/') rem = len(s) % 4 if rem: s += '=' * (4 - rem) return base64.b64decode(s) # ── Widevine device ────────────────────────────────────────────────────────── def _find_wvd(): from pywidevine.device import Device for p in sorted((HERE / 'L3').glob('*.wvd')): try: d = Device.load(str(p)) if d.system_id and d.system_id != 0: return p except Exception: pass return next((HERE / 'L3').glob('*.wvd'), None) WVD = _find_wvd() # ── SRP-6a (Apple's SIRP variant) ─────────────────────────────────────────── class AppleSRP: """SRP-6a with Apple's PBKDF2-based password hash and SHA-256 group. Apple SIRP uses the 2048-bit Oakley prime (same as Tom Wu SRP / srp package NG_2048). N is 2048 bits = 256 bytes. Verified by decoding Apple's SRP init 'b' response. """ # 2048-bit SRP Oakley prime (from srp package NG_2048 / Tom Wu's SRP 2048-bit group) # 2048 actual bits, 512 hex chars, 256 bytes N = int( 'AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07' 'FC3192943DB56050A37329CBB4A099ED8193E0757767A13D' 'D52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0' 'CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8' '55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA7' '1D281E446B14773BCA97B43A23FB801676BD207A436C6481' 'F1D2B9078717461A5B9D32E688F87748544523B524B0D57D' '5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6' 'AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C382' '71AE35F8E9DBFBB694B5C803D89F7AE435DE236D525F5475' '9B65E372FCD68EF20FA7111F9E4AFF73', 16, ) G = 2 N_LEN = (N.bit_length() + 7) // 8 # 256 bytes def __init__(self, account: str, password: str): self.account = account self.password = password self._a = int.from_bytes(secrets.token_bytes(32), 'big') self.A = pow(self.G, self._a, self.N) def A_bytes(self) -> bytes: return self.A.to_bytes(self.N_LEN, 'big') def A_b64(self) -> str: return base64.b64encode(self.A_bytes()).decode() def _x(self, salt: bytes, iterations: int, protocol: str) -> int: """Apple SIRP x derivation (reverse-engineered from webSRPClientWorker.js). Step 1 — PBKDF2: s2k: pbkdf_key = SHA256(password) (32 raw bytes) s2k_fo: pbkdf_key = SHA256(password).hex() (64 ASCII bytes, hex repr) pbkdf = PBKDF2-HMAC-SHA256(key=pbkdf_key, salt=salt, iter=iterations, dkLen=32) Step 2 — E() function: x = SHA256(salt || SHA256(b":" || pbkdf)) """ pw = self.password if protocol == 's2k_fo': pw = unicodedata.normalize('NFKD', pw).casefold() pw_hash = hashlib.sha256(pw.encode('utf-8')).digest() if protocol == 's2k_fo': # importKey gets the hex-string bytes of SHA256(password) pbkdf_key = pw_hash.hex().encode('ascii') # 64 bytes else: # importKey gets the raw SHA256(password) bytes pbkdf_key = pw_hash # 32 bytes kdf = PBKDF2HMAC( algorithm=crypto_hashes.SHA256(), length=32, salt=salt, iterations=iterations, backend=default_backend(), ) pbkdf_bytes = kdf.derive(pbkdf_key) # E() function: SHA256(salt || SHA256(b":" || pbkdf)) intermediate = hashlib.sha256(b':' + pbkdf_bytes).digest() x_bytes = hashlib.sha256(salt + intermediate).digest() return int.from_bytes(x_bytes, 'big') def compute_m1(self, B_b64: str, salt_b64: str, iterations: int, protocol: str): """Return (M1_b64, M2_b64). M1 = H(H(N) XOR H(g) | H(account) | salt | A | B | K) M2 = H(A | M1 | K) — client's expected server proof, required by Apple """ B_bytes = _b64decode(B_b64) salt_bytes = _b64decode(salt_b64) # Pad B to N_LEN bytes in case leading zeros were dropped in transmission if len(B_bytes) < self.N_LEN: B_bytes = B_bytes.rjust(self.N_LEN, b'\x00') B = int.from_bytes(B_bytes, 'big') N_b = self.N.to_bytes(self.N_LEN, 'big') G_b = self.G.to_bytes(self.N_LEN, 'big') A_b = self.A_bytes() k = int.from_bytes(hashlib.sha256(N_b + G_b).digest(), 'big') x = self._x(salt_bytes, iterations, protocol) u = int.from_bytes(hashlib.sha256(A_b + B_bytes).digest(), 'big') gx = pow(self.G, x, self.N) kgx = (k * gx) % self.N base = (B - kgx) % self.N exp = (self._a + u * x) % (self.N - 1) S = pow(base, exp, self.N) K = hashlib.sha256(S.to_bytes(self.N_LEN, 'big')).digest() h_N = hashlib.sha256(N_b).digest() h_G = hashlib.sha256(G_b).digest() xNG = bytes(a ^ b for a, b in zip(h_N, h_G)) h_I = hashlib.sha256(self.account.lower().encode('utf-8')).digest() M1 = hashlib.sha256(xNG + h_I + salt_bytes + A_b + B_bytes + K).digest() M2 = hashlib.sha256(A_b + M1 + K).digest() return base64.b64encode(M1).decode(), base64.b64encode(M2).decode() # ── Hashcash (Apple proof-of-work) ─────────────────────────────────────────── def _hashcash(bits: int, challenge: str) -> str: """Compute SHA-1 hashcash stamp satisfying 'bits' leading zero bits.""" bits = int(bits) n_zero = bits // 8 n_extra = bits % 8 chars = string.ascii_letters + string.digits for _ in range(5_000_000): nonce = ''.join(random.choices(chars, k=16)) stamp = f'1:{bits}:{challenge}:{nonce}' h = hashlib.sha1(stamp.encode()).digest() ok = all(b == 0 for b in h[:n_zero]) if ok and n_extra and (h[n_zero] >> (8 - n_extra)): ok = False if ok: return stamp raise RuntimeError('Hashcash PoW failed — this should not happen') # ── Apple ID authentication ────────────────────────────────────────────────── def _get_service_key(session: requests.Session) -> str: """Fetch Apple auth widget key for Apple TV+ login.""" # 1. Try appleid.apple.com — the canonical Apple ID sign-in page embeds it try: r = session.get( 'https://appleid.apple.com/', headers={'User-Agent': UA}, timeout=10, ) if r.ok: m = re.search(r'"widgetKey"\s*:\s*"([^"]+)"', r.text) if m: return m.group(1) m2 = re.search(r"widgetKey\s*=\s*['\"]([^'\"]+)['\"]", r.text) if m2: return m2.group(1) except Exception: pass # 2. Try idmsa sign-in page response header try: r = session.get( f'{AUTH_HOST}/appleauth/auth/signin', params={'widgetKey': ''}, headers={'User-Agent': UA}, timeout=10, ) key = r.headers.get('X-Apple-Widget-Key', '') if key: return key except Exception: pass # 3. Try App Store Connect olympus endpoint (may not be the right key for TV) try: r = session.get( f'{OLYMPUS}/app/config?hostname=itunesconnect.apple.com', timeout=10, ) if r.ok: key = r.json().get('authServiceKey', '') if key: return key except Exception: pass raise RuntimeError( 'Cannot determine Apple auth service key. ' 'Check your internet connection or update the hardcoded key.' ) def _auth_headers(service_key: str, session_id: str = '', scnt: str = '') -> dict: h = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Apple-Widget-Key': service_key, 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': UA, 'Origin': 'https://appleid.apple.com', 'Referer': 'https://appleid.apple.com/', } if session_id: h['X-Apple-ID-Session-Id'] = session_id if scnt: h['scnt'] = scnt return h def _get_hashcash_params(session: requests.Session, service_key: str): """GET /appleauth/auth/signin to obtain PoW parameters.""" r = session.get( f'{AUTH_HOST}/appleauth/auth/signin', params={'widgetKey': service_key}, headers=_auth_headers(service_key), timeout=10, ) bits = r.headers.get('X-Apple-HC-Bits', '0') challenge = r.headers.get('X-Apple-HC-Challenge', '') return bits, challenge def apple_login(session: requests.Session, username: str, password: str) -> dict: """Full Apple ID SIRP login with 2FA support. Returns a dict with 'media_user_token', 'myacinfo', 'itua'. Raises RuntimeError on failure. """ print('Fetching Apple auth service key...') service_key = _get_service_key(session) print(f' Service key: {service_key[:20]}...') srp = AppleSRP(username, password) # ── Step 1: SRP init ──────────────────────────────────────────────────── print('Starting SRP handshake...') r = session.post( f'{AUTH_HOST}/appleauth/auth/signin/init', json={ 'a': srp.A_b64(), 'accountName': username, 'protocols': ['s2k', 's2k_fo'], }, headers=_auth_headers(service_key), timeout=15, ) if not r.ok: raise RuntimeError(f'SRP init failed (HTTP {r.status_code}): {r.text[:200]}') init_data = r.json() print(f' Init keys: {list(init_data.keys())}') # ── Step 2: Compute M1 + M2 ──────────────────────────────────────────── print('Computing SRP response...') b_raw = _b64decode(init_data['b']) salt_raw = _b64decode(init_data['salt']) protocol = init_data.get('protocol', 's2k') iters = init_data['iteration'] print(f' Server B: {len(b_raw)} bytes ({len(b_raw)*8}-bit group)') print(f' Salt: {len(salt_raw)} bytes = {salt_raw.hex()}') print(f' Iterations: {iters}') print(f' Protocol: {protocol}') print(f' B (b64 raw): {init_data["b"][:32]}...') print(f' c: {init_data.get("c", "")[:32]}...') m1_b64, m2_b64 = srp.compute_m1( B_b64 = init_data['b'], salt_b64 = init_data['salt'], iterations = iters, protocol = protocol, ) c = init_data['c'] print(f' M1: {m1_b64}') print(f' M2: {m2_b64[:16]}...') # ── Step 3: Hashcash PoW ──────────────────────────────────────────────── hc_bits, hc_challenge = _get_hashcash_params(session, service_key) hc = '' if hc_bits and hc_challenge and int(hc_bits) > 0: print(f'Computing hashcash ({hc_bits} bits)...') hc = _hashcash(int(hc_bits), hc_challenge) # ── Step 4: SRP complete ───────────────────────────────────────────────── print('Submitting credentials...') headers = _auth_headers(service_key) if hc: headers['X-Apple-HC'] = hc r = session.post( f'{AUTH_HOST}/appleauth/auth/signin/complete', params={'isRememberMeEnabled': 'false'}, json={ 'accountName': username, 'c': c, 'm1': m1_b64, 'm2': m2_b64, 'rememberMe': False, }, headers=headers, timeout=20, ) session_id = r.headers.get('X-Apple-ID-Session-Id', '') scnt = r.headers.get('scnt', '') if r.status_code == 200: # Login succeeded without 2FA print('Login succeeded (no 2FA required).') return _extract_tokens(session, service_key, session_id, scnt) if r.status_code == 409: # 2FA required return _handle_2fa(session, service_key, session_id, scnt) body = r.text[:500] print(f' Response headers: {dict(r.headers)}') raise RuntimeError(f'Login failed (HTTP {r.status_code}): {body}') def _handle_2fa(session: requests.Session, service_key: str, session_id: str, scnt: str) -> dict: """Handle two-factor authentication (trusted device or SMS).""" headers = _auth_headers(service_key, session_id, scnt) # Get available 2FA methods r = session.get( f'{AUTH_HOST}/appleauth/auth', headers=headers, timeout=15, ) auth_data = r.json() if r.ok else {} phone_numbers = auth_data.get('trustedPhoneNumbers', []) print('\n2FA is required.') # Prefer trusted-device code if available security_devices = auth_data.get('trustedDevices', []) or auth_data.get('securityCode', {}) if security_devices or not phone_numbers: code = input('Enter 6-digit verification code from your trusted device: ').strip() r = session.post( f'{AUTH_HOST}/appleauth/auth/verify/trusteddevice/securitycode', json={'securityCode': {'code': code}}, headers=headers, timeout=15, ) else: print('Available phone numbers for SMS verification:') for i, ph in enumerate(phone_numbers): num = ph.get('numberWithDialCode', ph.get('number', f'#{i+1}')) print(f' [{i+1}] {num}') choice = input('Select number (1): ').strip() or '1' ph_idx = int(choice) - 1 ph_id = phone_numbers[ph_idx]['id'] # Trigger SMS session.put( f'{AUTH_HOST}/appleauth/auth/verify/phone', json={'phoneNumber': {'id': ph_id}, 'mode': 'sms'}, headers=headers, timeout=15, ) code = input('Enter SMS verification code: ').strip() r = session.post( f'{AUTH_HOST}/appleauth/auth/verify/phone/securitycode', json={ 'phoneNumber': {'id': ph_id}, 'securityCode': {'code': code}, 'mode': 'sms', }, headers=headers, timeout=15, ) if r.status_code not in (200, 204): raise RuntimeError(f'2FA verification failed (HTTP {r.status_code}): {r.text[:200]}') print('2FA verified.') # Trust this session so we don't need 2FA again for a while try: session.post( f'{AUTH_HOST}/appleauth/auth/2sv/trust', headers=headers, timeout=10, ) except Exception: pass return _extract_tokens(session, service_key, session_id, scnt) def _extract_tokens(session: requests.Session, service_key: str, session_id: str, scnt: str) -> dict: """Get the olympus session and extract media-user-token.""" try: r = session.get(f'{OLYMPUS}/session', timeout=15) except Exception: pass mut = session.cookies.get('media-user-token', '') myacinfo = session.cookies.get('myacinfo', '') itua = session.cookies.get('itua', '') if not mut: # media-user-token may be in response headers on some flows for cookie in session.cookies: if 'media' in cookie.name.lower() and 'token' in cookie.name.lower(): mut = cookie.value break return { 'media_user_token': mut, 'myacinfo': myacinfo, 'itua': itua, } # ── MusicKit / media-user-token ────────────────────────────────────────────── def get_media_user_token(session: requests.Session, dev_token: str, username: str, password: str) -> str: """Obtain media-user-token via MusicKit OAuth + SIRP. Flow: authenticateMusicKitRequest → IDMSA OAuth page (scnt, oAuthToken, liteSessionId) → SIRP with MUSIC_MKT_KEY → POST /auth → getMusicUserToken Uses a separate session so main session cookies are not touched. Raises RuntimeError if the flow cannot complete (e.g. 2FA needed). """ # 1 — try simple direct-exchange endpoints first (work when myacinfo is already # valid for the buy.itunes.apple.com domain) for path in ('/commerce/account/webPlayerToken', '/commerce/account/getMusicUserToken'): try: r = session.get( f'{BUY_APPLE}{path}', params={'jwtToken': dev_token}, headers={'Accept': 'application/json'}, timeout=10, ) if r.ok: data = r.json() for key in ('musicUserToken', 'music-user-token', 'userToken', 'token'): if data.get(key): return data[key] except Exception: pass # 2 — full OAuth + SIRP flow (requires credentials) if not username or not password: raise RuntimeError( 'Credentials required for automated media-user-token flow.\n' 'Options:\n' ' a) Re-run with -u -p so the flow can complete.\n' ' b) Provide the token directly: --media-token or APPLE_MUT env var.\n' ' To get it: open tv.apple.com in a browser, then\n' ' DevTools → Application → Cookies → .apple.com → media-user-token' ) ms = requests.Session() ms.headers['User-Agent'] = UA for c in session.cookies: ms.cookies.set(c.name, c.value, domain=c.domain, path=c.path) # 2a — kick off MusicKit OAuth r1 = ms.post( f'{BUY_APPLE}/commerce/account/authenticateMusicKitRequest', data={'jwtToken': dev_token, 'isWebPlayer': 'true', 'LogoURL': ''}, headers={'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'text/html,*/*'}, allow_redirects=False, timeout=10, ) if r1.status_code not in (301, 302, 303, 307, 308): raise RuntimeError( f'authenticateMusicKitRequest returned HTTP {r1.status_code}' ) idmsa_url = r1.headers.get('Location', '') # 2b — fetch IDMSA OAuth page r2 = ms.get(idmsa_url, timeout=10, headers={'Accept': 'text/html'}) html = r2.text page_scnt = (re.search(r'name="scnt"\s+value="([^"]+)"', html) or ['', ''])[1] or \ (re.search(r'name="scnt"\s+value=\'([^\']+)\'', html) or ['', ''])[1] oauth_tok = (re.search(r'name="oAuthToken"\s+value="([^"]+)"', html) or ['', ''])[1] if not page_scnt: m = re.search(r'"scnt"\s*:\s*"([^"]+)"', html) page_scnt = m.group(1) if m else '' lite_id = validate_host = None am = re.search(r'"appReturnUrl"\s*:\s*"([^"]+)"', html) if am: app_ret = unquote(am.group(1)) lm = re.search(r'liteSessionId=([^&"]+)', app_ret) if lm: lite_id = lm.group(1) parsed = urlparse(app_ret) validate_host = f'{parsed.scheme}://{parsed.netloc}' if not all([page_scnt, oauth_tok, lite_id, validate_host]): raise RuntimeError( f'Missing OAuth params from IDMSA page ' f'(scnt={bool(page_scnt)}, oAuthToken={bool(oauth_tok)}, ' f'liteSessionId={lite_id}, host={validate_host})' ) # 2c — SIRP with Music widget key (fresh session, no pre-existing scnt) srp = AppleSRP(username, password) init_r = ms.post( f'{AUTH_HOST}/appleauth/auth/signin/init', json={'a': srp.A_b64(), 'accountName': username, 'protocols': ['s2k', 's2k_fo']}, headers=_auth_headers(MUSIC_MKT_KEY), timeout=15, ) if not init_r.ok: raise RuntimeError( f'Music SIRP init failed ({init_r.status_code}): {init_r.text[:200]}' ) init_data = init_r.json() sirp_scnt = init_r.headers.get('scnt', page_scnt) sirp_sid = init_r.headers.get('X-Apple-ID-Session-Id', '') m1, m2 = srp.compute_m1( B_b64=init_data['b'], salt_b64=init_data['salt'], iterations=init_data['iteration'], protocol=init_data.get('protocol', 's2k'), ) hc_bits, hc_chal = _get_hashcash_params(ms, MUSIC_MKT_KEY) hc = _hashcash(int(hc_bits), hc_chal) if hc_bits and hc_chal and int(hc_bits) > 0 else '' comp_headers = _auth_headers(MUSIC_MKT_KEY, sirp_sid, sirp_scnt) if hc: comp_headers['X-Apple-HC'] = hc comp_r = ms.post( f'{AUTH_HOST}/appleauth/auth/signin/complete', params={'isRememberMeEnabled': 'false'}, json={'accountName': username, 'c': init_data['c'], 'm1': m1, 'm2': m2, 'rememberMe': False}, headers=comp_headers, timeout=20, ) if comp_r.status_code == 409: # Apple requires 2FA for the Music widget key session — handle it inline music_sid = comp_r.headers.get('X-Apple-ID-Session-Id', sirp_sid) music_scnt = comp_r.headers.get('scnt', sirp_scnt) music_hdrs = _auth_headers(MUSIC_MKT_KEY, music_sid, music_scnt) auth_r = ms.get(f'{AUTH_HOST}/appleauth/auth', headers=music_hdrs, timeout=15) auth_data = auth_r.json() if auth_r.ok else {} phones = auth_data.get('trustedPhoneNumbers', []) sec_devs = auth_data.get('trustedDevices', []) or auth_data.get('securityCode', {}) print('\n2FA required for Music authorisation.') if sec_devs or not phones: code = input('Enter 6-digit verification code from your trusted device: ').strip() v_r = ms.post( f'{AUTH_HOST}/appleauth/auth/verify/trusteddevice/securitycode', json={'securityCode': {'code': code}}, headers=music_hdrs, timeout=15, ) else: print('Available phone numbers for SMS verification:') for i, ph in enumerate(phones): print(f' [{i+1}] {ph.get("numberWithDialCode", ph.get("number", f"#{i+1}"))}') choice = input('Select number (1): ').strip() or '1' ph_id = phones[int(choice) - 1]['id'] ms.put(f'{AUTH_HOST}/appleauth/auth/verify/phone', json={'phoneNumber': {'id': ph_id}, 'mode': 'sms'}, headers=music_hdrs, timeout=15) code = input('Enter SMS verification code: ').strip() v_r = ms.post( f'{AUTH_HOST}/appleauth/auth/verify/phone/securitycode', json={'phoneNumber': {'id': ph_id}, 'securityCode': {'code': code}, 'mode': 'sms'}, headers=music_hdrs, timeout=15, ) if v_r.status_code not in (200, 204): raise RuntimeError( f'Music 2FA verification failed (HTTP {v_r.status_code}): {v_r.text[:200]}\n' 'Provide the token via --media-token or APPLE_MUT env var.\n' 'To get it: open tv.apple.com in your browser (logged in),\n' 'open DevTools → Application → Cookies → .apple.com → media-user-token' ) print('Music 2FA verified.') try: ms.post(f'{AUTH_HOST}/appleauth/auth/2sv/trust', headers=music_hdrs, timeout=10) except Exception: pass elif not comp_r.ok: raise RuntimeError( f'Music SIRP complete failed ({comp_r.status_code}): {comp_r.text[:200]}' ) # 2d — submit IDMSA OAuth form with the original OAuth scnt (not the SIRP auth scnt) ms.post( 'https://idmsa.apple.com/auth', data={'rememberMe': 'false', 'oAuthToken': oauth_tok, 'scnt': page_scnt}, headers={ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'text/html,*/*', 'Referer': idmsa_url, 'Origin': 'https://idmsa.apple.com', 'X-Apple-Widget-Key': MUSIC_MKT_KEY, }, allow_redirects=True, timeout=15, ) # 2e — getMusicUserToken mut_r = ms.get( f'{validate_host}/commerce/account/getMusicUserToken', params={'liteSessionId': lite_id, 'jwtToken': dev_token}, headers={'Accept': 'application/json'}, timeout=10, ) if mut_r.ok: data = mut_r.json() for key in ('musicUserToken', 'music-user-token', 'userToken'): if data.get(key): return data[key] raise RuntimeError( f'getMusicUserToken failed ({mut_r.status_code}): {mut_r.text[:300]}\n' 'Provide token via --media-token or APPLE_MUT env var.\n' 'To get it: open tv.apple.com in your browser (logged in),\n' 'open DevTools → Application → Cookies → .apple.com → media-user-token' ) # ── Session persistence ────────────────────────────────────────────────────── def load_session(session: requests.Session) -> dict: """Restore cookies and session metadata from disk. Returns metadata dict.""" meta = {} if COOKIES_FILE.exists(): try: with open(COOKIES_FILE, 'rb') as f: jar = pickle.load(f) session.cookies.update(jar) except Exception as e: print(f'[warn] Could not load cookies: {e}') if SESSION_FILE.exists(): try: meta = json.loads(SESSION_FILE.read_text()) except Exception: pass return meta def save_session(session: requests.Session, meta: dict): """Persist cookies and session metadata to disk.""" with open(COOKIES_FILE, 'wb') as f: pickle.dump(session.cookies, f) COOKIES_FILE.chmod(0o600) SESSION_FILE.write_text(json.dumps(meta, indent=2)) SESSION_FILE.chmod(0o600) def is_session_valid(session: requests.Session, meta: dict) -> bool: """Check that the myacinfo auth cookie is present (set after Apple ID login).""" return bool(session.cookies.get('myacinfo', '')) # ── Developer token + utsk (public API key extracted from page HTML) ────────── def _get_tv_page_config(session: requests.Session, country: str = 'us') -> tuple: """Fetch tv.apple.com and extract (dev_token, utsk). Also updates _API_BASE. Returns (dev_token: str, utsk: str). utsk is the user session key required as URL param in content API calls. """ global _API_BASE r = session.get( f'{TV_HOME}/{country}', headers={'User-Agent': UA, 'Accept': 'text/html'}, timeout=15, ) if not r.ok: raise RuntimeError(f'Apple TV page returned HTTP {r.status_code}') dev_token = '' utsk = '' m = re.search( r']+id=["\']serialized-server-data["\'][^>]*>(.*?)', r.text, re.DOTALL, ) if m: try: data = json.loads(m.group(1)) # Structure: {"data": [{"data": {"configureParams": ..., "configuration": ...}}]} entry = data['data'][0]['data'] dev_token = entry['configureParams']['developerToken'] cfg = entry['configuration'] utsk = cfg.get('utskProps', {}).get('utsk', '') # Use the live requiredParamsMap.Default as base for all API calls default_params = (cfg.get('applicationProps', {}) .get('requiredParamsMap', {}) .get('Default', {})) if default_params: # Strip utsk — we inject it per-country in _api_params _API_BASE = {k: v for k, v in default_params.items() if k not in ('sf', 'locale', 'utsk')} _API_BASE['utsk'] = utsk except (KeyError, IndexError, json.JSONDecodeError): pass if not dev_token: # Fallback: regex anywhere in the page m2 = re.search(r'"developerToken"\s*:\s*"([^"]{40,})"', r.text) if not m2: raise RuntimeError( 'Cannot extract developerToken from Apple TV page. ' 'The page structure may have changed.' ) dev_token = m2.group(1) return dev_token, utsk # ── Apple TV content API ────────────────────────────────────────────────────── LOCALE_MAP = { 'us': 'en-US', 'gb': 'en-GB', 'ca': 'en-CA', 'au': 'en-AU', 'pl': 'pl-PL', 'de': 'de-DE', 'fr': 'fr-FR', 'it': 'it-IT', 'es': 'es-ES', 'nl': 'nl-NL', 'se': 'sv-SE', 'no': 'nb-NO', 'dk': 'da-DK', 'fi': 'fi-FI', 'br': 'pt-BR', 'mx': 'es-MX', 'jp': 'ja-JP', 'kr': 'ko-KR', } def _sf(country: str) -> str: return STOREFRONT.get(country.lower(), '143441') def _locale(country: str) -> str: return LOCALE_MAP.get(country.lower(), 'en-US') def _api_params(country: str, **extra) -> dict: base = dict(_API_BASE) if _API_BASE else { 'utscf': 'OjAAAAAAAAA~', 'caller': 'web', 'v': '94', 'pfm': 'web', } # Always override country-specific fields base['sf'] = _sf(country) base['locale'] = _locale(country) base.update(extra) return base def _parse_url(url: str): """Return (country, kind, content_id) from an Apple TV URL.""" m = re.search( r'tv\.apple\.com/([a-z]{2})/(\w+)/[^/]+/(umc\.cmc\.[a-z0-9]+)', url, ) if not m: raise ValueError(f'Unrecognised Apple TV URL: {url}') country, kind, cid = m.group(1), m.group(2), m.group(3) if kind in ('episode', 'season'): kind = 'show' return country, kind, cid def _check_api(r: requests.Response): if not r.ok: raise RuntimeError( f'API {r.status_code} for {r.url.split("?")[0]}: {r.text[:300]}' ) def get_show_metadata(session: requests.Session, cid: str, country: str) -> dict: r = session.get( f'{TV_API}/v3/shows/{cid}', params=_api_params(country), timeout=15, ) if r.status_code == 404: # cid might be an episode ID; try to look up its parent show ep_r = session.get(f'{TV_API}/v3/episodes/{cid}', params=_api_params(country), timeout=15) if ep_r.ok: show_id = ep_r.json().get('data', {}).get('content', {}).get('showId', '') if show_id: r = session.get(f'{TV_API}/v3/shows/{show_id}', params=_api_params(country), timeout=15) _check_api(r) return r.json().get('data', {}) def get_movie_metadata(session: requests.Session, cid: str, country: str) -> dict: r = session.get( f'{TV_API}/v3/movies/{cid}', params=_api_params(country), timeout=15, ) _check_api(r) return r.json().get('data', {}) def get_episodes(session: requests.Session, show_id: str, country: str, season_ids: list | None = None) -> list: """Fetch all available episodes for a show across all seasons. season_ids: list of season ID strings from the show metadata 'seasons' dict. If None or a single season, fetches without seasonId filter. """ def _fetch_season(sid: str | None) -> list: eps = [] skip = 0 while True: extra = {'count': '20', 'skip': str(skip)} if sid: extra['seasonId'] = sid params = _api_params(country, **extra) r = session.get( f'{TV_API}/v3/shows/{show_id}/episodes', params=params, timeout=20, ) _check_api(r) data = r.json().get('data', {}) items = data.get('episodes', []) if not items: break eps.extend(items) if len(items) < 20: break skip += 20 return eps if not season_ids or len(season_ids) <= 1: return _fetch_season(season_ids[0] if season_ids else None) all_episodes = [] for sid in season_ids: all_episodes.extend(_fetch_season(sid)) return all_episodes def get_playback_info(session: requests.Session, content_id: str, country: str) -> dict: """Get subscriber playback data (requires media-user-token). v3/episodes and v3/movies are the current endpoints; v2/view/product/*/personalized returns 404 as of 2026. """ endpoints = [ f'{TV_API}/v3/episodes/{content_id}', f'{TV_API}/v3/movies/{content_id}', ] for url in endpoints: r = session.get(url, params=_api_params(country), timeout=15) if r.status_code in (401, 403): raise RuntimeError( f'Playback info denied (HTTP {r.status_code}). ' 'media-user-token may be missing or expired — ' 'provide via --media-token or APPLE_MUT env var.' ) if r.ok: data = r.json().get('data', {}) if _has_playback_data(data): return data elif r.status_code != 404: _check_api(r) raise RuntimeError( f'No playback endpoint returned usable data for {content_id}. ' 'Content may not be available in your region or the ID format changed.' ) def _has_playback_data(data: dict) -> bool: """Return True if the API response contains a playables section with entitlement info.""" if not data: return False pl = data.get('playables', {}) if isinstance(pl, dict) and pl: first = next(iter(pl.values()), {}) return 'isEntitledToPlay' in first or 'hlsUrl' in first return False def _extract_hls_url(playback: dict) -> str: """Dig through the response to find the HLS URL. v3/episodes|movies: 'playables' is a dict keyed by playable_id; each value has an 'assets' sub-dict containing 'hlsUrl' when entitled. """ pl = playback.get('playables', {}) if isinstance(pl, dict): for pid, pval in pl.items(): if not isinstance(pval, dict): continue # Primary location: assets.hlsUrl (v3 entitled response) url = pval.get('assets', {}).get('hlsUrl', '') if url and url.startswith('http'): return url # Fallback: hlsUrl directly on playable url = pval.get('hlsUrl', '') if url and url.startswith('http'): return url # Give a clear entitlement error if we know the reason if not pval.get('isEntitledToPlay', True): raise RuntimeError( 'Not entitled to play this content (isEntitledToPlay=false). ' 'Ensure media-user-token is set via --media-token or APPLE_MUT env var ' 'and that you have an active Apple TV+ subscription.' ) raise RuntimeError( f'No hlsUrl found in playback response. ' f'Keys present: {list(playback.keys())}\n' f'Raw snippet: {json.dumps(playback)[:500]}' ) def _extract_play_params(playback: dict) -> dict: """Extract adamId, svcId, and licenseUrl needed for license requests. v3 responses: 'playables[pid].assets' contains assetAdamId, fpsKeyServerUrl, and fpsKeyServerQueryParameters.svcId. """ pl = playback.get('playables', {}) if isinstance(pl, dict): for pid, pval in pl.items(): if not isinstance(pval, dict): continue assets = pval.get('assets', {}) fps_q = assets.get('fpsKeyServerQueryParameters', {}) adam = (assets.get('assetAdamId', '') or fps_q.get('adamId', '') or pval.get('externalId', '')) svc = (fps_q.get('svcId', '') or pval.get('channelId', 'tvs.sbd.4000')) lic = (assets.get('fpsKeyServerUrl', '') or LICENSE_URL) if adam: return { 'adamId': adam, 'svcId': svc, 'licenseUrl': lic, 'isExternal': fps_q.get('isExternal', True), } # Fallback: v2 personalized shape try: items = (playback.get('content', {}) .get('playables', {}) .get('items', [{}])) pp = items[0].get('playParams', {}) return { 'adamId': pp.get('id', pp.get('catalogId', '')), 'svcId': pp.get('channelId', 'tvs.sbd.4000'), 'licenseUrl': LICENSE_URL, 'isExternal': True, } except Exception: return {'adamId': '', 'svcId': 'tvs.sbd.4000', 'licenseUrl': LICENSE_URL, 'isExternal': True} # ── HLS / DRM ──────────────────────────────────────────────────────────────── def fetch_m3u8(url: str, session: requests.Session) -> str: r = session.get(url, headers={'User-Agent': UA}, timeout=15) _check_api(r) return r.text def _parse_hls_attrs(attr_str: str) -> dict: """Parse HLS attribute list: KEY=VALUE,KEY="VALUE",...""" result = {} for m in re.finditer(r'([\w-]+)=("(?:[^"\\]|\\.)*"|[^,]+)', attr_str): result[m.group(1)] = m.group(2).strip('"') return result def _pssh_from_m3u8_text(text: str) -> str: """Scan M3U8 text for a Widevine EXT-X-KEY/EXT-X-SESSION-KEY; returns full data: URI. Apple TV+ uses: data:text/plain;keyInfo=;base64, The full URI (including keyInfo) must be forwarded to the license server. """ for line in text.splitlines(): if not (line.startswith('#EXT-X-KEY') or line.startswith('#EXT-X-SESSION-KEY')): continue if WIDEVINE_KEYFORMAT not in line: continue attrs = _parse_hls_attrs(line.split(':', 1)[1]) uri = attrs.get('URI', '') if 'base64,' in uri: return uri # return full URI, keyInfo included return '' def get_pssh_from_m3u8(m3u8_text: str, session: requests.Session | None = None, base_url: str = '') -> list[str]: """Extract Widevine PSSH data: URIs from HLS manifests. Returns a list of unique PSSH URIs (full data: URIs with keyInfo preserved). Apple TV+ uses per-codec-group KIDs: dvh1 (Dolby Vision), hvc1 (HEVC), and avc1 (H.264) streams each carry a different KID. We sample one variant per codec family so that all KIDs are covered and every selected stream can be decrypted. base_url: URL the m3u8_text was fetched from, needed to resolve relative variant URLs. """ # Check master first (uncommon but possible) master_uri = _pssh_from_m3u8_text(m3u8_text) if master_uri: return [master_uri] if session is None: raise RuntimeError('No Widevine PSSH found in HLS master manifest.') base_dir = base_url.split('?')[0].rsplit('/', 1)[0] + '/' if base_url else '' lines = m3u8_text.splitlines() # Collect the first variant URL for each top-level codec family. # Apple uses the same KID within a family (all dvh1 bitrates share one key, # all hvc1 share another, etc.), so one sample per family is enough. codec_seen: set[str] = set() variant_urls: list[str] = [] i = 0 while i < len(lines): line = lines[i] if line.startswith('#EXT-X-STREAM-INF:'): attrs = _parse_hls_attrs(line[len('#EXT-X-STREAM-INF:'):]) codecs = attrs.get('CODECS', '') # Codec family = first 3 chars of the first codec (dvh, hvc, avc, …) family = codecs.split('.')[0][:3].lower() if codecs else 'unk' if family not in codec_seen: codec_seen.add(family) for j in range(i + 1, len(lines)): candidate = lines[j].strip() if candidate and not candidate.startswith('#'): var_url = (candidate if candidate.startswith('http') else base_dir + candidate) variant_urls.append(var_url) break i += 1 # Fetch sampled variants, collect unique PSSH URIs (deduplicated by b64 payload) seen_b64: set[str] = set() pssh_uris: list[str] = [] for var_url in variant_urls: try: var_r = session.get(var_url, headers={'User-Agent': UA}, timeout=15) if var_r.ok: uri = _pssh_from_m3u8_text(var_r.text) if uri: b64 = uri.split('base64,', 1)[1] if 'base64,' in uri else uri if b64 not in seen_b64: seen_b64.add(b64) pssh_uris.append(uri) except Exception: pass if not pssh_uris: raise RuntimeError('No Widevine PSSH found in HLS manifest or any variant playlist.') return pssh_uris def parse_m3u8_tracks(m3u8_text: str) -> tuple: """Parse video streams, audio tracks, and subtitle tracks from HLS master. Returns (video_streams, audio_tracks, sub_tracks). """ video_streams = [] audio_tracks = [] sub_tracks = [] lines = m3u8_text.splitlines() for i, line in enumerate(lines): if line.startswith('#EXT-X-STREAM-INF:'): attrs = _parse_hls_attrs(line[len('#EXT-X-STREAM-INF:'):]) bw = int(attrs.get('BANDWIDTH', 0)) // 1000 res = attrs.get('RESOLUTION', '') codecs = attrs.get('CODECS', '') if res and 'x' in res: w, h = map(int, res.split('x')) codec_tag = codecs.split(',')[0] if codecs else '' video_streams.append({ 'width': w, 'height': h, 'kbps': bw, 'codec': codec_tag }) elif line.startswith('#EXT-X-MEDIA:'): attrs = _parse_hls_attrs(line[len('#EXT-X-MEDIA:'):]) typ = attrs.get('TYPE', '') lang = attrs.get('LANGUAGE', 'und') name = attrs.get('NAME', lang) uri = attrs.get('URI', '') grp = attrs.get('GROUP-ID', '') if typ == 'AUDIO' and uri: audio_tracks.append({ 'lang': lang, 'lang_disp': lang_name(lang), 'name': name, 'group_id': grp, 'default': attrs.get('DEFAULT', 'NO') == 'YES', }) elif typ == 'SUBTITLES': forced = attrs.get('FORCED', 'NO') == 'YES' sub_tracks.append({ 'lang': lang, 'lang_disp': lang_name(lang), 'name': name, 'forced': forced, 'uri': uri, }) # Deduplicate video by resolution, keep highest bitrate per resolution best: dict = {} for v in video_streams: key = (v['width'], v['height']) if key not in best or v['kbps'] > best[key]['kbps']: best[key] = v video_streams = sorted(best.values(), key=lambda x: (-x['width'], -x['kbps'])) return video_streams, audio_tracks, sub_tracks # ── Widevine license via Apple's FPS endpoint ───────────────────────────────── def fetch_content_key(pssh_uris: list[str] | str, play_params: dict, session: requests.Session) -> dict: """Fetch Widevine content keys via Apple's license server. pssh_uris: one or more full data: URIs from HLS EXT-X-KEY lines (data:text/plain;keyInfo=;base64,). Apple TV+ uses per-codec-group KIDs, so callers should pass one URI per codec family (dvh1, hvc1, avc1) to obtain all needed keys. """ from pywidevine.cdm import Cdm from pywidevine.device import Device from pywidevine.pssh import PSSH import uuid as _uuid if isinstance(pssh_uris, str): pssh_uris = [pssh_uris] lic_url = play_params.get('licenseUrl', LICENSE_URL) lic_headers = { 'User-Agent': UA, 'Content-Type': 'application/json', 'Accept': 'application/json', } mut = session.cookies.get('media-user-token', '') or session.headers.get('media-user-token', '') if mut: lic_headers['media-user-token'] = mut myacinfo = session.cookies.get('myacinfo', '') if myacinfo: lic_headers['Cookie'] = f'myacinfo={myacinfo}; media-user-token={mut}' device = Device.load(str(WVD)) all_keys: dict = {} for pssh_uri in pssh_uris: cdm = Cdm.from_device(device) session_id = cdm.open() try: pssh_b64 = pssh_uri.split('base64,', 1)[1] if 'base64,' in pssh_uri else pssh_uri challenge = cdm.get_license_challenge(session_id, PSSH(pssh_b64)) challenge_b64 = base64.b64encode(challenge).decode() body = { 'streaming-request': { 'version': 1, 'streaming-keys': [{ 'challenge': challenge_b64, 'key-system': 'com.widevine.alpha', 'uri': pssh_uri, 'id': 1, 'lease-action': 'start', 'adamId': play_params.get('adamId', ''), 'isExternal': play_params.get('isExternal', True), 'svcId': play_params.get('svcId', 'tvs.sbd.4000'), }], } } r = session.post(lic_url, json=body, headers=lic_headers, timeout=20) if not r.ok: raise RuntimeError(f'License server {r.status_code}: {r.text[:300]}') resp = r.json() keys_resp = (resp.get('streaming-response', {}) .get('streaming-keys', [{}])) license_b64 = keys_resp[0].get('license', '') if not license_b64: raise RuntimeError(f'No license in response: {resp}') cdm.parse_license(session_id, base64.b64decode(license_b64)) for key in cdm.get_keys(session_id): if str(key.type) not in ('SIGNING', 'OPERATOR_SESSION'): kid = key.kid.hex if isinstance(key.kid, _uuid.UUID) else key.kid.hex() all_keys[kid] = key.key.hex() finally: cdm.close(session_id) return all_keys # ── Interactive pickers (reused from sky_download style) ───────────────────── def checkbox_picker(title, items, default_all=True, single_select=False, default_idx=None): if not items: return [] if default_idx is not None: selected = [i == default_idx for i in range(len(items))] else: selected = [default_all] * len(items) state = {'cursor': 0, 'offset': 0} def _run(stdscr): curses.curs_set(0) try: curses.start_color(); curses.use_default_colors() curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) except Exception: pass while True: stdscr.erase() h, w = stdscr.getmaxyx() list_rows = max(1, h - 5) cursor = state['cursor']; offset = state['offset'] if cursor < offset: offset = cursor elif cursor >= offset + list_rows: offset = cursor - list_rows + 1 state['offset'] = offset try: stdscr.addstr(0, 0, title[:w-1], curses.A_BOLD) stdscr.addstr(1, 0, ('─' * min(len(title), w-1))[:w-1]) hint = ('↑↓ move SPACE select ENTER=confirm' if single_select else '↑↓ move SPACE toggle a=all n=none ENTER=confirm') stdscr.addstr(2, 0, hint[:w-1]) except curses.error: pass for i in range(min(list_rows, len(items))): idx = i + offset if idx >= len(items): break mark = ('(◉)' if selected[idx] else '( )') if single_select else ('[✓]' if selected[idx] else '[ ]') line = f' {mark} {items[idx]}'[:w-1] try: stdscr.addstr(i + 4, 0, line, curses.color_pair(1) if idx == cursor else 0) except curses.error: pass if not single_select: try: stdscr.addstr(h-1, 0, f' {sum(selected)} of {len(items)} selected'[:w-1], curses.A_DIM) except curses.error: pass stdscr.refresh() key = stdscr.getch() if key in (curses.KEY_UP, ord('k')): state['cursor'] = max(0, cursor-1) elif key in (curses.KEY_DOWN, ord('j')): state['cursor'] = min(len(items)-1, cursor+1) elif key == ord(' '): if single_select: for j in range(len(selected)): selected[j] = (j == cursor) else: selected[cursor] = not selected[cursor] elif not single_select and key == ord('a'): for j in range(len(selected)): selected[j] = True elif not single_select and key == ord('n'): for j in range(len(selected)): selected[j] = False elif key in (curses.KEY_ENTER, 10, 13): break try: curses.wrapper(_run) except Exception as e: print(f'[warning] Interactive picker failed ({e}), using default.') if single_select: return [default_idx if default_idx is not None else 0] return list(range(len(items))) return [i for i, s in enumerate(selected) if s] def select_video_interactive(video_streams): if not video_streams: return ['--select-video', 'best'] items = [] for v in video_streams: c = v.get('codec', '') if 'hvc' in c or 'hev' in c or 'dvh' in c: cn = 'H.265' elif 'avc' in c: cn = 'H.264' else: cn = c.split('.')[0] if c else '?' items.append(f'{v["width"]}×{v["height"]:<9} {v["kbps"]:>5} Kbps {cn}') print() sel = checkbox_picker('Select video quality', items, single_select=True, default_idx=0) if not sel: return ['--select-video', 'best'] v = video_streams[sel[0]] print(f'Video: {v["width"]}×{v["height"]} {v["kbps"]} Kbps') return ['--select-video', f'res={v["width"]}x{v["height"]}:for=best'] def select_audio_interactive(audio_tracks): if not audio_tracks: return ['--drop-audio', 'all'] seen = set() langs = [] for t in audio_tracks: if t['lang'] not in seen: seen.add(t['lang']) langs.append(t['lang']) items = [f'{l:<9} {lang_name(l)}' for l in langs] print() sel = checkbox_picker('Select audio languages', items, default_all=False) if not sel: return ['--drop-audio', 'all'] sel_langs = [langs[i] for i in sel] print(f'Audio: {", ".join(sel_langs)}') return ['--select-audio', f'lang={"|".join(sel_langs)}:for=best'] def select_subs_interactive(sub_tracks): if not sub_tracks: return ['--drop-subtitle', 'all'] seen = set(); langs = [] for t in sub_tracks: if t['lang'] not in seen: seen.add(t['lang']); langs.append(t['lang']) items = [] for l in langs: forced_only = all(t['forced'] for t in sub_tracks if t['lang'] == l) note = ' (forced only)' if forced_only else '' items.append(f'{l:<9} {lang_name(l)}{note}') print() sel = checkbox_picker('Select subtitle tracks', items, default_all=False) if not sel: return ['--drop-subtitle', 'all'] sel_langs = [langs[i] for i in sel] print(f'Subtitles: {", ".join(sel_langs)}') return ['--select-subtitle', f'lang={"|".join(sel_langs)}:for=best'] # ── Download + decrypt ──────────────────────────────────────────────────────── def safe_filename(title: str) -> str: title = re.sub(r'[^\w\s-]', '', title) return re.sub(r'[\s-]+', '_', title).strip('_') def download_and_decrypt(hls_url: str, keys: dict, out_name: str, video_args: list, audio_args: list, cookie_header: str = '', sub_args: list | None = None): if sub_args is None: sub_args = ['--drop-subtitle', 'all'] key_args = [] for kid, key in keys.items(): key_args += ['--key', f'{kid}:{key}'] extra_headers = [f'User-Agent: {UA}'] if cookie_header: extra_headers.append(f'Cookie: {cookie_header}') cmd = [ str(N_M3U8), hls_url, '--save-dir', str(SAVE_DIR), '--save-name', out_name, '--tmp-dir', TMP_DIR, '--decryption-binary-path', str(MP4DEC), '--mux-after-done', 'format=mkv', '--concurrent-downloads', '16', ] for h in extra_headers: cmd += ['--header', h] cmd += video_args + audio_args + sub_args + key_args print(f'N_m3u8DL-RE: {" ".join(video_args + audio_args + sub_args)}') print('Downloading and decrypting...') result = subprocess.run(cmd) if result.returncode != 0: raise RuntimeError(f'N_m3u8DL-RE failed (exit {result.returncode})') # ── Episode helpers ─────────────────────────────────────────────────────────── def _parse_episode(item: dict) -> dict | None: """Normalise an episode item from the API into a flat dict.""" # The API may return episodes in different shapes cid = item.get('id', '') title = item.get('title', item.get('name', '')) ep_num = item.get('episodeNumber', item.get('number', 0)) sn_num = item.get('seasonNumber', 1) # Some responses nest under 'attributes' attrs = item.get('attributes', {}) if attrs: cid = cid or attrs.get('id', '') title = title or attrs.get('title', attrs.get('name', '')) ep_num = ep_num or attrs.get('episodeNumber', attrs.get('number', 0)) sn_num = sn_num or attrs.get('seasonNumber', 1) if not cid or not title: return None try: ep_num = int(ep_num) except (TypeError, ValueError): ep_num = 0 try: sn_num = int(sn_num) except (TypeError, ValueError): sn_num = 1 return { 'id': cid, 'title': title, 'season': sn_num, 'episode': ep_num, } def select_episode_interactive(show_title: str, episodes: list) -> list: items = ['[All episodes]'] + [ f'S{e["season"]:02d}E{e["episode"]:02d} {e["title"]}' for e in episodes ] print(f'\nShow: {show_title} ({len(episodes)} episodes)') sel = checkbox_picker('Select episode to download', items, single_select=True, default_idx=0) idx = sel[0] if sel else 0 if idx == 0: print('Downloading all episodes.') return list(episodes) ep = episodes[idx - 1] print(f'Downloading S{ep["season"]:02d}E{ep["episode"]:02d}: {ep["title"]}') return [ep] def episode_out_name(ep: dict) -> str: return f's{ep["season"]:02d}e{ep["episode"]:02d}_{safe_filename(ep["title"])}' def download_episode(session, ep: dict, country: str, video_args: list, audio_args: list, cookie_header: str, sub_args: list | None = None, keys_only: bool = False): print(f'\n── S{ep["season"]:02d}E{ep["episode"]:02d}: {ep["title"]} ──') pb = get_playback_info(session, ep['id'], country) hls_url = _extract_hls_url(pb) play_params = _extract_play_params(pb) m3u8_text = fetch_m3u8(hls_url, session) pssh_uris = get_pssh_from_m3u8(m3u8_text, session, base_url=hls_url) print('Fetching content key...') keys = fetch_content_key(pssh_uris, play_params, session) for kid, key in keys.items(): print(f' {kid}:{key}') if keys_only: return out_name = episode_out_name(ep) download_and_decrypt(hls_url, keys, out_name, video_args, audio_args, cookie_header, sub_args=sub_args) print(f'Saved: {SAVE_DIR / out_name}.mkv') # ── CLI ─────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description='Download a DRM-protected video from Apple TV+', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python3 apple_download.py https://tv.apple.com/pl/show/star-city/umc.cmc.2l8p785osmtmiyk64bh6tfde1 python3 apple_download.py -a en -s pl https://tv.apple.com/pl/movie/f1-the-movie/umc.cmc.3t6dvnnr87zwd4wmvpdx5came python3 apple_download.py -u me@icloud.com https://tv.apple.com/... Session is cached in .apple_cookies + .apple_session after first login. Delete those files to force re-login. """, ) parser.add_argument('url', help='https://tv.apple.com/... URL') parser.add_argument('-u', '--username', default=os.environ.get('APPLE_USER')) parser.add_argument('-p', '--password', default=os.environ.get('APPLE_PASS')) parser.add_argument('-t', '--media-token', default=os.environ.get('APPLE_MUT'), metavar='TOKEN', help='media-user-token (from browser DevTools → Cookies → .apple.com)') parser.add_argument('-o', '--output', help='Output filename without extension') parser.add_argument('-v', '--video', default=None, metavar='RES', help='Video resolution e.g. 1920x1080, "best", "worst"') parser.add_argument('-a', '--audio', default=None, metavar='LANGS', help='Audio: comma-separated lang codes, "all", or "none"') parser.add_argument('-s', '--subs', default=None, metavar='LANGS', help='Subtitles: comma-separated lang codes, "all", or "none"') parser.add_argument('--debug', action='store_true', help='Print raw API responses for debugging') parser.add_argument('--keys-only', action='store_true', help='Fetch and print Widevine keys then exit without downloading') args = parser.parse_args() if not WVD: sys.exit('ERROR: No valid .wvd file found in L3/.') # Parse URL try: country, kind, cid = _parse_url(args.url) except ValueError as e: sys.exit(f'ERROR: {e}') print(f'Country: {country.upper()} Type: {kind} ID: {cid}') session = requests.Session() session.headers.update({'User-Agent': UA}) # ── Session restore ─────────────────────────────────────────────────────── meta = load_session(session) if is_session_valid(session, meta): print('Using cached session.') else: # Need to login if not args.username: args.username = input('Apple ID (email): ').strip() if not args.password: args.password = getpass.getpass('Apple ID password: ') print(f'Logging in as {args.username}...') try: token_data = apple_login(session, args.username, args.password) except RuntimeError as e: sys.exit(f'ERROR: Login failed: {e}') meta.update(token_data) meta['username'] = args.username save_session(session, meta) print('Session saved.') # ── media-user-token — set before fetching TV page so utsk is authenticated ─ mut = (args.media_token or meta.get('media_user_token', '') or session.cookies.get('media-user-token', '')) if not mut: # We need dev_token to drive the OAuth flow; fetch the TV page first (anonymous). print('Fetching Apple TV page tokens (anonymous)...') try: dev_token, _ = _get_tv_page_config(session, country) session.headers['Authorization'] = f'Bearer {dev_token}' except RuntimeError as e: sys.exit(f'ERROR: {e}') print('Obtaining media-user-token...') try: mut = get_media_user_token( session, dev_token, username=args.username or meta.get('username', ''), password=args.password or '', ) print('media-user-token obtained.') except RuntimeError as e: print(f'[warn] Could not obtain media-user-token: {e}') if mut: session.cookies.set('media-user-token', mut, domain='.apple.com') session.headers['media-user-token'] = mut meta['media_user_token'] = mut save_session(session, meta) # Fetch TV page with media-user-token set so SSR returns authenticated utsk print('Fetching Apple TV page tokens...') try: dev_token, utsk = _get_tv_page_config(session, country) session.headers['Authorization'] = f'Bearer {dev_token}' print(f'Developer token: {dev_token[:40]}...') if utsk: anon = '::::::' in utsk print(f'utsk: {utsk[:32]}... ({"anonymous" if anon else "authenticated"})') else: print('[warn] utsk not found in TV page — API calls may fail') except RuntimeError as e: sys.exit(f'ERROR: {e}') use_interactive = (args.video is None and args.audio is None and args.subs is None and sys.stdin.isatty()) # Cookie header string for N_m3u8DL-RE cookie_header = '; '.join(f'{c.name}={c.value}' for c in session.cookies) SAVE_DIR.mkdir(exist_ok=True) if kind == 'show': # ── Series flow ─────────────────────────────────────────────────────── print(f'Fetching show metadata for {cid}...') show_data = get_show_metadata(session, cid, country) if args.debug: print('SHOW DATA:', json.dumps(show_data, indent=2)[:2000]) content = show_data.get('content', {}) show_title = content.get('title') or show_data.get('title', cid) # Update cid in case the URL pointed to an episode that was resolved to its show cid = content.get('id', cid) season_ids = list(show_data.get('seasons', {}).keys()) print(f'Show: {show_title} ({len(season_ids)} season(s))') print('Fetching episode list...') ep_raw = get_episodes(session, cid, country, season_ids) if args.debug: print('EPISODES RAW:', json.dumps(ep_raw[:2], indent=2)) episodes = [e for e in (_parse_episode(r) for r in ep_raw) if e] if not episodes: # Sometimes episodes are nested differently for item in ep_raw: for sub in item.get('episodes', item.get('items', [])): ep = _parse_episode(sub) if ep: episodes.append(ep) episodes.sort(key=lambda e: (e['season'], e['episode'])) if not episodes: sys.exit(f'ERROR: No episodes found. Use --debug to inspect the API response.') if sys.stdin.isatty(): selected_eps = select_episode_interactive(show_title, episodes) else: selected_eps = episodes # Fetch first episode's manifest to offer track selection first = selected_eps[0] print(f'\nFetching track list from S{first["season"]:02d}E{first["episode"]:02d}...') pb0 = get_playback_info(session, first['id'], country) hls0 = _extract_hls_url(pb0) m3u8_0 = fetch_m3u8(hls0, session) video_streams, audio_tracks, sub_tracks = parse_m3u8_tracks(m3u8_0) if use_interactive: video_args = select_video_interactive(video_streams) audio_args = select_audio_interactive(audio_tracks) sub_args = select_subs_interactive(sub_tracks) else: v = args.video if v and v not in ('best', 'worst'): video_args = ['--select-video', f'res={v}:for=best'] else: video_args = ['--select-video', v or 'best'] a = args.audio or 'all' if a == 'all': audio_args = ['--select-audio', 'all'] elif a == 'none': audio_args = ['--drop-audio', 'all'] else: langs = '|'.join(x.strip() for x in a.split(',')) audio_args = ['--select-audio', f'lang={langs}:for=best'] s = args.subs if not s: sub_args = ['--drop-subtitle', 'all'] elif s == 'all': sub_args = ['--select-subtitle', 'all'] else: sub_langs = '|'.join(x.strip() for x in s.split(',')) sub_args = ['--select-subtitle', f'lang={sub_langs}:for=best'] print(f'\nDownloading {len(selected_eps)} episode(s)...') for ep in selected_eps: try: download_episode(session, ep, country, video_args, audio_args, cookie_header, sub_args=sub_args, keys_only=args.keys_only) except Exception as e: print(f' ERROR on {ep["title"]}: {e}') print(f'\nAll done. Files in {SAVE_DIR}') else: # ── Movie flow ──────────────────────────────────────────────────────── print(f'Fetching movie metadata for {cid}...') movie_data = get_movie_metadata(session, cid, country) if args.debug: print('MOVIE DATA:', json.dumps(movie_data, indent=2)[:2000]) movie_title = (movie_data.get('content', {}).get('title') or movie_data.get('title', cid)) print(f'Movie: {movie_title}') print('Getting playback info...') pb = get_playback_info(session, cid, country) if args.debug: print('PLAYBACK:', json.dumps(pb, indent=2)[:2000]) hls_url = _extract_hls_url(pb) play_params = _extract_play_params(pb) print(f'HLS: {hls_url[:80]}...') print(f'adamId={play_params["adamId"]} svcId={play_params["svcId"]}') print('Fetching manifest...') m3u8_text = fetch_m3u8(hls_url, session) print('Extracting Widevine PSSH...') pssh_uris = get_pssh_from_m3u8(m3u8_text, session, base_url=hls_url) print(f'PSSH count: {len(pssh_uris)} codec group(s)') video_streams, audio_tracks, sub_tracks = parse_m3u8_tracks(m3u8_text) if use_interactive: video_args = select_video_interactive(video_streams) audio_args = select_audio_interactive(audio_tracks) sub_args = select_subs_interactive(sub_tracks) else: v = args.video if v and v not in ('best', 'worst'): video_args = ['--select-video', f'res={v}:for=best'] else: video_args = ['--select-video', v or 'best'] a = args.audio or 'all' if a == 'all': audio_args = ['--select-audio', 'all'] elif a == 'none': audio_args = ['--drop-audio', 'all'] else: langs = '|'.join(x.strip() for x in a.split(',')) audio_args = ['--select-audio', f'lang={langs}:for=best'] s = args.subs if not s: sub_args = ['--drop-subtitle', 'all'] elif s == 'all': sub_args = ['--select-subtitle', 'all'] else: sub_langs = '|'.join(x.strip() for x in s.split(',')) sub_args = ['--select-subtitle', f'lang={sub_langs}:for=best'] print('Fetching content key...') keys = fetch_content_key(pssh_uris, play_params, session) if not keys: raise RuntimeError('License server returned no content keys.') for kid, key in keys.items(): print(f' {kid}:{key}') if args.keys_only: return out_name = args.output or safe_filename(movie_title) download_and_decrypt(hls_url, keys, out_name, video_args, audio_args, cookie_header, sub_args=sub_args) print(f'Done: {SAVE_DIR / out_name}.mkv') if __name__ == '__main__': main()