mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2025-10-23 15:11:08 +00:00
feat(drm): ✨ Add support for mp4decrypt as a decryption method
* Introduced a new configuration option for DRM decryption in `unshackle.yaml`. * Updated the `decrypt` methods in `PlayReady` and `Widevine` classes to allow using `mp4decrypt`. * Enhanced the `Config` class to manage decryption methods per service. * Added `mp4decrypt` binary detection in the binaries module.
This commit is contained in:
31
CONFIG.md
31
CONFIG.md
@@ -213,6 +213,37 @@ downloader:
|
|||||||
|
|
||||||
The `default` entry is optional. If omitted, `requests` will be used for services not listed.
|
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 - <https://github.com/shaka-project/shaka-packager>
|
||||||
|
- `mp4decrypt` - mp4decrypt from Bento4 - <https://github.com/axiomatic-systems/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)
|
## filenames (dict)
|
||||||
|
|
||||||
Override the default filenames used across unshackle.
|
Override the default filenames used across unshackle.
|
||||||
|
|||||||
@@ -912,6 +912,31 @@ class dl:
|
|||||||
if font_count:
|
if font_count:
|
||||||
self.log.info(f"Attached {font_count} fonts for the Subtitles")
|
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..."):
|
with console.status("Repackaging tracks with FFMPEG..."):
|
||||||
has_repacked = False
|
has_repacked = False
|
||||||
for track in title.tracks:
|
for track in title.tracks:
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ def check() -> None:
|
|||||||
"desc": "DRM decryption",
|
"desc": "DRM decryption",
|
||||||
"cat": "DRM",
|
"cat": "DRM",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mp4decrypt",
|
||||||
|
"binary": binaries.Mp4decrypt,
|
||||||
|
"required": False,
|
||||||
|
"desc": "DRM decryption",
|
||||||
|
"cat": "DRM",
|
||||||
|
},
|
||||||
# HDR Processing
|
# HDR Processing
|
||||||
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"},
|
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ MKVToolNix = find("mkvmerge")
|
|||||||
Mkvpropedit = find("mkvpropedit")
|
Mkvpropedit = find("mkvpropedit")
|
||||||
DoviTool = find("dovi_tool")
|
DoviTool = find("dovi_tool")
|
||||||
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
|
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
|
||||||
|
Mp4decrypt = find("mp4decrypt")
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -71,5 +72,6 @@ __all__ = (
|
|||||||
"Mkvpropedit",
|
"Mkvpropedit",
|
||||||
"DoviTool",
|
"DoviTool",
|
||||||
"HDR10PlusTool",
|
"HDR10PlusTool",
|
||||||
|
"Mp4decrypt",
|
||||||
"find",
|
"find",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ class Config:
|
|||||||
self.proxy_providers: dict = kwargs.get("proxy_providers") or {}
|
self.proxy_providers: dict = kwargs.get("proxy_providers") or {}
|
||||||
self.serve: dict = kwargs.get("serve") or {}
|
self.serve: dict = kwargs.get("serve") or {}
|
||||||
self.services: dict = kwargs.get("services") 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.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False)
|
||||||
self.tag: str = kwargs.get("tag") or ""
|
self.tag: str = kwargs.get("tag") or ""
|
||||||
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
||||||
|
|||||||
@@ -187,14 +187,69 @@ class PlayReady:
|
|||||||
if not self.content_keys:
|
if not self.content_keys:
|
||||||
raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")
|
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:
|
if not self.content_keys:
|
||||||
raise ValueError("Cannot decrypt a Track without any 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():
|
if not path or not path.exists():
|
||||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
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")
|
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -227,22 +227,69 @@ class Widevine:
|
|||||||
finally:
|
finally:
|
||||||
cdm.close(session_id)
|
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.
|
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:
|
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.
|
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:
|
if not self.content_keys:
|
||||||
raise ValueError("Cannot decrypt a Track without any 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():
|
if not path or not path.exists():
|
||||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
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")
|
output_path = path.with_stem(f"{path.stem}_decrypted")
|
||||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user