Files
video_extractor/sky_download.py
T

1532 lines
58 KiB
Python
Raw Normal View History

2026-08-08 12:35:28 +02:00
#!/usr/bin/env python3
"""Download a DRM-protected video from SkyShowtime (Widevine DASH)."""
import argparse
import base64
import curses
import getpass
import hashlib
import hmac
import json
import os
import random
import re
import string
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import urlparse
import requests
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'
CREDS_FILE = HERE / '.sky_creds'
SESSION_FILE = HERE / '.sky_session'
class TokenExpiredError(RuntimeError):
pass
WIDEVINE_SYSTEM_ID = 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'
MPD_NS = '{urn:mpeg:dash:schema:mpd:2011}'
CENC_NS = '{urn:mpeg:cenc:2013}'
APP_ID = 'SKYSHOWTIME-ANDROID-v1'
SIGNATURE_KEY = base64.b64decode('amZqOXFHZzZhREhhQmJGcEg2d05Fdk42Y0h1SHRaVnBwSFJ2Qmdacw==')
UA = 'SkyShowtimeAndroid-GLOBAL/4.2.12-121040212'
HOST = 'skyshowtime.com'
OVP = f'https://ovp.{HOST}'
RANGO = f'https://rango.id.{HOST}'
ATOM = f'https://atom.{HOST}'
WEBCLIENTS = f'https://web.clients.{HOST}'
SKY_HEADERS = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'User-Agent': UA,
'x-skyott-activeterritory': 'PL',
'x-skyott-client-version': '4.3.12',
'x-skyott-device': 'MOBILE',
'x-skyott-language': 'pl-PL',
'x-skyott-platform': 'ANDROID',
'x-skyott-proposition': 'SKYSHOWTIME',
'x-skyott-provider': 'SKYSHOWTIME',
'x-skyott-territory': 'PL',
}
LANG_NAMES = {
'ar': 'Arabic', 'bg': 'Bulgarian', 'bg-BG': 'Bulgarian',
'cs': 'Czech', 'cs-CZ': 'Czech',
'da': 'Danish', 'da-DK': 'Danish',
'de': 'German', 'de-DE': 'German',
'el': 'Greek', 'el-GR': 'Greek',
'en': 'English', 'en-US': 'English (US)', 'en-GB': 'English (UK)',
'es': 'Spanish', 'es-ES': 'Spanish (Spain)', 'es-419': 'Spanish (Latam)',
'et': 'Estonian', 'et-EE': 'Estonian',
'fi': 'Finnish', 'fi-FI': 'Finnish',
'fr': 'French', 'fr-FR': 'French',
'hr': 'Croatian', 'hr-HR': 'Croatian',
'hu': 'Hungarian', 'hu-HU': 'Hungarian',
'it': 'Italian', 'it-IT': 'Italian',
'ja': 'Japanese', 'ja-JP': 'Japanese',
'ko': 'Korean', 'ko-KR': 'Korean',
'lt': 'Lithuanian', 'lt-LT': 'Lithuanian',
'lv': 'Latvian', 'lv-LV': 'Latvian',
'nl': 'Dutch', 'nl-NL': 'Dutch',
'nb': 'Norwegian', 'nb-NO': 'Norwegian',
'pl': 'Polish', 'pl-PL': 'Polish',
'pt': 'Portuguese', 'pt-PT': 'Portuguese (PT)', 'pt-BR': 'Portuguese (BR)',
'ro': 'Romanian', 'ro-RO': 'Romanian',
'ru': 'Russian', 'ru-RU': 'Russian',
'sk': 'Slovak', 'sk-SK': 'Slovak',
'sl': 'Slovenian', 'sl-SI': 'Slovenian',
'sr': 'Serbian', 'sr-Latn-ME': 'Montenegrin',
'sv': 'Swedish', 'sv-SE': 'Swedish',
'tr': 'Turkish', 'tr-TR': 'Turkish',
'uk': 'Ukrainian', 'uk-UA': 'Ukrainian',
'und': 'Undetermined',
'zh': 'Chinese', 'zh-CN': 'Chinese (Simp.)', 'zh-TW': 'Chinese (Trad.)',
}
def lang_name(code):
return LANG_NAMES.get(code, code)
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 sign(method, url, headers, payload=b''):
"""Calculate x-sky-signature HMAC-SHA1 header."""
timestamp = int(time.time())
path = urlparse(url).path if url.startswith('http') else url
text_headers = ''
for key in sorted(headers.keys()):
if key.lower().startswith('x-skyott'):
text_headers += key.lower() + ': ' + headers[key] + '\n'
headers_md5 = hashlib.md5(text_headers.encode()).hexdigest()
if isinstance(payload, str):
payload = payload.encode('utf-8')
payload_md5 = hashlib.md5(payload).hexdigest()
to_hash = f'{method}\n{path}\n\n{APP_ID}\n1.0\n{headers_md5}\n{timestamp}\n{payload_md5}\n'
digest = hmac.new(SIGNATURE_KEY, to_hash.encode('utf-8'), hashlib.sha1).digest()
sig = base64.b64encode(digest).decode('utf-8')
return {'x-sky-signature': f'SkyOTT client="{APP_ID}",signature="{sig}",timestamp="{timestamp}",version="1.0"'}
def login(session, username, password):
url = f'{RANGO}/signin/service/international'
headers = {k: v for k, v in SKY_HEADERS.items()
if k not in ('x-skyott-device', 'x-skyott-platform')}
headers['content-type'] = 'application/x-www-form-urlencoded'
headers['Accept'] = 'application/vnd.siren+json'
r = session.post(url, data={
'userIdentifier': username,
'password': password,
'rememberMe': True,
'isWeb': True,
}, headers=headers, timeout=15)
try:
data = r.json()
except Exception:
raise RuntimeError(f'Login response is not JSON (status {r.status_code}): {r.text[:200]}')
if data.get('properties', {}).get('eventType') != 'success':
props = data.get('properties', {})
msg = props.get('userMessage')
if not msg:
errors = props.get('errors', {})
for err in errors.get('categoryErrors', []) + errors.get('fieldErrors', []):
if err.get('message'):
msg = f"{err['message']} (code: {err.get('code', '?')})"
break
if not msg:
msg = str(data)[:300]
raise RuntimeError(f'Login failed: {msg}')
cookie_str = '; '.join(f'{k}={v}' for k, v in r.cookies.items())
return cookie_str
def detect_territory(cookie_str):
m = re.search(r'(?:hterr|userTerritory|activeTerritory)=([A-Z]{2})', cookie_str)
return m.group(1) if m else None
def get_profile_id(session, cookie_str):
url = f'{WEBCLIENTS}/bff/personas/v2'
headers = SKY_HEADERS.copy()
headers['content-type'] = 'application/json'
headers['cookie'] = cookie_str
r = session.post(url, data='', headers=headers, timeout=15)
data = r.json()
personas = data.get('personas', [])
if not personas:
raise RuntimeError(f'No profiles found: {data}')
return personas[0]['id']
def get_user_token(session, cookie_str, profile_id, device_id):
url = f'{OVP}/auth/throttled/tokens'
post_data = json.dumps({
'auth': {
'authScheme': 'MESSO',
'authIssuer': 'NOWTV',
'personaId': profile_id,
'provider': SKY_HEADERS['x-skyott-provider'],
'providerTerritory': SKY_HEADERS['x-skyott-territory'],
'proposition': SKY_HEADERS['x-skyott-proposition'],
},
'device': {
'type': 'MOBILE',
'platform': 'ANDROID',
'id': device_id,
'drmDeviceId': 'UNKNOWN',
}
})
headers = {
'Accept': 'application/vnd.tokens.v1+json',
'Content-Type': 'application/vnd.tokens.v1+json',
'cookie': cookie_str,
}
headers.update(sign('POST', url, {}, post_data))
r = session.post(url, data=post_data, headers=headers, timeout=15)
data = r.json()
if 'userToken' not in data:
raise RuntimeError(f'Failed to get user token: {data}')
return data['userToken']
def get_video_info(session, uuid):
url = (f'{ATOM}/adapter-calypso/v3/query/nodes/uuid/{uuid}'
f'?exclude=expired%2Cfuture%2Cshortform')
r = session.get(url, headers=SKY_HEADERS, timeout=15)
data = r.json()
if not data:
raise RuntimeError(f'No video info returned for UUID {uuid}')
att = data[0]['attributes']
title = att.get('title', uuid)
provider_variant_id = att.get('providerVariantId')
content_id = None
for fmt in ('HD', 'SD'):
if fmt in att.get('formats', {}):
content_id = att['formats'][fmt].get('contentId')
if content_id:
break
if not content_id:
raise RuntimeError('No contentId found in video info')
return title, content_id, provider_variant_id
def get_playback_info(session, user_token, content_id, provider_variant_id):
url = f'{OVP}/video/playouts/vod'
headers = SKY_HEADERS.copy()
headers['Accept'] = 'application/vnd.playvod.v1+json'
headers['Content-Type'] = 'application/vnd.playvod.v1+json'
headers['x-skyott-usertoken'] = user_token
post_data = json.dumps({
'device': {
'capabilities': [
{'protection': 'WIDEVINE', 'container': 'ISOBMFF',
'transport': 'DASH', 'acodec': 'AAC', 'vcodec': 'H264'},
{'protection': 'NONE', 'container': 'ISOBMFF',
'transport': 'DASH', 'acodec': 'AAC', 'vcodec': 'H264'},
],
'maxVideoFormat': 'HD',
'model': 'Pixel',
'hdcpEnabled': False,
'supportedColourSpaces': ['SDR'],
},
'client': {'thirdParties': ['FREEWHEEL']},
'parentalControlPin': 'null',
'personaParentalControlRating': '19',
'contentId': content_id,
'providerVariantId': provider_variant_id,
})
headers.update(sign('POST', url, headers, post_data))
r = session.post(url, data=post_data, headers=headers, timeout=15)
data = r.json()
if r.status_code in (401, 403) or (
'asset' not in data and
data.get('status', 0) in (401, 403)):
raise TokenExpiredError(f'User token rejected (HTTP {r.status_code}): {json.dumps(data)[:200]}')
if 'asset' not in data:
raise RuntimeError(f'No asset in playback response: {json.dumps(data)[:400]}')
manifest_url = data['asset']['endpoints'][0]['url']
manifest_url += '&audio=all&subtitle=all&forcedNarrative=true&trickplay=true'
license_url = data['protection']['licenceAcquisitionUrl']
return manifest_url, license_url
def fetch_mpd(mpd_url, session):
"""Fetch MPD and return (text, parsed root element)."""
r = session.get(mpd_url, headers={'User-Agent': UA}, timeout=15)
r.raise_for_status()
return r.text, ET.fromstring(r.text)
def get_pssh_from_root(root):
"""Extract Widevine PSSH base64 from a parsed MPD root element."""
for cp in root.iter(f'{MPD_NS}ContentProtection'):
if 'edef8ba9' in cp.get('schemeIdUri', '').lower():
pssh_el = cp.find(f'{CENC_NS}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 _stream_path(as_el, codec, ch_value, lang):
"""Extract or derive the stream path used in N_m3u8DL-RE's stream name.
N_m3u8DL-RE names streams as "r{id}/{path}" (e.g. r21/audio/surround/en-US).
We derive the path so we can use the name= filter to select individual tracks.
"""
# 1. BaseURL within AdaptationSet (most reliable)
bu = as_el.find(f'{MPD_NS}BaseURL')
if bu is not None and bu.text:
text = bu.text.strip().rstrip('/')
if '://' not in text and text:
return text
# 2. SegmentTemplate initialization / media path
st = as_el.find(f'{MPD_NS}SegmentTemplate')
if st is not None:
for attr in ('initialization', 'media'):
raw = st.get(attr, '').lstrip('/')
if raw:
parts = raw.split('/')
clean = []
for p in parts:
if '$' in p or '.' in p:
break
clean.append(p)
if clean:
return '/'.join(clean)
# 3. Construct from codec + channel + lang (SkyShowtime CMAF convention)
if 'ec-3' in codec.lower():
fmt = 'surround' if ch_value.upper().startswith('F') else 'stereo-eac3'
elif 'mp4a' in codec.lower():
fmt = 'stereo'
else:
fmt = 'audio'
return f'audio/{fmt}/{lang}'
def parse_mpd_tracks(root):
"""Parse video, audio AdaptationSets and subtitle tracks from MPD.
Returns:
video_streams: list of dicts per Representation (sorted best-first):
{width, height, kbps, codec}
audio_tracks: list of dicts per AdaptationSet (individual format variant):
{lang, lang_disp, fmt_type, codec_disp, kbps, ch_value, name_pattern}
sub_langs: dict {lang_code: {'forced_only': bool}}
sub_tracks: list of (lang, is_forced) in MPD order — index N here maps to
ffmpeg stream 0:s:N when downloading from the same MPD
"""
video_streams = []
audio_tracks = []
sub_langs = {}
sub_tracks = [] # (lang, is_forced) in MPD AdaptationSet order
for as_el in root.iter(f'{MPD_NS}AdaptationSet'):
mime = as_el.get('mimeType', '')
ctype = as_el.get('contentType', '')
lang = as_el.get('lang', 'und')
if ctype == 'video' or mime.startswith('video/'):
# Skip thumbnail / trickplay tracks
if any(r.get('value', '') in ('thumbnail', 'trick', 'trickplay')
for r in as_el.iter(f'{MPD_NS}Role')):
continue
as_codec = as_el.get('codecs', '')
for rep in as_el.iter(f'{MPD_NS}Representation'):
w = int(rep.get('width', 0))
h = int(rep.get('height', 0))
bw = int(rep.get('bandwidth', 0)) // 1000
codec = rep.get('codecs', '') or as_codec
if w > 0 and h > 0 and bw >= 200:
video_streams.append({'width': w, 'height': h, 'kbps': bw, 'codec': codec})
elif ctype == 'audio' or mime.startswith('audio/'):
# Best (highest bandwidth) representation gives codec + kbps
max_bw = 0
codec = as_el.get('codecs', '')
for rep in as_el.iter(f'{MPD_NS}Representation'):
bw = int(rep.get('bandwidth', 0))
if bw > max_bw:
max_bw = bw
c = rep.get('codecs', '')
if c:
codec = c
kbps = max_bw // 1000
# Channel configuration value
ch_value = ''
for acc in as_el.iter(f'{MPD_NS}AudioChannelConfiguration'):
ch_value = acc.get('value', '')
if ch_value:
break
# Human-readable format type
if 'ec-3' in codec.lower():
codec_disp = 'EC-3'
fmt_type = 'Surround 5.1' if ch_value.upper().startswith('F') else 'Stereo'
elif 'mp4a' in codec.lower():
codec_disp = 'AAC'
try:
fmt_type = 'Stereo' if int(ch_value) <= 2 else f'Surround {ch_value}ch'
except (ValueError, TypeError):
fmt_type = 'Stereo'
else:
codec_disp = codec
fmt_type = ''
# Path used in segment URLs (e.g. "audio/surround/en-US")
name_pattern = _stream_path(as_el, codec, ch_value, lang)
audio_tracks.append({
'lang': lang,
'lang_disp': lang_name(lang),
'fmt_type': fmt_type,
'codec_disp': codec_disp,
'kbps': kbps,
'ch_value': ch_value,
'name_pattern': name_pattern,
})
elif ctype == 'text' or mime.startswith('text/'):
roles = list(as_el.iter(f'{MPD_NS}Role'))
is_forced = bool(roles) and all(
r.get('value', '') in ('forced', 'forcedSubtitle') for r in roles
)
sub_tracks.append((lang, is_forced))
if lang not in sub_langs:
sub_langs[lang] = {'forced_only': True}
if not roles or any(r.get('value', '') not in ('forced', 'forcedSubtitle') for r in roles):
sub_langs[lang]['forced_only'] = False
# Sort video best-first (highest resolution, then highest bitrate)
video_streams.sort(key=lambda x: (-x['width'], -x['height'], -x['kbps']))
return video_streams, audio_tracks, sub_langs, sub_tracks
def checkbox_picker(title, items, default_all=True, single_select=False, default_idx=None):
"""Interactive curses checkbox / radio-button picker.
single_select: if True, Space selects one item and deselects all others.
default_idx: pre-select this index only (overrides default_all).
Returns list of selected indices.
"""
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])
if single_select:
hint = '↑↓ move SPACE select ENTER=confirm'
else:
hint = '↑↓ 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
y = i + 4
mark = ('(◉)' if selected[idx] else '( )') if single_select else ('[✓]' if selected[idx] else '[ ]')
line = f' {mark} {items[idx]}'[:w - 1]
try:
attr = curses.color_pair(1) if idx == cursor else 0
stdscr.addstr(y, 0, line, attr)
except curses.error:
pass
if not single_select:
sel_count = sum(selected)
try:
stdscr.addstr(h - 1, 0,
f' {sel_count} 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:
fallback = default_idx if default_idx is not None else 0
return [fallback] if fallback < len(items) else [0]
return list(range(len(items)))
return [i for i, s in enumerate(selected) if s]
def _audio_select_args(lang_pattern, sel_fmts, all_fmts, audio_tracks):
"""Build the most precise single --select-audio filter achievable.
N_m3u8DL-RE only accepts --select-audio once and ch= is ignored for audio.
Confirmed working attributes: lang=, codecs=, bwMin=, bwMax= (all in Kbps).
Strategy per selected format combination:
• All formats → lang=X:for=all
• AAC only → lang=X:codecs=mp4a:for=all
• All EC-3 → lang=X:codecs=ec-3:for=all
• High-bw EC-3 only → lang=X:codecs=ec-3:bwMin=T:for=all (surround tier)
• Low-bw EC-3 only → lang=X:codecs=ec-3:bwMax=T:for=all (stereo-EC-3 tier)
• Mixed codecs → lang=X:for=all (can't express cross-codec OR in one filter)
"""
if not sel_fmts:
return ['--drop-audio', 'all']
if len(sel_fmts) == len(all_fmts):
return ['--select-audio', f'lang={lang_pattern}:for=all']
sel_ec3 = [f for f in sel_fmts if f.get('codec_disp') == 'EC-3']
sel_aac = [f for f in sel_fmts if f.get('codec_disp') == 'AAC']
all_ec3 = [f for f in all_fmts if f.get('codec_disp') == 'EC-3']
# AAC only
if sel_aac and not sel_ec3:
return ['--select-audio', f'lang={lang_pattern}:codecs=mp4a:for=all']
# EC-3 only (some or all EC-3 tiers)
if sel_ec3 and not sel_aac:
if len(sel_ec3) == len(all_ec3):
return ['--select-audio', f'lang={lang_pattern}:codecs=ec-3:for=all']
# Multiple EC-3 tiers exist — distinguish by bitrate threshold
ec3_bws = sorted(set(f['kbps'] for f in all_ec3), reverse=True) # [388, 132]
sel_bws = {f['kbps'] for f in sel_ec3}
if len(ec3_bws) >= 2:
threshold = (ec3_bws[0] + ec3_bws[-1]) // 2 # e.g. 260 Kbps
hi_selected = any(bw >= ec3_bws[0] - 20 for bw in sel_bws)
lo_selected = any(bw <= ec3_bws[-1] + 20 for bw in sel_bws)
if hi_selected and not lo_selected:
return ['--select-audio', f'lang={lang_pattern}:codecs=ec-3:bwMin={threshold}:for=all']
if lo_selected and not hi_selected:
return ['--select-audio', f'lang={lang_pattern}:codecs=ec-3:bwMax={threshold}:for=all']
return ['--select-audio', f'lang={lang_pattern}:codecs=ec-3:for=all']
# Mixed EC-3 + AAC: can't express the cross-codec OR, fall back to all langs
return ['--select-audio', f'lang={lang_pattern}:for=all']
def select_tracks_interactive(audio_tracks, sub_langs):
"""Show pickers for audio languages + format types, and subtitle languages.
Audio selection is two steps:
1. Which languages to include?
2. Which format types to include?
Returns (audio_args, sel_sub_langs) where sel_sub_langs is a set of
selected language codes (empty set = no subtitles).
"""
# ── Build unique language list (preserving MPD order) ──────────────────
seen_langs: set = set()
lang_list: list = []
for t in audio_tracks:
if t['lang'] not in seen_langs:
seen_langs.add(t['lang'])
lang_list.append(t['lang'])
lang_items = [f'{l:<9} {lang_name(l)}' for l in lang_list]
# ── Build unique format-type list (preserving MPD order) ───────────────
seen_fmt: set = set()
fmt_list: list = []
for t in audio_tracks:
key = (t['fmt_type'], t['codec_disp'], t['ch_value'])
if key not in seen_fmt:
seen_fmt.add(key)
fmt_list.append({
'fmt_type': t['fmt_type'],
'codec_disp': t['codec_disp'],
'ch_value': t['ch_value'],
'kbps': t['kbps'],
})
fmt_items = []
for f in fmt_list:
kbps_str = f'~{f["kbps"]} Kbps' if f['kbps'] else ''
fmt_items.append(
f'{f["fmt_type"]:<16} {f["codec_disp"]:<8}' + (f' {kbps_str}' if kbps_str else '')
)
# ── Audio picker ────────────────────────────────────────────────────────
print()
audio_args = ['--drop-audio', 'all']
if lang_items and fmt_items:
sel_lang_idx = checkbox_picker('Select audio languages', lang_items, default_all=False)
sel_fmt_idx = checkbox_picker('Select audio format types', fmt_items, default_all=False)
sel_langs = [lang_list[i] for i in sel_lang_idx]
sel_fmts = [fmt_list[i] for i in sel_fmt_idx]
if sel_langs and sel_fmts:
lang_pattern = '|'.join(sel_langs)
audio_args = _audio_select_args(lang_pattern, sel_fmts, fmt_list, audio_tracks)
fmt_labels = ', '.join(f'{f["fmt_type"]} ({f["codec_disp"]})' for f in sel_fmts)
print(f'Audio: {", ".join(sel_langs)} — {fmt_labels}')
elif sel_langs:
lang_pattern = '|'.join(sel_langs)
audio_args = ['--select-audio', f'lang={lang_pattern}:for=all']
print(f'Audio: {", ".join(sel_langs)} — all formats')
else:
print('Audio: none')
# ── Subtitle picker ─────────────────────────────────────────────────────
sub_keys = list(sub_langs.keys())
sub_items = []
for lang in sub_keys:
note = ' (forced only)' if sub_langs[lang]['forced_only'] else ''
sub_items.append(f'{lang:<9} {lang_name(lang)}{note}')
print()
sel_sub_langs: set = set()
if sub_items:
sel_idx = checkbox_picker('Select subtitle tracks', sub_items, default_all=False)
sel_subs = [sub_keys[i] for i in sel_idx]
if sel_subs:
sel_sub_langs = set(sel_subs)
print(f'Subtitles: {", ".join(sel_subs)}')
else:
print('Subtitles: none')
return audio_args, sel_sub_langs
def select_video_interactive(video_streams):
"""Show single-select picker for video quality. Returns N_m3u8DL-RE --select-video args."""
if not video_streams:
return ['--select-video', 'best']
# Count streams per resolution to detect duplicates
from collections import Counter
res_count = Counter(f'{v["width"]}x{v["height"]}' for v in video_streams)
items = []
for vs in video_streams:
codec = vs.get('codec', '')
if 'hvc' in codec.lower() or 'hev' in codec.lower() or 'dvh' in codec.lower():
codec_name = 'H.265'
elif 'avc' in codec.lower():
codec_name = 'H.264'
else:
codec_name = codec.split('.')[0] if codec else '?'
items.append(f'{vs["width"]}×{vs["height"]:<9} {vs["kbps"]:>5} Kbps {codec_name}')
print()
sel_idx = checkbox_picker('Select video quality', items, single_select=True, default_idx=0)
if not sel_idx:
return ['--select-video', 'best']
chosen = video_streams[sel_idx[0]]
w, h, kbps = chosen['width'], chosen['height'], chosen['kbps']
res_str = f'{w}x{h}'
if res_count[res_str] > 1:
# Duplicate resolution: use bitrate range (±20 %) to single out this tier.
# N_m3u8DL-RE bwMin/bwMax are in Kbps.
bw_min = int(kbps * 0.8)
filter_str = f'res={res_str}:bwMin={bw_min}:for=best'
else:
filter_str = f'res={res_str}:for=best'
print(f'Video: {w}×{h} {kbps} Kbps')
return ['--select-video', filter_str]
def langs_to_select_args(select_flag, drop_flag, langs):
"""Build N_m3u8DL-RE stream selection args from a list of language codes."""
if not langs:
return [drop_flag, 'all']
pattern = '|'.join(langs)
return [select_flag, f'lang={pattern}:for=all']
def cli_spec_to_select_args(select_flag, drop_flag, spec):
"""Build N_m3u8DL-RE stream selection args from a CLI spec string.
spec='all' → select all
spec='none' → drop all
spec='pl-PL,en-US' → select by language
"""
spec = spec.strip()
if spec == 'all':
return [select_flag, 'all']
if spec == 'none':
return [drop_flag, 'all']
langs = [s.strip() for s in spec.split(',') if s.strip()]
return langs_to_select_args(select_flag, drop_flag, langs)
def fetch_content_key(pssh_b64, license_url):
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))
path = '/' + license_url.split('://', 1)[1].split('/', 1)[1]
req_headers = {'User-Agent': UA, 'Accept': '*/*'}
req_headers.update(sign('POST', path, {}, challenge))
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 extract_uuid(url):
m = re.search(r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
url, re.IGNORECASE)
if not m:
raise ValueError(f'Cannot extract UUID from URL: {url}')
return m.group(1)
def safe_filename(title):
title = re.sub(r'[^\w\s-]', '', title)
title = re.sub(r'[\s-]+', '_', title).strip('_')
return title
_ISO639_2_TO_1 = {
'pol': 'pl', 'eng': 'en', 'deu': 'de', 'ger': 'de', 'fra': 'fr', 'fre': 'fr',
'spa': 'es', 'ita': 'it', 'por': 'pt', 'rus': 'ru', 'ces': 'cs', 'cze': 'cs',
'slk': 'sk', 'slv': 'sl', 'hun': 'hu', 'ron': 'ro', 'rum': 'ro', 'bul': 'bg',
'hrv': 'hr', 'srp': 'sr', 'nob': 'nb', 'nor': 'nb', 'dan': 'da', 'swe': 'sv',
'fin': 'fi', 'nld': 'nl', 'dut': 'nl', 'tur': 'tr', 'ukr': 'uk', 'ara': 'ar',
'zho': 'zh', 'chi': 'zh', 'jpn': 'ja', 'kor': 'ko', 'ell': 'el', 'gre': 'el',
'lat': 'la', 'und': 'und',
}
def _lang_tag_to_iso639_1(tag):
"""Return the ISO 639-1 two-letter code for a language tag.
Handles ISO 639-2 three-letter codes (pol→pl), BCP-47 (pl-PL→pl),
and bare ISO 639-1 (pl→pl).
"""
t = tag.strip().lower()
if t in _ISO639_2_TO_1:
return _ISO639_2_TO_1[t]
return t.split('-')[0].split('_')[0]
def _parse_vtt_ts(ts):
"""Convert a VTT timestamp (HH:MM:SS.mmm or MM:SS.mmm) to SRT format (HH:MM:SS,mmm)."""
ts = ts.strip()
parts = ts.split(':')
if len(parts) == 3:
h, m, s = parts
elif len(parts) == 2:
h, m, s = '0', parts[0], parts[1]
else:
return None
return f'{int(h):02d}:{m}:{s.replace(".", ",")}'
def _vtt_to_srt(vtt_text):
"""Convert merged WebVTT text to SRT.
Handles STYLE/NOTE/REGION blocks, X-TIMESTAMP-MAP headers, and VTT-specific
inline tags (<c>, <v>, <ruby>) — none of which ffmpeg's WebVTT parser
handles reliably when all segments are merged into one file.
"""
lines = vtt_text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
n = len(lines)
i = 0
# Skip WEBVTT header line and any header-metadata lines (X-TIMESTAMP-MAP etc.)
if i < n and lines[i].startswith('WEBVTT'):
i += 1
while i < n and lines[i].strip():
i += 1 # skip header continuation until first blank
cues = []
while i < n:
line = lines[i].strip()
if not line:
i += 1
continue
# Skip block types that are not cues
if line in ('STYLE', 'REGION') or line.startswith('NOTE'):
i += 1
while i < n and lines[i].strip():
i += 1
continue
# Determine if this line is a timestamp or a cue identifier
if '-->' in line:
ts_line = line
else:
# Treat as cue ID; the next non-empty line should be the timestamp
i += 1
while i < n and not lines[i].strip():
i += 1
if i >= n or '-->' not in lines[i]:
continue
ts_line = lines[i].strip()
# Parse "start --> end [settings]"
arrow = ts_line.index('-->')
start_srt = _parse_vtt_ts(ts_line[:arrow])
end_srt = _parse_vtt_ts(ts_line[arrow + 3:].split()[0])
if not start_srt or not end_srt:
i += 1
continue
# Read cue text lines until blank line
i += 1
text_lines = []
while i < n and lines[i].strip():
cue_line = lines[i].strip()
# Strip WebVTT-specific tags: <c.class>, </c>, <v Name>, <ruby>, <rt>
cue_line = re.sub(r'</?c(\.[^>]*)?>', '', cue_line)
cue_line = re.sub(r'<v [^>]+>', '', cue_line)
cue_line = re.sub(r'</?r(?:uby|[bt])[^>]*>', '', cue_line)
# Strip timestamp tags <HH:MM:SS.mmm>
cue_line = re.sub(r'<\d{2}:\d{2}:\d{2}\.\d{3}>', '', cue_line)
cue_line = cue_line.strip()
if cue_line:
text_lines.append(cue_line)
i += 1
if text_lines:
cues.append((start_srt, end_srt, text_lines))
parts = [
f'{num}\n{s} --> {e}\n' + '\n'.join(t)
for num, (s, e, t) in enumerate(cues, 1)
]
return '\n\n'.join(parts) + ('\n' if parts else '')
def _dash_segment_url(tmpl, rep_id, number=None, time=None):
"""Expand a DASH SegmentTemplate media URL."""
def _replace(m):
name = m.group(1)
fmt = m.group(2) # e.g. '%06d' captured by (%\d+[diouxX])?, or None
if name == 'RepresentationID':
return rep_id
if name == 'Number' and number is not None:
return (fmt % number) if fmt else str(number)
if name == 'Time' and time is not None:
return (fmt % time) if fmt else str(time)
return m.group(0)
return re.sub(r'\$(\w+)(%\d+[diouxX])?\$', _replace, tmpl)
def download_subs_as_srt(mpd_url, mpd_root, sub_tracks, sel_sub_langs, out_name, session):
"""Download subtitle tracks by fetching VTT segments, then convert to SRT.
Avoids ffmpeg's DASH demuxer (which mishandles presentationTimeOffset on
subtitle tracks). Instead, segment URLs are built from the MPD and each
segment is fetched via the authenticated session.
Plex naming convention:
{out_name}.{lang}.srt — regular subtitles
{out_name}.{lang}.forced.srt — forced subtitles (foreign dialogue only)
"""
if not sel_sub_langs:
return
# MPD base URL for resolving relative segment paths
mpd_no_qs = mpd_url.split('?')[0]
mpd_base = mpd_no_qs[:mpd_no_qs.rfind('/') + 1]
# Honour explicit <BaseURL> element if present
base_el = mpd_root.find(f'{MPD_NS}BaseURL')
if base_el is not None and base_el.text and base_el.text.startswith('http'):
mpd_base = base_el.text.rstrip('/') + '/'
# Collect subtitle AdaptationSet elements in MPD order
all_sub_as = [
el for el in mpd_root.iter(f'{MPD_NS}AdaptationSet')
if (el.get('contentType', '') == 'text' or
el.get('mimeType', '').startswith('text/'))
]
# Group selected tracks by language to determine forced/regular naming
lang_track_indices: dict = {} # lang → [sub_tracks index, ...]
for idx, (lang, _) in enumerate(sub_tracks):
if lang in sel_sub_langs:
lang_track_indices.setdefault(lang, []).append(idx)
print('Downloading subtitles...')
for lang, indices in lang_track_indices.items():
iso = _lang_tag_to_iso639_1(lang)
for pos, track_idx in enumerate(indices):
if track_idx >= len(all_sub_as):
continue
as_el = all_sub_as[track_idx]
if len(indices) > 1 and pos == 0:
label = f'{iso}.forced'
elif pos > 1:
label = f'{iso}.{pos}'
else:
label = iso
# SegmentTemplate may live on AdaptationSet or Representation
st = as_el.find(f'{MPD_NS}SegmentTemplate')
rep = next(as_el.iter(f'{MPD_NS}Representation'), None)
if st is None and rep is not None:
st = rep.find(f'{MPD_NS}SegmentTemplate')
if st is None:
print(f' Warning: no SegmentTemplate for {label}')
continue
media_tmpl = st.get('media', '')
start_num = int(st.get('startNumber', '1'))
rep_id = rep.get('id', '') if rep is not None else ''
# Build segment list from SegmentTimeline
timeline = st.find(f'{MPD_NS}SegmentTimeline')
segments = [] # list of (number, time)
if timeline is not None:
t = 0
num = start_num
for s_el in timeline:
t_attr = s_el.get('t')
if t_attr is not None:
t = int(t_attr)
d = int(s_el.get('d', 0))
r = int(s_el.get('r', 0))
for _ in range(r + 1):
segments.append((num, t))
t += d
num += 1
if not segments:
print(f' Warning: empty SegmentTimeline for {label}')
continue
# Download each VTT segment
vtt_parts = []
for seg_num, seg_t in segments:
url = _dash_segment_url(media_tmpl, rep_id, number=seg_num, time=seg_t)
if not url.startswith('http'):
url = mpd_base + url.lstrip('/')
try:
r = session.get(url, headers={'User-Agent': UA}, timeout=15)
if r.ok:
vtt_parts.append(r.text)
else:
print(f' Warning: segment {seg_num} HTTP {r.status_code}: {url}')
except Exception as exc:
print(f' Warning: segment {seg_num} fetch error: {exc}')
if len(vtt_parts) < len(segments):
print(f' Warning: [{label}] only {len(vtt_parts)}/{len(segments)} segments downloaded')
if not vtt_parts:
print(f' Warning: no segments downloaded for {label}')
continue
# Merge segments: keep WEBVTT header from first; append only cue
# blocks from subsequent segments (skip the WEBVTT/NOTE header lines).
merged_lines = vtt_parts[0].splitlines()
for part in vtt_parts[1:]:
lines = part.splitlines()
for i, line in enumerate(lines):
if '-->' in line:
# Back up one line to include optional cue identifier
start = (i - 1 if i > 0 and lines[i - 1].strip()
and '-->' not in lines[i - 1] else i)
merged_lines.append('')
merged_lines.extend(lines[start:])
break
srt_text = _vtt_to_srt('\n'.join(merged_lines))
cue_count = srt_text.count('\n\n') + (1 if srt_text.strip() else 0)
srt_path = SAVE_DIR / f'{out_name}.{label}.srt'
if srt_text.strip():
srt_path.write_text(srt_text, encoding='utf-8')
print(f' {srt_path.name} ({cue_count} cues)')
else:
print(f' Warning: no cues converted for {label}')
def download_and_decrypt(mpd_url, keys, out_name, video_args, audio_args):
key_args = []
for kid, key in keys.items():
key_args += ['--key', f'{kid}:{key}']
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}',
'--drop-subtitle', 'all',
] + video_args + audio_args + key_args
print(f'N_m3u8DL-RE select: {" ".join(video_args + audio_args)}')
print('Downloading and decrypting...')
result = subprocess.run(cmd)
if result.returncode != 0:
raise RuntimeError(f'N_m3u8DL-RE failed (exit {result.returncode})')
def _slug_from_url(url):
"""Extract the ATOM catalog slug from a SkyShowtime watch URL.
https://www.skyshowtime.com/watch/asset/tv/name/uuid → /asset/tv/name/uuid
"""
from urllib.parse import urlparse
path = urlparse(url).path
if path.startswith('/watch'):
path = path[len('/watch'):]
return path
def _parse_episode_node(node):
"""Parse a single ASSET/EPISODE node into an episode dict, or None if unusable."""
att = node.get('attributes', {})
try:
season_num = int(att.get('seasonNumber', 1))
except (TypeError, ValueError):
season_num = 1
try:
ep_num = int(att.get('episodeNumber', att.get('number', 0)))
except (TypeError, ValueError):
ep_num = 0
ep_title = att.get('title', f'Episode {ep_num}')
content_id = None
for fmt in ('HD', 'SD'):
if fmt in att.get('formats', {}):
content_id = att['formats'][fmt].get('contentId')
if content_id:
break
provider_variant_id = att.get('providerVariantId')
if not content_id or not provider_variant_id:
return None
return {
'number': ep_num,
'title': ep_title,
'content_id': content_id,
'provider_variant_id': provider_variant_id,
'season_number': season_num,
}
def get_series_info(session, series_uuid):
"""Fetch series title and all available episodes from the ATOM catalog API.
Uses /node?slug=...&represent=(items(items)) which returns the full
series → season → episode tree in a single request.
Returns (series_title, episodes) sorted by (season_number, episode_number).
"""
# Step 1: resolve UUID → actual ATOM slug + title
uuid_url = (f'{ATOM}/adapter-calypso/v3/query/nodes/uuid/{series_uuid}'
f'?exclude=expired%2Cfuture%2Cshortform')
r = session.get(uuid_url, headers=SKY_HEADERS, timeout=15)
uuid_data = r.json()
if not uuid_data:
raise RuntimeError(f'No info returned for series UUID {series_uuid}')
series_node = uuid_data[0]
s_att = series_node.get('attributes', {})
series_title = s_att.get('title', series_uuid)
series_slug = s_att.get('slug', '')
if not series_slug:
raise RuntimeError(f'Series node has no slug (UUID {series_uuid})')
print(f'Series slug: {series_slug}')
# Step 2: fetch full series tree with nested season/episode data
url = (f'{ATOM}/adapter-calypso/v3/query/node'
f'?slug={series_slug}&represent=(items(items))')
r = session.get(url, headers=SKY_HEADERS, timeout=30)
data = r.json()
series_title = data.get('attributes', {}).get('title', series_title)
# Top-level items are seasons (CATALOGUE/SEASON) or episodes (ASSET/EPISODE)
top_items = (data.get('relationships', {})
.get('items', {})
.get('data', []))
if not top_items:
raise RuntimeError(f'No seasons/episodes found for series "{series_title}"')
all_episodes = []
for item in top_items:
node_type = item.get('type', '')
if node_type == 'ASSET/EPISODE':
ep = _parse_episode_node(item)
if ep:
all_episodes.append(ep)
elif node_type == 'CATALOGUE/SEASON':
s_att = item.get('attributes', {})
try:
season_num = int(s_att.get('seasonNumber', 1))
except (TypeError, ValueError):
season_num = 1
ep_nodes = (item.get('relationships', {})
.get('items', {})
.get('data', []))
if ep_nodes:
for ep_node in ep_nodes:
ep = _parse_episode_node(ep_node)
if ep:
ep['season_number'] = season_num
all_episodes.append(ep)
else:
# represent=(items(items)) didn't populate nested episodes;
# fall back to a separate request using the season slug.
season_slug = s_att.get('slug', '')
if season_slug:
ep_r = session.get(
f'{ATOM}/adapter-calypso/v3/query/node'
f'?slug={season_slug}&represent=(items)',
headers=SKY_HEADERS, timeout=15)
ep_data = ep_r.json()
ep_nodes2 = (ep_data.get('relationships', {})
.get('items', {})
.get('data', []))
for ep_node in ep_nodes2:
ep = _parse_episode_node(ep_node)
if ep:
ep['season_number'] = season_num
all_episodes.append(ep)
all_episodes.sort(key=lambda e: (e['season_number'], e['number']))
return series_title, all_episodes
def select_episode_interactive(series_title, episodes):
"""Single-select episode picker with an [All episodes] option at the top.
Returns the list of episode dicts to download (all, or a single one).
"""
items = ['[All episodes]'] + [
f'S{e["season_number"]:02d}E{e["number"]:02d} {e["title"]}'
for e in episodes
]
print(f'\nSeries: {series_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)
chosen = episodes[idx - 1]
print(f'Downloading S{chosen["season_number"]:02d}E{chosen["number"]:02d}: {chosen["title"]}')
return [chosen]
def episode_out_name(ep):
"""Plex-compatible episode filename: s01e03_Title_Words"""
return f's{ep["season_number"]:02d}e{ep["number"]:02d}_{safe_filename(ep["title"])}'
def download_episode(session, user_token, ep, video_args, audio_args, sel_sub_langs):
"""Fetch playback info, decrypt, and save one episode with its subtitles."""
s, e = ep['season_number'], ep['number']
print(f'\n── S{s:02d}E{e:02d}: {ep["title"]} ──')
manifest_url, license_url = get_playback_info(
session, user_token, ep['content_id'], ep['provider_variant_id']
)
_, mpd_root = fetch_mpd(manifest_url, session)
pssh_b64 = get_pssh_from_root(mpd_root)
_, _, _, sub_tracks = parse_mpd_tracks(mpd_root)
print('Fetching content key...')
keys = fetch_content_key(pssh_b64, license_url)
for kid, key in keys.items():
print(f' {kid}:{key}')
out_name = episode_out_name(ep)
download_and_decrypt(manifest_url, keys, out_name, video_args, audio_args)
if sel_sub_langs:
download_subs_as_srt(manifest_url, mpd_root, sub_tracks,
sel_sub_langs, out_name, session)
print(f'Saved: {SAVE_DIR / out_name}.mkv')
def main():
parser = argparse.ArgumentParser(
description='Download a DRM-protected video from SkyShowtime',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Interactive video/audio/subtitle selection (default when running in terminal)
python3 sky_download.py <url>
# All audio + all subtitles, best video (non-interactive)
python3 sky_download.py -a all -s all <url>
# Polish + English audio, Polish subtitles, specific resolution
python3 sky_download.py -a pl-PL,en-US -s pl-PL -v 1920x1080 <url>
# No subtitles
python3 sky_download.py -s none <url>
# Credentials via env vars (avoids password in shell history)
export SKY_USER=me@email.com
read -rs SKY_PASS && export SKY_PASS
python3 sky_download.py <url>
""",
)
parser.add_argument('url', help='https://www.skyshowtime.com/watch/... URL')
parser.add_argument('-u', '--username', default=os.environ.get('SKY_USER'),
help='SkyShowtime email (or set SKY_USER env var)')
parser.add_argument('-p', '--password', default=os.environ.get('SKY_PASS'),
help='SkyShowtime password (or set SKY_PASS env var)')
parser.add_argument('-o', '--output',
help='Output filename without extension. Default: video title.')
parser.add_argument('-v', '--video', default=None, metavar='RES',
help=('Video quality: resolution like "1920x1080", "best", or "worst". '
'Omit for interactive selection.'))
parser.add_argument('-a', '--audio', default=None, metavar='LANGS',
help=('Audio tracks: comma-separated lang codes (e.g. pl-PL,en-US), '
'"all", or "none". Omit for interactive selection.'))
parser.add_argument('-s', '--subs', default=None, metavar='LANGS',
help=('Subtitle tracks: comma-separated lang codes, "all", or "none". '
'Omit for interactive selection.'))
args = parser.parse_args()
if not WVD:
sys.exit('ERROR: No valid .wvd file found in L3/.')
session = requests.Session()
# ── Session restore ───────────────────────────────────────────────────────
# Persist cookie, device_id, profile_id AND user_token across runs.
# The /auth/throttled/tokens call is what registers a new device in the
# SkyShowtime account — so we cache user_token and only refresh it when
# a playback request tells us it has expired.
saved_session: dict = {}
if SESSION_FILE.exists():
try:
saved_session = json.loads(SESSION_FILE.read_text())
except Exception:
pass
# Always reuse the same device_id so no "new device" notification is triggered.
device_id = (saved_session.get('device_id') or
''.join(random.choices(string.ascii_letters + string.digits, k=20)))
cookie_str = saved_session.get('cookie_str', '')
profile_id = saved_session.get('profile_id', '')
user_token = saved_session.get('user_token', '')
def _refresh_token():
"""Fetch a new user_token (registers device) and persist the session."""
nonlocal user_token
print('Refreshing user token...')
user_token = get_user_token(session, cookie_str, profile_id, device_id)
_save_session()
print(f'User token: {user_token[:30]}...')
def _save_session():
SESSION_FILE.write_text(json.dumps({
'cookie_str': cookie_str,
'device_id': device_id,
'profile_id': profile_id,
'user_token': user_token,
}))
SESSION_FILE.chmod(0o600)
if user_token and cookie_str and profile_id:
print('Using cached session.')
else:
# No usable session — need a full login.
if not args.username or not args.password:
if CREDS_FILE.exists():
saved_creds = json.loads(CREDS_FILE.read_text())
args.username = args.username or saved_creds.get('username', '')
args.password = args.password or saved_creds.get('password', '')
prompted = False
if not args.username:
args.username = input('SkyShowtime username (email): ').strip()
prompted = True
if not args.password:
args.password = getpass.getpass('SkyShowtime password: ')
prompted = True
print(f'Logging in as {args.username}...')
try:
cookie_str = login(session, args.username, args.password)
except RuntimeError as e:
if CREDS_FILE.exists() and not prompted:
print(f'ERROR: {e}')
print(f'Hint: saved credentials may be wrong. Delete {CREDS_FILE} to re-enter them.')
sys.exit(1)
raise
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}')
print('Getting profile...')
profile_id = get_profile_id(session, cookie_str)
print(f'Profile ID: {profile_id[:20]}...')
_refresh_token() # first-time token fetch; saves session
_save_session()
# ── End session restore ───────────────────────────────────────────────────
territory = detect_territory(cookie_str)
if territory:
SKY_HEADERS['x-skyott-territory'] = territory
SKY_HEADERS['x-skyott-activeterritory'] = territory
print(f'Territory: {territory}')
uuid = extract_uuid(args.url)
print(f'UUID: {uuid}')
is_series = '/watch/asset/tv/' in args.url
use_interactive = (args.video is None and args.audio is None
and args.subs is None and sys.stdin.isatty())
if is_series:
# ── Series flow ──────────────────────────────────────────────────────
print('Getting series info...')
series_title, all_episodes = get_series_info(session, uuid)
if not all_episodes:
sys.exit('ERROR: No downloadable episodes found for this series.')
selected_eps = select_episode_interactive(series_title, all_episodes)
# Fetch the first episode's MPD to offer track selection that applies
# to every episode in the batch.
first_ep = selected_eps[0]
print(f'\nFetching track info from '
f'S{first_ep["season_number"]:02d}E{first_ep["number"]:02d}: '
f'{first_ep["title"]}...')
try:
manifest_url_first, _ = get_playback_info(
session, user_token, first_ep['content_id'], first_ep['provider_variant_id'])
except TokenExpiredError:
_refresh_token()
manifest_url_first, _ = get_playback_info(
session, user_token, first_ep['content_id'], first_ep['provider_variant_id'])
_, mpd_root_first = fetch_mpd(manifest_url_first, session)
video_streams, audio_tracks, sub_langs, _ = parse_mpd_tracks(mpd_root_first)
if use_interactive:
video_args = select_video_interactive(video_streams)
audio_args, sel_sub_langs = select_tracks_interactive(audio_tracks, sub_langs)
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']
audio_spec = args.audio or 'all'
audio_args = cli_spec_to_select_args('--select-audio', '--drop-audio', audio_spec)
subs_spec = args.subs or 'all'
if subs_spec.lower() == 'none':
sel_sub_langs = set()
elif subs_spec.lower() == 'all':
sel_sub_langs = set(sub_langs.keys())
else:
sel_sub_langs = set(subs_spec.split(','))
SAVE_DIR.mkdir(exist_ok=True)
print(f'\nDownloading {len(selected_eps)} episode(s)...')
for ep in selected_eps:
try:
download_episode(session, user_token, ep, video_args, audio_args, sel_sub_langs)
except TokenExpiredError:
_refresh_token()
download_episode(session, user_token, ep, video_args, audio_args, sel_sub_langs)
print(f'\nAll done. Files saved to {SAVE_DIR}')
else:
# ── Movie / single-item flow ──────────────────────────────────────────
print('Getting video info...')
title, content_id, provider_variant_id = get_video_info(session, uuid)
print(f'Title: {title}')
print(f'Content ID: {content_id}')
print(f'Provider Variant ID: {provider_variant_id}')
print('Getting playback info...')
try:
manifest_url, license_url = get_playback_info(
session, user_token, content_id, provider_variant_id)
except TokenExpiredError:
_refresh_token()
manifest_url, license_url = get_playback_info(
session, user_token, content_id, provider_variant_id)
print(f'Manifest: {manifest_url[:80]}...')
print(f'License: {license_url[:80]}...')
print('Fetching MPD...')
_, mpd_root = fetch_mpd(manifest_url, session)
print('Extracting PSSH...')
pssh_b64 = get_pssh_from_root(mpd_root)
print(f'PSSH: {pssh_b64[:48]}...')
video_streams, audio_tracks, sub_langs, sub_tracks = parse_mpd_tracks(mpd_root)
if use_interactive:
video_args = select_video_interactive(video_streams)
audio_args, sel_sub_langs = select_tracks_interactive(audio_tracks, sub_langs)
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']
audio_spec = args.audio or 'all'
audio_args = cli_spec_to_select_args('--select-audio', '--drop-audio', audio_spec)
subs_spec = args.subs or 'all'
if subs_spec.lower() == 'none':
sel_sub_langs = set()
elif subs_spec.lower() == 'all':
sel_sub_langs = set(sub_langs.keys())
else:
sel_sub_langs = set(subs_spec.split(','))
print('Fetching content key...')
keys = fetch_content_key(pssh_b64, license_url)
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(manifest_url, keys, out_name, video_args, audio_args)
if sel_sub_langs:
download_subs_as_srt(manifest_url, mpd_root, sub_tracks,
sel_sub_langs, out_name, session)
print(f'Done: {SAVE_DIR / out_name}.mkv')
if __name__ == '__main__':
main()