diff --git a/CONFIG.md b/CONFIG.md index b2f8545..8b0d8ad 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -213,6 +213,37 @@ downloader: The `default` entry is optional. If omitted, `requests` will be used for services not listed. +## decryption (str | dict) + +Choose what software to use to decrypt DRM-protected content throughout unshackle where needed. +You may provide a single decryption method globally or a mapping of service tags to +decryption methods. + +Options: + +- `shaka` (default) - Shaka Packager - +- `mp4decrypt` - mp4decrypt from Bento4 - + +Note that Shaka Packager is the traditional method and works with most services. mp4decrypt +is an alternative that may work better with certain services that have specific encryption formats. + +Example mapping: + +```yaml +decryption: + ATVP: mp4decrypt + AMZN: shaka + default: shaka +``` + +The `default` entry is optional. If omitted, `shaka` will be used for services not listed. + +Simple configuration (single method for all services): + +```yaml +decryption: mp4decrypt +``` + ## filenames (dict) Override the default filenames used across unshackle. diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 33ad6c4..7cf9642 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -912,6 +912,31 @@ class dl: if font_count: self.log.info(f"Attached {font_count} fonts for the Subtitles") + # Handle DRM decryption BEFORE repacking (must decrypt first!) + service_name = service.__class__.__name__.upper() + decryption_method = config.decryption_map.get(service_name, config.decryption) + use_mp4decrypt = decryption_method.lower() == "mp4decrypt" + + if use_mp4decrypt: + decrypt_tool = "mp4decrypt" + else: + decrypt_tool = "Shaka Packager" + + drm_tracks = [track for track in title.tracks if track.drm] + if drm_tracks: + with console.status(f"Decrypting tracks with {decrypt_tool}..."): + has_decrypted = False + for track in drm_tracks: + for drm in track.drm: + if hasattr(drm, "decrypt"): + drm.decrypt(track.path, use_mp4decrypt=use_mp4decrypt) + has_decrypted = True + events.emit(events.Types.TRACK_REPACKED, track=track) + break + if has_decrypted: + self.log.info(f"Decrypted tracks with {decrypt_tool}") + + # Now repack the decrypted tracks with console.status("Repackaging tracks with FFMPEG..."): has_repacked = False for track in title.tracks: diff --git a/unshackle/commands/env.py b/unshackle/commands/env.py index f4dbe8a..504cbf6 100644 --- a/unshackle/commands/env.py +++ b/unshackle/commands/env.py @@ -45,6 +45,13 @@ def check() -> None: "desc": "DRM decryption", "cat": "DRM", }, + { + "name": "mp4decrypt", + "binary": binaries.Mp4decrypt, + "required": False, + "desc": "DRM decryption", + "cat": "DRM", + }, # HDR Processing {"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"}, { diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index ccc5267..da31fb5 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -53,6 +53,7 @@ MKVToolNix = find("mkvmerge") Mkvpropedit = find("mkvpropedit") DoviTool = find("dovi_tool") HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool") +Mp4decrypt = find("mp4decrypt") __all__ = ( @@ -71,5 +72,6 @@ __all__ = ( "Mkvpropedit", "DoviTool", "HDR10PlusTool", + "Mp4decrypt", "find", ) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 24d5172..5124137 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -75,6 +75,14 @@ class Config: self.proxy_providers: dict = kwargs.get("proxy_providers") or {} self.serve: dict = kwargs.get("serve") or {} self.services: dict = kwargs.get("services") or {} + decryption_cfg = kwargs.get("decryption") or {} + if isinstance(decryption_cfg, dict): + self.decryption_map = {k.upper(): v for k, v in decryption_cfg.items()} + self.decryption = self.decryption_map.get("DEFAULT", "shaka") + else: + self.decryption_map = {} + self.decryption = decryption_cfg or "shaka" + self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False) self.tag: str = kwargs.get("tag") or "" self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or "" diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py index 4e73382..37590c7 100644 --- a/unshackle/core/drm/playready.py +++ b/unshackle/core/drm/playready.py @@ -187,14 +187,69 @@ class PlayReady: if not self.content_keys: raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License") - def decrypt(self, path: Path) -> None: + def decrypt(self, path: Path, use_mp4decrypt: bool = False) -> None: + """ + Decrypt a Track with PlayReady DRM. + Args: + path: Path to the encrypted file to decrypt + use_mp4decrypt: If True, use mp4decrypt instead of Shaka Packager + Raises: + EnvironmentError if the required decryption executable could not be found. + ValueError if the track has not yet been downloaded. + SubprocessError if the decryption process returned a non-zero exit code. + """ if not self.content_keys: raise ValueError("Cannot decrypt a Track without any Content Keys...") - if not binaries.ShakaPackager: - raise EnvironmentError("Shaka Packager executable not found but is required.") + if not path or not path.exists(): raise ValueError("Tried to decrypt a file that does not exist.") + if use_mp4decrypt: + return self._decrypt_with_mp4decrypt(path) + else: + return self._decrypt_with_shaka_packager(path) + + def _decrypt_with_mp4decrypt(self, path: Path) -> None: + """Decrypt using mp4decrypt""" + if not binaries.Mp4decrypt: + raise EnvironmentError("mp4decrypt executable not found but is required.") + + output_path = path.with_stem(f"{path.stem}_decrypted") + + # Build key arguments + key_args = [] + for kid, key in self.content_keys.items(): + kid_hex = kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "") + key_hex = key if isinstance(key, str) else key.hex() + key_args.extend(["--key", f"{kid_hex}:{key_hex}"]) + + cmd = [ + str(binaries.Mp4decrypt), + "--show-progress", + *key_args, + str(path), + str(output_path), + ] + + try: + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except subprocess.CalledProcessError as e: + error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}" + raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg) + + if not output_path.exists(): + raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created") + if output_path.stat().st_size == 0: + raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty") + + path.unlink() + shutil.move(output_path, path) + + def _decrypt_with_shaka_packager(self, path: Path) -> None: + """Decrypt using Shaka Packager (original method)""" + if not binaries.ShakaPackager: + raise EnvironmentError("Shaka Packager executable not found but is required.") + output_path = path.with_stem(f"{path.stem}_decrypted") config.directories.temp.mkdir(parents=True, exist_ok=True) diff --git a/unshackle/core/drm/widevine.py b/unshackle/core/drm/widevine.py index 4ead9b1..b08076a 100644 --- a/unshackle/core/drm/widevine.py +++ b/unshackle/core/drm/widevine.py @@ -227,22 +227,69 @@ class Widevine: finally: cdm.close(session_id) - def decrypt(self, path: Path) -> None: + def decrypt(self, path: Path, use_mp4decrypt: bool = False) -> None: """ Decrypt a Track with Widevine DRM. + Args: + path: Path to the encrypted file to decrypt + use_mp4decrypt: If True, use mp4decrypt instead of Shaka Packager Raises: - EnvironmentError if the Shaka Packager executable could not be found. + EnvironmentError if the required decryption executable could not be found. ValueError if the track has not yet been downloaded. - SubprocessError if Shaka Packager returned a non-zero exit code. + SubprocessError if the decryption process returned a non-zero exit code. """ if not self.content_keys: raise ValueError("Cannot decrypt a Track without any Content Keys...") - if not binaries.ShakaPackager: - raise EnvironmentError("Shaka Packager executable not found but is required.") if not path or not path.exists(): raise ValueError("Tried to decrypt a file that does not exist.") + if use_mp4decrypt: + return self._decrypt_with_mp4decrypt(path) + else: + return self._decrypt_with_shaka_packager(path) + + def _decrypt_with_mp4decrypt(self, path: Path) -> None: + """Decrypt using mp4decrypt""" + if not binaries.Mp4decrypt: + raise EnvironmentError("mp4decrypt executable not found but is required.") + + output_path = path.with_stem(f"{path.stem}_decrypted") + + # Build key arguments + key_args = [] + for kid, key in self.content_keys.items(): + kid_hex = kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "") + key_hex = key if isinstance(key, str) else key.hex() + key_args.extend(["--key", f"{kid_hex}:{key_hex}"]) + + cmd = [ + str(binaries.Mp4decrypt), + "--show-progress", + *key_args, + str(path), + str(output_path), + ] + + try: + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + except subprocess.CalledProcessError as e: + error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}" + raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg) + + if not output_path.exists(): + raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created") + if output_path.stat().st_size == 0: + raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty") + + path.unlink() + shutil.move(output_path, path) + + def _decrypt_with_shaka_packager(self, path: Path) -> None: + """Decrypt using Shaka Packager (original method)""" + if not binaries.ShakaPackager: + raise EnvironmentError("Shaka Packager executable not found but is required.") + output_path = path.with_stem(f"{path.stem}_decrypted") config.directories.temp.mkdir(parents=True, exist_ok=True)