262 lines
8.4 KiB
Python
262 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Download a DRM-protected video from CDA.pl (Widevine DASH + DRMtoday)."""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import xml.etree.ElementTree as ET
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import requests
|
||
|
|
from bs4 import BeautifulSoup
|
||
|
|
|
||
|
|
HERE = Path(__file__).parent
|
||
|
|
CREDS_FILE = HERE / '.cda_creds'
|
||
|
|
TOOLS = HERE / 'tools'
|
||
|
|
N_M3U8 = TOOLS / 'N_m3u8DL-RE'
|
||
|
|
MP4DEC = TOOLS / 'mp4decrypt'
|
||
|
|
SAVE_DIR = HERE / 'downloads'
|
||
|
|
TMP_DIR = '/tmp/n_m3u8dl_tmp'
|
||
|
|
|
||
|
|
UA = ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
|
||
|
|
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||
|
|
HEADERS = {
|
||
|
|
'User-Agent': UA,
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'X-Requested-With': 'XMLHttpRequest',
|
||
|
|
}
|
||
|
|
|
||
|
|
WIDEVINE_SYSTEM_ID = 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'
|
||
|
|
|
||
|
|
|
||
|
|
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()
|
||
|
|
|
||
|
|
|
||
|
|
def login(session, username, password):
|
||
|
|
r = session.post(
|
||
|
|
'https://www.cda.pl/login',
|
||
|
|
data={'username': username, 'password': password},
|
||
|
|
headers={'User-Agent': UA},
|
||
|
|
allow_redirects=True,
|
||
|
|
timeout=15,
|
||
|
|
)
|
||
|
|
r.raise_for_status()
|
||
|
|
if 'Zły login lub hasło!' in r.text:
|
||
|
|
raise RuntimeError('Wrong CDA username or password.')
|
||
|
|
if 'Zaznacz pole' in r.text:
|
||
|
|
raise RuntimeError('Login blocked by CAPTCHA — try again later.')
|
||
|
|
|
||
|
|
|
||
|
|
def get_video_id(url):
|
||
|
|
m = re.search(r'cda\.pl/video/([0-9a-z]+)', url)
|
||
|
|
if not m:
|
||
|
|
raise ValueError(f'Cannot extract video ID from URL: {url}')
|
||
|
|
return m.group(1)
|
||
|
|
|
||
|
|
|
||
|
|
def get_stream_info(session, url, video_id):
|
||
|
|
r = session.get(url, headers=HEADERS, timeout=15)
|
||
|
|
r.raise_for_status()
|
||
|
|
soup = BeautifulSoup(r.text, 'html.parser')
|
||
|
|
|
||
|
|
title_tag = soup.find('h1')
|
||
|
|
title = title_tag.get_text(strip=True) if title_tag else video_id
|
||
|
|
|
||
|
|
div = soup.find('div', id=f'mediaplayer{video_id}')
|
||
|
|
if not div:
|
||
|
|
raise RuntimeError(
|
||
|
|
f'mediaplayer div not found for {video_id}. '
|
||
|
|
'Make sure you are logged in with a valid account.'
|
||
|
|
)
|
||
|
|
|
||
|
|
vi = json.loads(div['player_data'])['video']
|
||
|
|
mpd_url = vi.get('manifest')
|
||
|
|
license_url = vi.get('manifest_drm_proxy')
|
||
|
|
drm_header = vi.get('manifest_drm_header') # x-dt-custom-data for DRMtoday
|
||
|
|
|
||
|
|
if not mpd_url:
|
||
|
|
raise RuntimeError('No DASH manifest in player_data — video unavailable.')
|
||
|
|
if not license_url:
|
||
|
|
raise RuntimeError('No Widevine license URL in player_data.')
|
||
|
|
|
||
|
|
return title, mpd_url, license_url, drm_header
|
||
|
|
|
||
|
|
|
||
|
|
def get_pssh_from_mpd(mpd_url, session):
|
||
|
|
r = session.get(mpd_url, headers={'User-Agent': UA}, timeout=15)
|
||
|
|
r.raise_for_status()
|
||
|
|
|
||
|
|
root = ET.fromstring(r.text)
|
||
|
|
# The Widevine UUID is on the ContentProtection schemeIdUri, not on the pssh element.
|
||
|
|
# Walk ContentProtection elements, find the one with the Widevine UUID, grab its pssh child.
|
||
|
|
for cp in root.iter('{urn:mpeg:dash:schema:mpd:2011}ContentProtection'):
|
||
|
|
scheme = cp.get('schemeIdUri', '').lower()
|
||
|
|
if 'edef8ba9' in scheme:
|
||
|
|
pssh_el = cp.find('{urn:mpeg:cenc:2013}pssh')
|
||
|
|
if pssh_el is not None and pssh_el.text:
|
||
|
|
return pssh_el.text.strip()
|
||
|
|
|
||
|
|
raise RuntimeError('No Widevine PSSH found in MPD.')
|
||
|
|
|
||
|
|
|
||
|
|
def fetch_content_key(pssh_b64, license_url, drm_header):
|
||
|
|
from pywidevine.cdm import Cdm
|
||
|
|
from pywidevine.device import Device
|
||
|
|
from pywidevine.pssh import PSSH
|
||
|
|
import uuid as _uuid
|
||
|
|
|
||
|
|
device = Device.load(str(WVD))
|
||
|
|
cdm = Cdm.from_device(device)
|
||
|
|
session_id = cdm.open()
|
||
|
|
challenge = cdm.get_license_challenge(session_id, PSSH(pssh_b64))
|
||
|
|
|
||
|
|
req_headers = {
|
||
|
|
'User-Agent': UA,
|
||
|
|
'Content-Type': 'application/octet-stream',
|
||
|
|
}
|
||
|
|
if drm_header:
|
||
|
|
req_headers['x-dt-custom-data'] = drm_header
|
||
|
|
|
||
|
|
r = requests.post(license_url, data=challenge, headers=req_headers, timeout=15)
|
||
|
|
if not r.ok:
|
||
|
|
raise RuntimeError(f'License server {r.status_code}: {r.text[:300]}')
|
||
|
|
|
||
|
|
cdm.parse_license(session_id, r.content)
|
||
|
|
|
||
|
|
keys = {}
|
||
|
|
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()
|
||
|
|
keys[kid] = key.key.hex()
|
||
|
|
cdm.close(session_id)
|
||
|
|
return keys
|
||
|
|
|
||
|
|
|
||
|
|
def safe_filename(title):
|
||
|
|
title = re.sub(r'[^\w\s-]', '', title)
|
||
|
|
title = re.sub(r'[\s-]+', '_', title).strip('_')
|
||
|
|
return title
|
||
|
|
|
||
|
|
|
||
|
|
def download_and_decrypt(mpd_url, keys, out_name, session):
|
||
|
|
key_args = []
|
||
|
|
for kid, key in keys.items():
|
||
|
|
key_args += ['--key', f'{kid}:{key}']
|
||
|
|
|
||
|
|
cookie_str = '; '.join(f'{k}={v}' for k, v in session.cookies.items())
|
||
|
|
|
||
|
|
cmd = [
|
||
|
|
str(N_M3U8),
|
||
|
|
mpd_url,
|
||
|
|
'--save-dir', str(SAVE_DIR),
|
||
|
|
'--save-name', out_name,
|
||
|
|
'--tmp-dir', TMP_DIR,
|
||
|
|
'--decryption-binary-path', str(MP4DEC),
|
||
|
|
'--mux-after-done', 'format=mkv',
|
||
|
|
'--header', f'User-Agent: {UA}',
|
||
|
|
'--header', f'Cookie: {cookie_str}',
|
||
|
|
] + key_args
|
||
|
|
|
||
|
|
print('Downloading and decrypting...')
|
||
|
|
result = subprocess.run(cmd)
|
||
|
|
if result.returncode != 0:
|
||
|
|
raise RuntimeError(f'N_m3u8DL-RE failed (exit {result.returncode})')
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description='Download a DRM-protected video from CDA.pl',
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
|
|
epilog="""
|
||
|
|
Examples:
|
||
|
|
python3 cda_download.py -u mylogin -p mypass https://www.cda.pl/video/56706683e/vfilm
|
||
|
|
|
||
|
|
Credentials via env vars (avoids password in shell history):
|
||
|
|
export CDA_USER=mylogin
|
||
|
|
read -rs CDA_PASS && export CDA_PASS
|
||
|
|
python3 cda_download.py https://www.cda.pl/video/56706683e/vfilm
|
||
|
|
""",
|
||
|
|
)
|
||
|
|
parser.add_argument('url', help='https://www.cda.pl/video/... URL')
|
||
|
|
parser.add_argument('-u', '--username', default=os.environ.get('CDA_USER'),
|
||
|
|
help='CDA login email (or set CDA_USER env var)')
|
||
|
|
parser.add_argument('-p', '--password', default=os.environ.get('CDA_PASS'),
|
||
|
|
help='CDA password (or set CDA_PASS env var)')
|
||
|
|
parser.add_argument('-o', '--output',
|
||
|
|
help='Output filename without extension. Default: sanitized video title.')
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
if not WVD:
|
||
|
|
sys.exit('ERROR: No valid .wvd file found in L3/. Cannot decrypt Widevine content.')
|
||
|
|
|
||
|
|
# Load saved credentials if not provided via flags or env vars
|
||
|
|
if not args.username or not args.password:
|
||
|
|
if CREDS_FILE.exists():
|
||
|
|
saved = json.loads(CREDS_FILE.read_text())
|
||
|
|
args.username = args.username or saved.get('username', '')
|
||
|
|
args.password = args.password or saved.get('password', '')
|
||
|
|
|
||
|
|
import getpass
|
||
|
|
prompted = False
|
||
|
|
if not args.username:
|
||
|
|
args.username = input('CDA username (email): ').strip()
|
||
|
|
prompted = True
|
||
|
|
if not args.password:
|
||
|
|
args.password = getpass.getpass('CDA password: ')
|
||
|
|
prompted = True
|
||
|
|
|
||
|
|
session = requests.Session()
|
||
|
|
|
||
|
|
print(f'Logging in as {args.username}...')
|
||
|
|
login(session, args.username, args.password)
|
||
|
|
print('Logged in.')
|
||
|
|
|
||
|
|
if prompted:
|
||
|
|
CREDS_FILE.write_text(json.dumps({'username': args.username, 'password': args.password}))
|
||
|
|
CREDS_FILE.chmod(0o600)
|
||
|
|
print(f'Credentials saved to {CREDS_FILE}')
|
||
|
|
|
||
|
|
video_id = get_video_id(args.url)
|
||
|
|
print(f'Video ID: {video_id}')
|
||
|
|
|
||
|
|
print('Fetching player data...')
|
||
|
|
title, mpd_url, license_url, drm_header = get_stream_info(session, args.url, video_id)
|
||
|
|
print(f'Title: {title}')
|
||
|
|
print(f'MPD: {mpd_url}')
|
||
|
|
print(f'License: {license_url}')
|
||
|
|
|
||
|
|
print('Extracting PSSH from MPD...')
|
||
|
|
pssh_b64 = get_pssh_from_mpd(mpd_url, session)
|
||
|
|
print(f'PSSH: {pssh_b64[:48]}...')
|
||
|
|
|
||
|
|
print('Fetching content key...')
|
||
|
|
keys = fetch_content_key(pssh_b64, license_url, drm_header)
|
||
|
|
if not keys:
|
||
|
|
raise RuntimeError('License server returned no content keys.')
|
||
|
|
for kid, key in keys.items():
|
||
|
|
print(f' {kid}:{key}')
|
||
|
|
|
||
|
|
SAVE_DIR.mkdir(exist_ok=True)
|
||
|
|
out_name = args.output if args.output else safe_filename(title)
|
||
|
|
|
||
|
|
download_and_decrypt(mpd_url, keys, out_name, session)
|
||
|
|
print(f'Done: {SAVE_DIR / out_name}.mkv')
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
main()
|