1532 lines
58 KiB
Python
1532 lines
58 KiB
Python
#!/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()
|