Files
TwinVine/gui.py
T
2025-09-29 11:16:14 +01:00

450 lines
18 KiB
Python

import sys
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QCheckBox, QFrame, QMessageBox, QHBoxLayout
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QPalette, QColor
import subprocess
import httpx
import base64
import re
import urllib.parse
import xml.etree.ElementTree as ET
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH
from base64 import b64encode
import codecs
from pathlib import Path
import os
import shlex
WVD_PATH = "./WVDs/device.wvd"
WIDEVINE_SYSTEM_ID = 'EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED'
class DownloadThread(QThread):
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, command):
super().__init__()
self.command = command
def run(self):
try:
subprocess.run(self.command, check=True)
self.finished.emit()
except subprocess.CalledProcessError as e:
self.error.emit(str(e))
class AllHell3App(QWidget):
def __init__(self):
super().__init__()
self.init_ui()
def init_ui(self):
self.setWindowTitle("HellYes!")
layout = QVBoxLayout()
self.mpd_url_label = QLabel("MPD URL")
layout.addWidget(self.mpd_url_label)
self.mpd_url_entry = QLineEdit()
layout.addWidget(self.mpd_url_entry)
self.curl_label = QLabel("cURL of License Request")
layout.addWidget(self.curl_label)
self.curl_text = QTextEdit()
layout.addWidget(self.curl_text)
highlighted_frame = QFrame()
highlighted_layout = QVBoxLayout()
highlighted_frame.setLayout(highlighted_layout)
highlighted_frame.setStyleSheet("border: 1px solid red;")
self.video_name_label = QLabel("Video Name")
highlighted_layout.addWidget(self.video_name_label)
self.video_name_entry = QLineEdit()
highlighted_layout.addWidget(self.video_name_entry)
self.fetch_keys_button = QPushButton("Get Keys")
self.fetch_keys_button.clicked.connect(self.fetch_keys)
highlighted_layout.addWidget(self.fetch_keys_button)
self.download_button = QPushButton("Download Nm~RE")
self.download_button.clicked.connect(self.download_video)
highlighted_layout.addWidget(self.download_button)
self.download_button2 = QPushButton("Download DASH")
self.download_button2.clicked.connect(self.download_dash)
highlighted_layout.addWidget(self.download_button2)
layout.addWidget(highlighted_frame)
self.keys_output = QTextEdit()
layout.addWidget(self.keys_output)
self.n_m3u8dl_re_label = QLabel("N_m3u8DL-RE command")
layout.addWidget(self.n_m3u8dl_re_label)
self.n_m3u8dl_re_output = QTextEdit()
layout.addWidget(self.n_m3u8dl_re_output)
self.dash_mpd_cli_label = QLabel("Dash-MPD-CLI command")
layout.addWidget(self.dash_mpd_cli_label)
self.dash_mpd_cli_output = QTextEdit()
layout.addWidget(self.dash_mpd_cli_output)
checkbox_layout = QHBoxLayout()
self.dark_mode_checkbox = QCheckBox("Dark Mode")
self.dark_mode_checkbox.setChecked(True)
self.dark_mode_checkbox.stateChanged.connect(self.toggle_dark_mode)
checkbox_layout.addWidget(self.dark_mode_checkbox)
self.reset_button = QPushButton("Reset")
self.reset_button.clicked.connect(self.reset_fields)
checkbox_layout.addWidget(self.reset_button)
layout.addLayout(checkbox_layout)
self.setLayout(layout)
self.toggle_dark_mode()
def reset_fields(self):
self.mpd_url_entry.clear()
self.curl_text.clear()
self.video_name_entry.clear()
self.keys_output.clear()
self.n_m3u8dl_re_output.clear()
self.dash_mpd_cli_output.clear()
def toggle_dark_mode(self):
if self.dark_mode_checkbox.isChecked():
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.Base, QColor(35, 35, 35))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.black)
palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
self.setPalette(palette)
self.mpd_url_label.setStyleSheet("color: white;")
self.curl_label.setStyleSheet("color: white;")
self.video_name_label.setStyleSheet("color: white;")
self.n_m3u8dl_re_label.setStyleSheet("color: white;")
self.dash_mpd_cli_label.setStyleSheet("color: white;")
self.dark_mode_checkbox.setStyleSheet("color: white;")
self.fetch_keys_button.setStyleSheet("""color: white;
background-color: #4e4e4e;
border: 1px solid #6e6e6e;
padding: 5px;""")
self.download_button.setStyleSheet("""color: white;
background-color: #4e4e4e;
border: 1px solid #6e6e6e;
padding: 5px;""")
self.download_button2.setStyleSheet("""color: white;
background-color: #4e4e4e;
border: 1px solid #6e6e6e;
padding: 5px;""")
self.curl_text.setStyleSheet("""color: white; border: 1px solid red;
background-color: #232323;""")
self.video_name_label.setStyleSheet("""
color: white; border: None;""")
self.mpd_url_entry.setStyleSheet("""color: white; border: 1px solid red;
background-color: #232323;""")
else:
self.setPalette(QApplication.palette())
self.mpd_url_label.setStyleSheet("color: black;")
self.curl_label.setStyleSheet("color: black;")
self.video_name_label.setStyleSheet("color: black;")
self.n_m3u8dl_re_label.setStyleSheet("color: black;")
self.dash_mpd_cli_label.setStyleSheet("color: black;")
self.dark_mode_checkbox.setStyleSheet("color: black;")
self.fetch_keys_button.setStyleSheet("""color: black;
background-color: #aeaeae;
border: 1px solid #6e6e6e;
padding: 5px;""")
self.download_button.setStyleSheet("""color: black;
background-color: #aeaeae;
border: 1px solid #6e6e6e;
padding: 5px;""")
self.download_button2.setStyleSheet("""color: black;
background-color: #aeaeae;
border: 1px solid #6e6e6e;
padding: 5px;""")
self.curl_text.setStyleSheet("""color: black; border: 1px solid red;
background-color: white;""")
self.video_name_label.setStyleSheet("""
color: black; border: None;""")
self.mpd_url_entry.setStyleSheet("""color: black; border: 1px solid red;
background-color: white;""")
def fetch_mpd_content(self, mpd_url):
response = httpx.get(mpd_url)
response.raise_for_status()
return response.text
def find_default_kid_with_regex(self, mpd_content):
match = re.search(r'cenc:default_KID="([A-F0-9-]+)"', mpd_content)
if match:
return match.group(1)
return None
def extract_or_generate_pssh(self, mpd_content):
try:
tree = ET.ElementTree(ET.fromstring(mpd_content))
root = tree.getroot()
namespaces = {
'cenc': 'urn:mpeg:cenc:2013',
'': 'urn:mpeg:dash:schema:mpd:2011'
}
default_kid = None
for elem in root.findall('.//ContentProtection', namespaces):
scheme_id_uri = elem.attrib.get('schemeIdUri', '').upper()
if scheme_id_uri == 'URN:MPEG:DASH:MP4PROTECTION:2011':
default_kid = elem.attrib.get('cenc:default_KID')
if default_kid:
break
if not default_kid:
default_kid = self.find_default_kid_with_regex(mpd_content)
pssh = None
for elem in root.findall('.//ContentProtection', namespaces):
scheme_id_uri = elem.attrib.get('schemeIdUri', '').upper()
if scheme_id_uri == f'URN:UUID:{WIDEVINE_SYSTEM_ID}':
pssh_elem = elem.find('cenc:pssh', namespaces)
if pssh_elem is not None:
pssh = pssh_elem.text
break
if pssh is not None:
return pssh
elif default_kid is not None:
default_kid = default_kid.replace('-', '')
s = f'000000387073736800000000edef8ba979d64acea3c827dcd51d21ed000000181210{default_kid}48e3dc959b06'
return b64encode(bytes.fromhex(s)).decode()
else:
# No pssh or default_KID found
try:
pssh = self.get_pssh_from_mpd(self.mpd_url_entry.text()) # init.m4f method
if pssh:
return pssh
else:
return None
except:
return None
except ET.ParseError as e:
try:
pssh = self.get_pssh_from_mpd(self.mpd_url_entry.text()) # init.m4f method
return pssh
except:
pass
print(f"Error parsing MPD content: {e}")
return None
def get_pssh_from_mpd(self, mpd: str):
print("Extracting PSSH from MPD...")
yt_dl = 'yt-dlp'
init = 'init.m4f'
files_to_delete = [init]
for file_name in files_to_delete:
if os.path.exists(file_name):
os.remove(file_name)
print(f"{file_name} file successfully deleted.")
try:
subprocess.run([yt_dl, '-q', '--no-warning', '--test', '--allow-u', '-f', 'bestvideo[ext=mp4]/bestaudio[ext=m4a]/best', '-o', init, mpd])
except FileNotFoundError:
print("yt-dlp not found. Trying installing it...")
exit(0)
pssh_list = self.extract_pssh_from_file('init.m4f')
pssh = None
for target_pssh in pssh_list:
if 20 < len(target_pssh) < 220:
pssh = target_pssh
print(f'\n{pssh}\n')
for file_name in files_to_delete:
if os.path.exists(file_name):
os.remove(file_name)
print(f"{file_name} file successfully deleted.")
return pssh
def extract_pssh_from_file(self,file_path: str) -> list:
print('Extracting PSSHs from init file:', file_path)
return self.to_pssh(Path(file_path).read_bytes())
def find_wv_pssh_offsets(self, raw: bytes) -> list:
offsets = []
offset = 0
while True:
offset = raw.find(b'pssh', offset)
if offset == -1:
break
size = int.from_bytes(raw[offset-4:offset], byteorder='big')
pssh_offset = offset - 4
offsets.append(raw[pssh_offset:pssh_offset+size])
offset += size
return offsets
def to_pssh(self, content: bytes) -> list:
wv_offsets = self.find_wv_pssh_offsets(content)
return [base64.b64encode(wv_offset).decode() for wv_offset in wv_offsets]
def parse_curl(self, curl_command):
url_match = re.search(r"curl\s+'(.*?)'", curl_command)
url = url_match.group(1) if url_match else ""
method_match = re.search(r"-X\s+(\w+)", curl_command)
method = method_match.group(1) if method_match else "UNDEFINED"
headers = {}
headers_matches = re.findall(r"-H\s+'([^:]+):\s*(.*?)'", curl_command)
for header in headers_matches:
headers[header[0]] = header[1]
data_match = re.search(r"--data(?:-raw)?\s+(?:(\$?')|(\$?{?))(.*?)'", curl_command, re.DOTALL)
if data_match:
raw_prefix = data_match.group(1)
data = data_match.group(3)
if raw_prefix and raw_prefix.startswith('$'):
data = None
else:
data = data.replace('\\\\', '\\').replace('\\x', '\\\\x')
try:
data = codecs.decode(data, 'unicode_escape')
except Exception as e:
data = ""
else:
data = ""
return url, method, headers, data
def get_key(self, pssh, license_url, headers, data):
device = Device.load(WVD_PATH)
cdm = Cdm.from_device(device)
session_id = cdm.open()
challenge = cdm.get_license_challenge(session_id, PSSH(pssh))
if data:
if match := re.search(r'"(CAQ=.*?)"', data):
challenge = data.replace(match.group(1), base64.b64encode(challenge).decode())
elif match := re.search(r'"(CAES.*?)"', data):
challenge = data.replace(match.group(1), base64.b64encode(challenge).decode())
elif match := re.search(r'=(CAES.*?)(&.*)?$', data):
b64challenge = base64.b64encode(challenge).decode()
quoted = urllib.parse.quote_plus(b64challenge)
challenge = data.replace(match.group(1), quoted)
payload = challenge if data is None else challenge
license_response = httpx.post(url=license_url, data=payload, headers=headers)
try:
license_response.raise_for_status()
except httpx.HTTPStatusError as e:
raise e
license_content = license_response.content
try:
match = re.search(r'"(CAIS.*?)"', license_response.content.decode('utf-8'))
if match:
license_content = base64.b64decode(match.group(1))
except:
pass
if isinstance(license_content, str):
license_content = base64.b64decode(license_content)
cdm.parse_license(session_id, license_content)
keys = []
for key in cdm.get_keys(session_id):
if key.type == 'CONTENT':
keys.append(f"--key {key.kid.hex}:{key.key.hex()}")
cdm.close(session_id)
return keys
def fetch_keys(self):
mpd_url = self.mpd_url_entry.text()
try:
mpd_content = self.fetch_mpd_content(mpd_url)
pssh = self.extract_or_generate_pssh(mpd_content)
if not pssh:
QMessageBox.critical(self, "Error", "Failed to extract or generate PSSH\nAre you sure the video is Widevine encrypted?.")
return
curl_command = self.curl_text.toPlainText().strip()
license_url, method, headers, data = self.parse_curl(curl_command)
keys = self.get_key(pssh, license_url, headers, data)
self.keys_output.clear()
self.keys_output.append("\n".join( keys))
key_str = " ".join(keys)
self.n_m3u8dl_re_command = f"N_m3u8DL-RE '{mpd_url}' {key_str} --save-name {self.video_name_entry.text()} -mt -M:format=mkv:muxer=mkvmerge"
self.n_m3u8dl_re_output.setText(self.n_m3u8dl_re_command)
self.dash_mpd_cli_command = f"dash-mpd-cli --quality best --muxer-preference mkv:mkvmerge {key_str} \"{mpd_url}\" --write-subs --output '{self.video_name_entry.text()}.mkv'"
self.dash_mpd_cli_output.setText(str(self.dash_mpd_cli_command))
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def download_video(self):
try:
self.thread = DownloadThread(shlex.split(self.n_m3u8dl_re_command))
self.thread.finished.connect(self.on_download_finished)
self.thread.error.connect(self.on_download_error)
self.thread.start()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def download_dash(self):
try:
self.thread = DownloadThread(shlex.split(self.dash_mpd_cli_command))
self.thread.finished.connect(self.on_download_finished)
self.thread.error.connect(self.on_download_error)
self.thread.start()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def on_download_finished(self):
QMessageBox.information(self, "Success", "Download finished successfully.")
def on_download_error(self, error_message):
QMessageBox.critical(self, "Error", f"Download failed: {error_message}")
if __name__ == "__main__":
app = QApplication(sys.argv)
window = AllHell3App()
window.show()
sys.exit(app.exec())