Hi everyone,
I hope this is worthy of a new thread.
Ive been working on a macOS app called UKTVDownloader, which I built with the help of some AI tools (mostly Claude and ChatGPT). Its a Python-based GUI that lets users download content from UK streaming platforms like Channel 4, ITVX, and Channel 5. It supports MPD detection, subtitle downloads, and merging with ffmpeg and N_m3u8DL-RE.
GitHub repo:
https://github.com/Padraig74/UKTVDownloader
Right now, the only manual step left is getting the DRM key (KID:KEY). I can extract the PSSH from the MPD, but the user still has to manually input the decryption key usually using Widevine CDM tools or browser extensions.
What Im asking for:
Is there any way to automate the process of getting the DRM key from the PSSH, via existing tools?
Has anyone integrated something like this into a script or app before?
Any advice on how I could approach this for macOS users would be really appreciated.
Im not a professional coder Ive stitched this together mostly by learning as I go and tweaking with AI assistance so if anyone has tips, examples, or even partial solutions, Id be really grateful.
Thanks in advance!
Padraig
+ Reply to Thread
Results 1 to 9 of 9
-
-
You maybe want to look at https://github.com/aarubui/yt-dlp-mp4decrypt, which should have support for Channel 4 and ITV among others.
-
Did an answer for this ever appear?! I use scripts but still have to input the KID:KEY manually.
-
You can use a txt file called key.txt to avoid manually entering kid:key.
This avoids manually entering the kid:keyCode:key_file = os.path.expanduser("~/Downloads/key.txt") def get_drm_key(self, pssh, url, service_name, key_file): if not pssh: print("❌ No PSSH available to extract key") return None # Check cache if pssh in self.widevine_data: print(f"✅ Found cached key for PSSH: {self.widevine_data[pssh]}") return self.widevine_data[pssh] # Initialize browser if needed if not self.driver: self.initialize_browser() # Decode PSSH to extract KID try: pssh_bytes = base64.b64decode(pssh) kid = pssh_bytes[32:48].hex() print(f"✅ Extracted KID from PSSH: {kid}") except Exception as e: print(f"❌ Error extracting KID from PSSH: {e}") kid = None print("\n�� DRM KEY EXTRACTION ��") print(f"1. Opening {service_name} video page...") self.driver.get(url) time.sleep(5) print("2. Waiting for Widevine license request...") try: play_button = WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable( (By.CSS_SELECTOR, "button.play-button, .play-icon, [aria-label='Play'], video") ) ) play_button.click() time.sleep(5) except Exception: print(" (Play button not found, continuing anyway)") print("\n3. Reading DRM key from key.txt") try: with open(key_file, "r", encoding="utf-8") as f: key_input = f.read().strip() except FileNotFoundError: print(f"❌ File '{key_file}' not found. Create it with KID:KEY inside.") return None except Exception as e: print(f"❌ Error reading '{key_file}': {e}") return None # Validate key format if ':' not in key_input: print("❌ Invalid format in key.txt (expected KID:KEY)") return None kid_from_file, key = key_input.split(':', 1) kid_from_file = kid_from_file.strip().lower() key = key.strip().lower() # Optional consistency check if kid and kid_from_file != kid: print("⚠️ Warning: KID in key.txt does not match extracted PSSH KID") final_key = f"{kid_from_file}:{key}" # Store key self.widevine_data[pssh] = final_key self.save_widevine_proxy_data() print("✅ DRM key successfully loaded from key.txt") return final_key
Example of key.txt
e3c1b7d2a4f94c8d9e2f123456789abc:1a2b3c4d5e6f7890a bcdef1234567890 -
otherwise a multiple version key.text.
Code:key_file = os.path.expanduser("~/Downloads/key.txt") def get_drm_key(self, pssh, url, service_name, key_file): if not pssh: print("❌ No PSSH available to extract key") return None # Check cache if pssh in self.widevine_data: print(f"✅ Found cached key for PSSH: {self.widevine_data[pssh]}") return self.widevine_data[pssh] # Initialize browser if needed if not self.driver: self.initialize_browser() # Decode PSSH → extract KID try: pssh_bytes = base64.b64decode(pssh) kid = pssh_bytes[32:48].hex().lower() print(f"✅ Extracted KID from PSSH: {kid}") except Exception as e: print(f"❌ Error extracting KID from PSSH: {e}") return None print("\n🔑 DRM KEY EXTRACTION 🔑") print(f"1. Opening {service_name} video page...") self.driver.get(url) time.sleep(5) print("2. Triggering Widevine license request...") try: play_button = WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable( (By.CSS_SELECTOR, "button.play-button, .play-icon, [aria-label='Play'], video") ) ) play_button.click() time.sleep(5) except Exception: print(" (Play button not found, continuing anyway)") print("\n3. Searching matching key in key.txt") found_key = None try: with open(key_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or ':' not in line: continue file_kid, file_key = line.split(':', 1) file_kid = file_kid.strip().lower() file_key = file_key.strip().lower() if file_kid == kid: found_key = f"{file_kid}:{file_key}" break except FileNotFoundError: print(f"❌ File '{key_file}' not found.") return None except Exception as e: print(f"❌ Error reading '{key_file}': {e}") return None if not found_key: print("❌ No matching KID found in key.txt") return None # Store key self.widevine_data[pssh] = found_key self.save_widevine_proxy_data() print("✅ DRM key found and loaded from key.txt") return found_key -
-
Alternatively, if there is no token for the license_url of the 3 sites: Channel 4, ITVX, and Channel 5, you can obtain the keys yourself with pywindevine.
You can simplify things by creating a menu for the 3 sites: Channel 4, ITVX, Channel 5. Here is a simplified version without a menu:
Here is my menu on the 3 French sites:Code:from pywidevine.cdm import Cdm from pywidevine.device import Device from pywidevine.pssh import PSSH from datetime import datetime import pyfiglet, requests, base64, binascii, os # Colors RED = '\x1b[38;5;160m' GREEN = '\x1b[38;5;46m' CYAN = '\x1b[38;5;14m' YELLOW = '\x1b[38;5;226m' END = '\x1b[0m' WVD_PATH = "./device.wvd" # Record the PSSH + keys in the text. def save_keys_to_file(keys, pssh, output_dir="keys"): if not keys: return os.makedirs(output_dir, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") short_pssh = pssh[:16] filename = f"widevine_keys_{short_pssh}_{timestamp}.txt" filepath = os.path.join(output_dir, filename) with open(filepath, "w", encoding="utf-8") as f: f.write("Widevine Decryption Keys\n") f.write("=" * 30 + "\n") f.write(f"PSSH: {pssh}\n") f.write(f"Keys found: {len(keys)}\n") for key in keys: f.write(f"--key {key}\n") print(f"{GREEN}✔ Keys saved to file: {YELLOW}{filepath}{END}") # Validate PSSH (Base64 + 'pssh' box + reasonable length) def is_valid_pssh(pssh_b64: str) -> bool: try: raw = base64.b64decode(pssh_b64, validate=True) if len(raw) < 32: return False if raw[4:8] != b'pssh': return False return True except (binascii.Error, ValueError): return False # Display ASCII banner def title(): banner = pyfiglet.figlet_format('Widevine Decryptor Keys', font='slant', width=150) print(f"{CYAN}{banner}{END}") # Prompt user input def ask(prompt): return input(f"{RED}{prompt}{YELLOW}") # Ensure Widevine device exists def ensure_device(): if os.path.isfile(WVD_PATH): print(f"{GREEN}✔ Device found: {YELLOW}{WVD_PATH}{END}") return Device.load(WVD_PATH) else: print(f"{RED}❌ Device file not found: {YELLOW}{WVD_PATH}{END}") input("\nPress Enter to exit...") exit(1) # Retrieve decryption keys def get_key(pssh, license_url, headers, device): cdm = Cdm.from_device(device) session_id = cdm.open() challenge = cdm.get_license_challenge(session_id, PSSH(pssh)) try: response = requests.post(license_url, data=challenge, headers=headers, timeout=10) response.raise_for_status() except Exception as e: print(f"{RED}❌ License error: {e}{END}") return "" cdm.parse_license(session_id, response.content) keys = [] for key in cdm.get_keys(session_id): if key.type == "CONTENT": keys.append(f"{key.kid.hex}:{key.key.hex()}") cdm.close(session_id) return keys # Main function def main(): title() device = ensure_device() # Loop until valid PSSH is entered while True: pssh_str = ask("Enter the PSSH code: ") if not pssh_str: print(f"{RED}❌ Empty PSSH, please try again.{END}") continue if not is_valid_pssh(pssh_str): print(f"{RED}❌ Invalid PSSH (base64 / pssh format){END}") continue break # Loop until License URL is entered while True: lic_url = ask("Enter the License URL: ") if not lic_url: print(f"{RED}❌ License URL is empty, please try again.{END}") continue break # Loop until Token is entered #while True: # token = ask("Enter the Token: ") # if not token: # print(f"{RED}❌ Token is empty, please try again.{END}") # continue # break #headers = {'x-dt-auth-token': token} # Optional alternative header headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0'} keys = get_key(pssh_str, lic_url, headers, device) if not keys: print(f"{RED}❌ No key found{END}") else: print(f"{GREEN}✔ {len(keys)} key(s) found:{END}") for k in keys: print(f"{YELLOW}--key {k}{END}") save_keys_to_file(keys, pssh_str) input("\nPress Enter to exit...") if __name__ == "__main__": main()
[Attachment 90836 - Click to enlarge]
Once you get the keys from Widevine, it automatically downloads the video with N-m3u8dl-re. If you need help creating a menu, I could help you if you want. -
Here is a menu to choose the site you want to download from after obtaining the keys, but there are things to change below, it's up to you.
I haven't tested it because you have to create an account for each site; you'll have to try it yourself.Code:from pywidevine.cdm import Cdm from pywidevine.device import Device from pywidevine.pssh import PSSH from lxml import etree import base64, requests, sys, os, pyfiglet, time, subprocess # ===================== COLORS ===================== # RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" CYAN = "\033[96m" BLUE = "\033[94m" END = "\033[0m" # ===================== PATHS ===================== # BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TOOLS_PATH = os.path.join(BASE_DIR, "Tools") FOLDER = os.path.join(BASE_DIR, "Downloader_Video") WVD_PATH = os.path.join(BASE_DIR, "device.wvd") # ===================== REQUIRED TOOLS ===================== # EXECUTABLES = ["ffmpeg.exe", "mp4decrypt.exe", "N_m3u8DL-RE.exe"] missing = [exe for exe in EXECUTABLES if not os.path.isfile(os.path.join(TOOLS_PATH, exe))] if missing: print(f"{RED}❌ Missing files in Tools folder:{END}") for f in missing: print(f" - {YELLOW}{f}{END}") input("\nPress Enter to exit...") sys.exit(1) # ===================== DISPLAY ===================== # def print_title(): title = pyfiglet.figlet_format("Widevine Decryptor Keys", font="slant", width=150) print(f"{CYAN}{title}{END}") def clear_screen(): os.system("cls" if os.name == "nt" else "clear") # ===================== INPUT UTILS ===================== # def ask_input(prompt, error): while True: try: value = input(f"{YELLOW}{prompt}{END} ").strip() except (EOFError, KeyboardInterrupt): print(f"\n{RED}Input cancelled{END}") sys.exit(1) if value: return value print(f"{RED}{error}{END}") def ensure_device(): if os.path.isfile(WVD_PATH): print(f"{GREEN}✔ Device found: {YELLOW}{WVD_PATH}{END}") return Device.load(WVD_PATH) else: print(f"{RED}❌ Device file not found: {YELLOW}{WVD_PATH}{END}") input("\nPress Enter to exit...") exit(1) def extract_pssh(mpd_url): if not mpd_url: print(f"{RED}❌ Failed to detect MPD URL{END}") return None try: r = requests.get(mpd_url, timeout=10) r.raise_for_status() except Exception as e: print(f"{RED}❌ MPD request error: {e}{END}") return None try: xml = etree.fromstring(r.content) except Exception as e: print(f"{RED}❌ Invalid MPD XML: {e}{END}") return None namespaces = {"mpd": "urn:mpeg:dash:schema:mpd:2011", "cenc": "urn:mpeg:cenc:2013"} pssh_list = xml.xpath("//cenc:pssh", namespaces=namespaces) if not pssh_list: print(f"{RED}❌ No PSSH found in the MPD{END}") return None # Cleanup + remove duplicates pssh_values = list(dict.fromkeys(pssh.text.strip() for pssh in pssh_list if pssh.text)) print(f"{GREEN}✔ {len(pssh_values)} PSSH found{END}") for i, pssh in enumerate(pssh_values, 1): print(f"{CYAN}PSSH #{i}:{END} {pssh}") # Usually the first one is sufficient return pssh_values[0] def get_key(pssh, license_url, headers, device): cdm = Cdm.from_device(device) session_id = cdm.open() challenge = cdm.get_license_challenge(session_id, PSSH(pssh)) try: response = requests.post(license_url, data=challenge, headers=headers, timeout=10) response.raise_for_status() except Exception as e: print(f"{RED}❌ License error: {e}{END}") return "" cdm.parse_license(session_id, response.content) keys = [] for key in cdm.get_keys(session_id): if key.type == "CONTENT": keys.append(f"{key.kid.hex}:{key.key.hex()}") cdm.close(session_id) return keys def Download_Video_DRM(mpd, title, keys): print(f"\n🧩 {BLUE}KID:KEY found:{END} {GREEN}{len(keys)}{END}") print("".join(f"🔑 --key {RED}{k}{END}\n" for k in keys)) os.makedirs(FOLDER, exist_ok=True) exe_path = os.path.join(TOOLS_PATH, "N_m3u8DL-RE.exe") cmd = ([exe_path, mpd, "-M", "format=mp4", "--no-log", "--log-level", "ERROR"] + [item for k in keys for item in ("--key", k)] + ["-sv", "best", "-sa", "best", "--save-name", title, "--save-dir", FOLDER]) print(f"\n{CYAN}📥 Download in progress...{END}\n") time.sleep(2) subprocess.run(cmd) def saisir_infos(site_name): clear_screen() print_title() print(f"\n--- {site_name} ---") device = ensure_device() name = ask_input("Video title:", "❌ Title is empty.") licence_mpd = ask_input("MPD URL:", "❌ MPD URL is empty.") licence_url = ask_input("License URL:", "❌ License URL is empty.") token = ask_input("dt-auth-token :", "❌ Token is empty.") #replace dt-auth-token with another token name headers = {"dt-auth-token": token} #replace dt-auth-token with another token name # If there is no token, replace it with this: #headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0"} pssh = extract_pssh(licence_mpd) keys = get_key(pssh, licence_url, headers, device) if not keys: print(f"{RED}❌ No key found{END}") else: Download_Video_DRM(licence_mpd, name, keys) def menu(): clear_screen() while True: print(f"\n{CYAN}============== Choix Replay ================{END}") print(f"{YELLOW}1{END} - {GREEN}Channel 4{END}") print(f"{YELLOW}2{END} - {BLUE}ITV{END}") print(f"{YELLOW}3{END} - {CYAN}Channel 5{END}") print(f"{YELLOW}4{END} - {RED}Exit{END}") print(f"{CYAN}============================================{END}") choice = input(f"{YELLOW}Choose a site: {END}").strip() if choice == "1": saisir_infos("Channel 4") elif choice == "2": saisir_infos("ITV") elif choice == "3": saisir_infos("Channel 5") elif choice == "4": print(f"{YELLOW}Goodbye!{END}") sys.exit(0) else: print("Invalid choice, please try again.") time.sleep(2) if __name__ == "__main__": menu()
Similar Threads
-
Tool similar to get_iPlayer for ITVX and Channel $
By pwl2706 in forum Video Streaming DownloadingReplies: 2Last Post: 21st Dec 2023, 15:21 -
Channel 4 downloader
By Diazole in forum Video Streaming DownloadingReplies: 123Last Post: 21st Dec 2023, 12:00 -
Best ITVX Downloader with Subtitles
By A_n_g_e_l_a in forum Video Streaming DownloadingReplies: 216Last Post: 7th Nov 2023, 02:18 -
No downloader working without key. now getting base64 key string is tough.
By akshaysic in forum Video Streaming DownloadingReplies: 6Last Post: 14th Jan 2023, 10:33 -
TubiTV key extraction error
By hgfdgfui in forum Video Streaming DownloadingReplies: 10Last Post: 9th Jun 2022, 08:39



Quote