Initial Commit
This commit is contained in:
0
unshackle/commands/__init__.py
Normal file
0
unshackle/commands/__init__.py
Normal file
90
unshackle/commands/cfg.py
Normal file
90
unshackle/commands/cfg.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import ast
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import click
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from unshackle.core.config import config, get_config_path
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.command(
|
||||
short_help="Manage configuration values for the program and its services.", context_settings=context_settings
|
||||
)
|
||||
@click.argument("key", type=str, required=False)
|
||||
@click.argument("value", type=str, required=False)
|
||||
@click.option("--unset", is_flag=True, default=False, help="Unset/remove the configuration value.")
|
||||
@click.option("--list", "list_", is_flag=True, default=False, help="List all set configuration values.")
|
||||
@click.pass_context
|
||||
def cfg(ctx: click.Context, key: str, value: str, unset: bool, list_: bool) -> None:
|
||||
"""
|
||||
Manage configuration values for the program and its services.
|
||||
|
||||
\b
|
||||
Known Issues:
|
||||
- Config changes remove all comments of the changed files, which may hold critical data. (#14)
|
||||
"""
|
||||
if not key and not value and not list_:
|
||||
raise click.UsageError("Nothing to do.", ctx)
|
||||
|
||||
if value:
|
||||
try:
|
||||
value = ast.literal_eval(value)
|
||||
except (ValueError, SyntaxError):
|
||||
pass # probably a str without quotes or similar, assume it's a string value
|
||||
|
||||
log = logging.getLogger("cfg")
|
||||
|
||||
yaml, data = YAML(), None
|
||||
yaml.default_flow_style = False
|
||||
|
||||
config_path = get_config_path() or config.directories.user_configs / config.filenames.root_config
|
||||
if config_path.exists():
|
||||
data = yaml.load(config_path)
|
||||
|
||||
if not data:
|
||||
log.warning("No config file was found or it has no data, yet")
|
||||
# yaml.load() returns `None` if the input data is blank instead of a usable object
|
||||
# force a usable object by making one and removing the only item within it
|
||||
data = yaml.load("""__TEMP__: null""")
|
||||
del data["__TEMP__"]
|
||||
|
||||
if list_:
|
||||
yaml.dump(data, sys.stdout)
|
||||
return
|
||||
|
||||
key_items = key.split(".")
|
||||
parent_key = key_items[:-1]
|
||||
trailing_key = key_items[-1]
|
||||
|
||||
is_write = value is not None
|
||||
is_delete = unset
|
||||
if is_write and is_delete:
|
||||
raise click.ClickException("You cannot set a value and use --unset at the same time.")
|
||||
|
||||
if not is_write and not is_delete:
|
||||
data = data.mlget(key_items, default=KeyError)
|
||||
if data == KeyError:
|
||||
raise click.ClickException(f"Key '{key}' does not exist in the config.")
|
||||
yaml.dump(data, sys.stdout)
|
||||
else:
|
||||
try:
|
||||
parent_data = data
|
||||
if parent_key:
|
||||
parent_data = data.mlget(parent_key, default=data)
|
||||
if parent_data == data:
|
||||
for key in parent_key:
|
||||
if not hasattr(parent_data, key):
|
||||
parent_data[key] = {}
|
||||
parent_data = parent_data[key]
|
||||
if is_write:
|
||||
parent_data[trailing_key] = value
|
||||
log.info(f"Set {key} to {repr(value)}")
|
||||
elif is_delete:
|
||||
del parent_data[trailing_key]
|
||||
log.info(f"Unset {key}")
|
||||
except KeyError:
|
||||
raise click.ClickException(f"Key '{key}' does not exist in the config.")
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
yaml.dump(data, config_path)
|
||||
1250
unshackle/commands/dl.py
Normal file
1250
unshackle/commands/dl.py
Normal file
File diff suppressed because it is too large
Load Diff
139
unshackle/commands/env.py
Normal file
139
unshackle/commands/env.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.padding import Padding
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
from unshackle.core.config import POSSIBLE_CONFIG_PATHS, config, config_path
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.utils.osenvironment import get_os_arch
|
||||
|
||||
|
||||
@click.group(short_help="Manage and configure the project environment.", context_settings=context_settings)
|
||||
def env() -> None:
|
||||
"""Manage and configure the project environment."""
|
||||
|
||||
|
||||
@env.command()
|
||||
def check() -> None:
|
||||
"""Checks environment for the required dependencies."""
|
||||
table = Table(title="Dependencies", expand=True)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Installed", justify="center")
|
||||
table.add_column("Path", no_wrap=False, overflow="fold")
|
||||
|
||||
# builds shaka-packager based on os, arch
|
||||
packager_dep = get_os_arch("packager")
|
||||
|
||||
# Helper function to find binary with multiple possible names
|
||||
def find_binary(*names):
|
||||
for name in names:
|
||||
if shutil.which(name):
|
||||
return name
|
||||
return names[0] # Return first name as fallback for display
|
||||
|
||||
dependencies = [
|
||||
{"name": "CCExtractor", "binary": "ccextractor"},
|
||||
{"name": "FFMpeg", "binary": "ffmpeg"},
|
||||
{"name": "MKVToolNix", "binary": "mkvmerge"},
|
||||
{"name": "Shaka-Packager", "binary": packager_dep},
|
||||
{"name": "N_m3u8DL-RE", "binary": find_binary("N_m3u8DL-RE", "n-m3u8dl-re")},
|
||||
{"name": "Aria2(c)", "binary": "aria2c"},
|
||||
]
|
||||
|
||||
for dep in dependencies:
|
||||
path = shutil.which(dep["binary"])
|
||||
|
||||
if path:
|
||||
installed = "[green]:heavy_check_mark:[/green]"
|
||||
path_output = path.lower()
|
||||
else:
|
||||
installed = "[red]:x:[/red]"
|
||||
path_output = "Not Found"
|
||||
|
||||
# Add to the table
|
||||
table.add_row(dep["name"], installed, path_output)
|
||||
|
||||
# Display the result
|
||||
console.print(Padding(table, (1, 5)))
|
||||
|
||||
|
||||
@env.command()
|
||||
def info() -> None:
|
||||
"""Displays information about the current environment."""
|
||||
log = logging.getLogger("env")
|
||||
|
||||
if config_path:
|
||||
log.info(f"Config loaded from {config_path}")
|
||||
else:
|
||||
tree = Tree("No config file found, you can use any of the following locations:")
|
||||
for i, path in enumerate(POSSIBLE_CONFIG_PATHS, start=1):
|
||||
tree.add(f"[repr.number]{i}.[/] [text2]{path.resolve()}[/]")
|
||||
console.print(Padding(tree, (0, 5)))
|
||||
|
||||
table = Table(title="Directories", expand=True)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Path", no_wrap=False, overflow="fold")
|
||||
|
||||
path_vars = {
|
||||
x: Path(os.getenv(x))
|
||||
for x in ("TEMP", "APPDATA", "LOCALAPPDATA", "USERPROFILE")
|
||||
if sys.platform == "win32" and os.getenv(x)
|
||||
}
|
||||
|
||||
for name in sorted(dir(config.directories)):
|
||||
if name.startswith("__") or name == "app_dirs":
|
||||
continue
|
||||
path = getattr(config.directories, name).resolve()
|
||||
for var, var_path in path_vars.items():
|
||||
if path.is_relative_to(var_path):
|
||||
path = rf"%{var}%\{path.relative_to(var_path)}"
|
||||
break
|
||||
table.add_row(name.title(), str(path))
|
||||
|
||||
console.print(Padding(table, (1, 5)))
|
||||
|
||||
|
||||
@env.group(name="clear", short_help="Clear an environment directory.", context_settings=context_settings)
|
||||
def clear() -> None:
|
||||
"""Clear an environment directory."""
|
||||
|
||||
|
||||
@clear.command()
|
||||
@click.argument("service", type=str, required=False)
|
||||
def cache(service: Optional[str]) -> None:
|
||||
"""Clear the environment cache directory."""
|
||||
log = logging.getLogger("env")
|
||||
cache_dir = config.directories.cache
|
||||
if service:
|
||||
cache_dir = cache_dir / Services.get_tag(service)
|
||||
log.info(f"Clearing cache directory: {cache_dir}")
|
||||
files_count = len(list(cache_dir.glob("**/*")))
|
||||
if not files_count:
|
||||
log.info("No files to delete")
|
||||
else:
|
||||
log.info(f"Deleting {files_count} files...")
|
||||
shutil.rmtree(cache_dir)
|
||||
log.info("Cleared")
|
||||
|
||||
|
||||
@clear.command()
|
||||
def temp() -> None:
|
||||
"""Clear the environment temp directory."""
|
||||
log = logging.getLogger("env")
|
||||
log.info(f"Clearing temp directory: {config.directories.temp}")
|
||||
files_count = len(list(config.directories.temp.glob("**/*")))
|
||||
if not files_count:
|
||||
log.info("No files to delete")
|
||||
else:
|
||||
log.info(f"Deleting {files_count} files...")
|
||||
shutil.rmtree(config.directories.temp)
|
||||
log.info("Cleared")
|
||||
200
unshackle/commands/kv.py
Normal file
200
unshackle/commands/kv.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.vault import Vault
|
||||
from unshackle.core.vaults import Vaults
|
||||
|
||||
|
||||
@click.group(short_help="Manage and configure Key Vaults.", context_settings=context_settings)
|
||||
def kv() -> None:
|
||||
"""Manage and configure Key Vaults."""
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("to_vault", type=str)
|
||||
@click.argument("from_vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
@click.option("-s", "--service", type=str, default=None, help="Only copy data to and from a specific service.")
|
||||
def copy(to_vault: str, from_vaults: list[str], service: Optional[str] = None) -> None:
|
||||
"""
|
||||
Copy data from multiple Key Vaults into a single Key Vault.
|
||||
Rows with matching KIDs are skipped unless there's no KEY set.
|
||||
Existing data is not deleted or altered.
|
||||
|
||||
The `to_vault` argument is the key vault you wish to copy data to.
|
||||
It should be the name of a Key Vault defined in the config.
|
||||
|
||||
The `from_vaults` argument is the key vault(s) you wish to take
|
||||
data from. You may supply multiple key vaults.
|
||||
"""
|
||||
if not from_vaults:
|
||||
raise click.ClickException("No Vaults were specified to copy data from.")
|
||||
|
||||
log = logging.getLogger("kv")
|
||||
|
||||
vaults = Vaults()
|
||||
for vault_name in [to_vault] + list(from_vaults):
|
||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
||||
if not vault:
|
||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
||||
vault_type = vault["type"]
|
||||
vault_args = vault.copy()
|
||||
del vault_args["type"]
|
||||
vaults.load(vault_type, **vault_args)
|
||||
|
||||
to_vault: Vault = vaults.vaults[0]
|
||||
from_vaults: list[Vault] = vaults.vaults[1:]
|
||||
|
||||
log.info(f"Copying data from {', '.join([x.name for x in from_vaults])}, into {to_vault.name}")
|
||||
if service:
|
||||
service = Services.get_tag(service)
|
||||
log.info(f"Only copying data for service {service}")
|
||||
|
||||
total_added = 0
|
||||
for from_vault in from_vaults:
|
||||
if service:
|
||||
services = [service]
|
||||
else:
|
||||
services = from_vault.get_services()
|
||||
|
||||
for service_ in services:
|
||||
log.info(f"Getting data from {from_vault} for {service_}")
|
||||
content_keys = list(from_vault.get_keys(service_)) # important as it's a generator we iterate twice
|
||||
|
||||
bad_keys = {kid: key for kid, key in content_keys if not key or key.count("0") == len(key)}
|
||||
|
||||
for kid, key in bad_keys.items():
|
||||
log.warning(f"Cannot add a NULL Content Key to a Vault, skipping: {kid}:{key}")
|
||||
|
||||
content_keys = {kid: key for kid, key in content_keys if kid not in bad_keys}
|
||||
|
||||
total_count = len(content_keys)
|
||||
log.info(f"Adding {total_count} Content Keys to {to_vault} for {service_}")
|
||||
|
||||
try:
|
||||
added = to_vault.add_keys(service_, content_keys)
|
||||
except PermissionError:
|
||||
log.warning(f" - No permission to create table ({service_}) in {to_vault}, skipping...")
|
||||
continue
|
||||
|
||||
total_added += added
|
||||
existed = total_count - added
|
||||
|
||||
log.info(f"{to_vault} ({service_}): {added} newly added, {existed} already existed (skipped)")
|
||||
|
||||
log.info(f"{to_vault}: {total_added} total newly added")
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
@click.option("-s", "--service", type=str, default=None, help="Only sync data to and from a specific service.")
|
||||
@click.pass_context
|
||||
def sync(ctx: click.Context, vaults: list[str], service: Optional[str] = None) -> None:
|
||||
"""
|
||||
Ensure multiple Key Vaults copies of all keys as each other.
|
||||
It's essentially just a bi-way copy between each vault.
|
||||
To see the precise details of what it's doing between each
|
||||
provided vault, see the documentation for the `copy` command.
|
||||
"""
|
||||
if not len(vaults) > 1:
|
||||
raise click.ClickException("You must provide more than one Vault to sync.")
|
||||
|
||||
ctx.invoke(copy, to_vault=vaults[0], from_vaults=vaults[1:], service=service)
|
||||
for i in range(1, len(vaults)):
|
||||
ctx.invoke(copy, to_vault=vaults[i], from_vaults=[vaults[i - 1]], service=service)
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("file", type=Path)
|
||||
@click.argument("service", type=str)
|
||||
@click.argument("vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
def add(file: Path, service: str, vaults: list[str]) -> None:
|
||||
"""
|
||||
Add new Content Keys to Key Vault(s) by service.
|
||||
|
||||
File should contain one key per line in the format KID:KEY (HEX:HEX).
|
||||
Each line should have nothing else within it except for the KID:KEY.
|
||||
Encoding is presumed to be UTF8.
|
||||
"""
|
||||
if not file.exists():
|
||||
raise click.ClickException(f"File provided ({file}) does not exist.")
|
||||
if not file.is_file():
|
||||
raise click.ClickException(f"File provided ({file}) is not a file.")
|
||||
if not service or not isinstance(service, str):
|
||||
raise click.ClickException(f"Service provided ({service}) is invalid.")
|
||||
if len(vaults) < 1:
|
||||
raise click.ClickException("You must provide at least one Vault.")
|
||||
|
||||
log = logging.getLogger("kv")
|
||||
service = Services.get_tag(service)
|
||||
|
||||
vaults_ = Vaults()
|
||||
for vault_name in vaults:
|
||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
||||
if not vault:
|
||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
||||
vault_type = vault["type"]
|
||||
vault_args = vault.copy()
|
||||
del vault_args["type"]
|
||||
vaults_.load(vault_type, **vault_args)
|
||||
|
||||
data = file.read_text(encoding="utf8")
|
||||
kid_keys: dict[str, str] = {}
|
||||
for line in data.splitlines(keepends=False):
|
||||
line = line.strip()
|
||||
match = re.search(r"^(?P<kid>[0-9a-fA-F]{32}):(?P<key>[0-9a-fA-F]{32})$", line)
|
||||
if not match:
|
||||
continue
|
||||
kid = match.group("kid").lower()
|
||||
key = match.group("key").lower()
|
||||
kid_keys[kid] = key
|
||||
|
||||
total_count = len(kid_keys)
|
||||
|
||||
for vault in vaults_:
|
||||
log.info(f"Adding {total_count} Content Keys to {vault}")
|
||||
added_count = vault.add_keys(service, kid_keys)
|
||||
existed_count = total_count - added_count
|
||||
log.info(f"{vault}: {added_count} newly added, {existed_count} already existed (skipped)")
|
||||
|
||||
log.info("Done!")
|
||||
|
||||
|
||||
@kv.command()
|
||||
@click.argument("vaults", nargs=-1, type=click.UNPROCESSED)
|
||||
def prepare(vaults: list[str]) -> None:
|
||||
"""Create Service Tables on Vaults if not yet created."""
|
||||
log = logging.getLogger("kv")
|
||||
|
||||
vaults_ = Vaults()
|
||||
for vault_name in vaults:
|
||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
||||
if not vault:
|
||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
||||
vault_type = vault["type"]
|
||||
vault_args = vault.copy()
|
||||
del vault_args["type"]
|
||||
vaults_.load(vault_type, **vault_args)
|
||||
|
||||
for vault in vaults_:
|
||||
if hasattr(vault, "has_table") and hasattr(vault, "create_table"):
|
||||
for service_tag in Services.get_tags():
|
||||
if vault.has_table(service_tag):
|
||||
log.info(f"{vault} already has a {service_tag} Table")
|
||||
else:
|
||||
try:
|
||||
vault.create_table(service_tag, commit=True)
|
||||
log.info(f"{vault}: Created {service_tag} Table")
|
||||
except PermissionError:
|
||||
log.error(f"{vault} user has no create table permission, skipping...")
|
||||
continue
|
||||
else:
|
||||
log.info(f"{vault} does not use tables, skipping...")
|
||||
|
||||
log.info("Done!")
|
||||
271
unshackle/commands/prd.py
Normal file
271
unshackle/commands/prd.py
Normal file
@@ -0,0 +1,271 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
import requests
|
||||
from Crypto.Random import get_random_bytes
|
||||
from pyplayready.cdm import Cdm
|
||||
from pyplayready.crypto.ecc_key import ECCKey
|
||||
from pyplayready.device import Device
|
||||
from pyplayready.exceptions import InvalidCertificateChain, OutdatedDevice
|
||||
from pyplayready.system.bcert import Certificate, CertificateChain
|
||||
from pyplayready.system.pssh import PSSH
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.group(
|
||||
short_help="Manage creation of PRD (Playready Device) files.",
|
||||
context_settings=context_settings,
|
||||
)
|
||||
def prd() -> None:
|
||||
"""Manage creation of PRD (Playready Device) files."""
|
||||
|
||||
|
||||
@prd.command()
|
||||
@click.argument("paths", type=Path, nargs=-1)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--encryption_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private encryption key",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--signing_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private signing key",
|
||||
)
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Directory")
|
||||
@click.pass_context
|
||||
def new(
|
||||
ctx: click.Context,
|
||||
paths: tuple[Path, ...],
|
||||
encryption_key: Optional[Path],
|
||||
signing_key: Optional[Path],
|
||||
output: Optional[Path],
|
||||
) -> None:
|
||||
"""Create a new .PRD PlayReady Device file.
|
||||
|
||||
Accepts either paths to a group key and certificate or a single directory
|
||||
containing ``zgpriv.dat`` and ``bgroupcert.dat``.
|
||||
"""
|
||||
if len(paths) == 1 and paths[0].is_dir():
|
||||
device_dir = paths[0]
|
||||
group_key = device_dir / "zgpriv.dat"
|
||||
group_certificate = device_dir / "bgroupcert.dat"
|
||||
if not group_key.is_file() or not group_certificate.is_file():
|
||||
raise click.UsageError("Folder must contain zgpriv.dat and bgroupcert.dat", ctx)
|
||||
elif len(paths) == 2:
|
||||
group_key, group_certificate = paths
|
||||
if not group_key.is_file():
|
||||
raise click.UsageError("group_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if not group_certificate.is_file():
|
||||
raise click.UsageError("group_certificate: Not a path to a file, or it doesn't exist.", ctx)
|
||||
device_dir = None
|
||||
else:
|
||||
raise click.UsageError(
|
||||
"Provide either a folder path or paths to group_key and group_certificate",
|
||||
ctx,
|
||||
)
|
||||
if encryption_key and not encryption_key.is_file():
|
||||
raise click.UsageError("encryption_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if signing_key and not signing_key.is_file():
|
||||
raise click.UsageError("signing_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("prd")
|
||||
|
||||
encryption_key_obj = ECCKey.load(encryption_key) if encryption_key else ECCKey.generate()
|
||||
signing_key_obj = ECCKey.load(signing_key) if signing_key else ECCKey.generate()
|
||||
|
||||
group_key_obj = ECCKey.load(group_key)
|
||||
certificate_chain = CertificateChain.load(group_certificate)
|
||||
|
||||
if certificate_chain.get(0).get_issuer_key() != group_key_obj.public_bytes():
|
||||
raise InvalidCertificateChain("Group key does not match this certificate")
|
||||
|
||||
new_certificate = Certificate.new_leaf_cert(
|
||||
cert_id=get_random_bytes(16),
|
||||
security_level=certificate_chain.get_security_level(),
|
||||
client_id=get_random_bytes(16),
|
||||
signing_key=signing_key_obj,
|
||||
encryption_key=encryption_key_obj,
|
||||
group_key=group_key_obj,
|
||||
parent=certificate_chain,
|
||||
)
|
||||
certificate_chain.prepend(new_certificate)
|
||||
certificate_chain.verify()
|
||||
|
||||
device = Device(
|
||||
group_key=group_key_obj.dumps(),
|
||||
encryption_key=encryption_key_obj.dumps(),
|
||||
signing_key=signing_key_obj.dumps(),
|
||||
group_certificate=certificate_chain.dumps(),
|
||||
)
|
||||
|
||||
if output and output.suffix:
|
||||
if output.suffix.lower() != ".prd":
|
||||
log.warning(
|
||||
"Saving PRD with the file extension '%s' but '.prd' is recommended.",
|
||||
output.suffix,
|
||||
)
|
||||
out_path = output
|
||||
else:
|
||||
out_dir = output or (device_dir or config.directories.prds)
|
||||
out_path = out_dir / f"{device.get_name()}.prd"
|
||||
|
||||
if out_path.exists():
|
||||
log.error("A file already exists at the path '%s', cannot overwrite.", out_path)
|
||||
return
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(device.dumps())
|
||||
|
||||
log.info("Created Playready Device (.prd) file, %s", out_path.name)
|
||||
log.info(" + Security Level: %s", device.security_level)
|
||||
log.info(" + Group Key: %s bytes", len(device.group_key.dumps()))
|
||||
log.info(" + Encryption Key: %s bytes", len(device.encryption_key.dumps()))
|
||||
log.info(" + Signing Key: %s bytes", len(device.signing_key.dumps()))
|
||||
log.info(" + Group Certificate: %s bytes", len(device.group_certificate.dumps()))
|
||||
log.info(" + Saved to: %s", out_path.absolute())
|
||||
|
||||
|
||||
@prd.command(name="reprovision")
|
||||
@click.argument("prd_path", type=Path)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--encryption_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private encryption key",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--signing_key",
|
||||
type=Path,
|
||||
required=False,
|
||||
help="Optional Device ECC private signing key",
|
||||
)
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Path or Directory")
|
||||
@click.pass_context
|
||||
def reprovision_device(
|
||||
ctx: click.Context,
|
||||
prd_path: Path,
|
||||
encryption_key: Optional[Path],
|
||||
signing_key: Optional[Path],
|
||||
output: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Reprovision a Playready Device (.prd) file."""
|
||||
if not prd_path.is_file():
|
||||
raise click.UsageError("prd_path: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("prd")
|
||||
log.info("Reprovisioning Playready Device (.prd) file, %s", prd_path.name)
|
||||
|
||||
device = Device.load(prd_path)
|
||||
|
||||
if device.group_key is None:
|
||||
raise OutdatedDevice(
|
||||
"Device does not support reprovisioning, re-create it or use a Device with a version of 3 or higher"
|
||||
)
|
||||
|
||||
device.group_certificate.remove(0)
|
||||
|
||||
encryption_key_obj = ECCKey.load(encryption_key) if encryption_key else ECCKey.generate()
|
||||
signing_key_obj = ECCKey.load(signing_key) if signing_key else ECCKey.generate()
|
||||
|
||||
device.encryption_key = encryption_key_obj
|
||||
device.signing_key = signing_key_obj
|
||||
|
||||
new_certificate = Certificate.new_leaf_cert(
|
||||
cert_id=get_random_bytes(16),
|
||||
security_level=device.group_certificate.get_security_level(),
|
||||
client_id=get_random_bytes(16),
|
||||
signing_key=signing_key_obj,
|
||||
encryption_key=encryption_key_obj,
|
||||
group_key=device.group_key,
|
||||
parent=device.group_certificate,
|
||||
)
|
||||
device.group_certificate.prepend(new_certificate)
|
||||
|
||||
if output and output.suffix:
|
||||
if output.suffix.lower() != ".prd":
|
||||
log.warning(
|
||||
"Saving PRD with the file extension '%s' but '.prd' is recommended.",
|
||||
output.suffix,
|
||||
)
|
||||
out_path = output
|
||||
else:
|
||||
out_path = prd_path
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(device.dumps())
|
||||
|
||||
log.info("Reprovisioned Playready Device (.prd) file, %s", out_path.name)
|
||||
|
||||
|
||||
@prd.command()
|
||||
@click.argument("device", type=Path)
|
||||
@click.option(
|
||||
"-c",
|
||||
"--ckt",
|
||||
type=click.Choice(["aesctr", "aescbc"], case_sensitive=False),
|
||||
default="aesctr",
|
||||
help="Content Key Encryption Type",
|
||||
)
|
||||
@click.option(
|
||||
"-sl",
|
||||
"--security-level",
|
||||
type=click.Choice(["150", "2000", "3000"], case_sensitive=False),
|
||||
default="2000",
|
||||
help="Minimum Security Level",
|
||||
)
|
||||
@click.pass_context
|
||||
def test(
|
||||
ctx: click.Context,
|
||||
device: Path,
|
||||
ckt: str,
|
||||
security_level: str,
|
||||
) -> None:
|
||||
"""Test a Playready Device on the Microsoft demo server."""
|
||||
|
||||
if not device.is_file():
|
||||
raise click.UsageError("device: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("prd")
|
||||
|
||||
prd_device = Device.load(device)
|
||||
log.info("Loaded Device: %s", prd_device.get_name())
|
||||
|
||||
cdm = Cdm.from_device(prd_device)
|
||||
log.info("Loaded CDM")
|
||||
|
||||
session_id = cdm.open()
|
||||
log.info("Opened Session")
|
||||
|
||||
pssh_b64 = "AAADfHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1xcAwAAAQABAFIDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AHQAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AbQBpAGMAcgBvAHMAbwBmAHQALgBjAG8AbQAvAEQAUgBNAC8AMgAwADAANwAvADAAMwAvAFAAbABhAHkAUgBlAGEAZAB5AEgAZQBhAGQAZQByACIAIAB2AGUAcgBzAGkAbwBuAD0AIgA0AC4AMAAuADAALgAwACIAPgA8AEQAQQBUAEEAPgA8AFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBFAFkATABFAE4APgAxADYAPAAvAEsARQBZAEwARQBOAD4APABBAEwARwBJAEQAPgBBAEUAUwBDAFQAUgA8AC8AQQBMAEcASQBEAD4APAAvAFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBJAEQAPgA0AFIAcABsAGIAKwBUAGIATgBFAFMAOAB0AEcAawBOAEYAVwBUAEUASABBAD0APQA8AC8ASwBJAEQAPgA8AEMASABFAEMASwBTAFUATQA+AEsATABqADMAUQB6AFEAUAAvAE4AQQA9ADwALwBDAEgARQBDAEsAUwBVAE0APgA8AEwAQQBfAFUAUgBMAD4AaAB0AHQAcABzADoALwAvAHAAcgBvAGYAZgBpAGMAaQBhAGwAcwBpAHQAZQAuAGsAZQB5AGQAZQBsAGkAdgBlAHIAeQAuAG0AZQBkAGkAYQBzAGUAcgB2AGkAYwBlAHMALgB3AGkAbgBkAG8AdwBzAC4AbgBlAHQALwBQAGwAYQB5AFIAZQBhAGQAeQAvADwALwBMAEEAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwASQBJAFMAXwBEAFIATQBfAFYARQBSAFMASQBPAE4APgA4AC4AMQAuADIAMwAwADQALgAzADEAPAAvAEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4APAAvAEMAVQBTAFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAPgA8AC8ARABBAFQAQQA+ADwALwBXAFIATQBIAEUAQQBEAEUAUgA+AA=="
|
||||
pssh = PSSH(pssh_b64)
|
||||
|
||||
challenge = cdm.get_license_challenge(session_id, pssh.wrm_headers[0])
|
||||
log.info("Created License Request")
|
||||
|
||||
license_server = f"https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:{security_level},ckt:{ckt})"
|
||||
|
||||
response = requests.post(
|
||||
url=license_server,
|
||||
headers={"Content-Type": "text/xml; charset=UTF-8"},
|
||||
data=challenge,
|
||||
)
|
||||
|
||||
cdm.parse_license(session_id, response.text)
|
||||
log.info("License Parsed Successfully")
|
||||
|
||||
for key in cdm.get_keys(session_id):
|
||||
log.info(f"{key.key_id.hex}:{key.key.hex()}")
|
||||
|
||||
cdm.close(session_id)
|
||||
log.info("Closed Session")
|
||||
149
unshackle/commands/search.py
Normal file
149
unshackle/commands/search.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
import yaml
|
||||
from rich.padding import Padding
|
||||
from rich.rule import Rule
|
||||
from rich.tree import Tree
|
||||
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.proxies import Basic, Hola, NordVPN
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.services import Services
|
||||
from unshackle.core.utils.click_types import ContextData
|
||||
from unshackle.core.utils.collections import merge_dict
|
||||
|
||||
|
||||
@click.command(
|
||||
short_help="Search for titles from a Service.",
|
||||
cls=Services,
|
||||
context_settings=dict(**context_settings, token_normalize_func=Services.get_tag),
|
||||
)
|
||||
@click.option(
|
||||
"-p", "--profile", type=str, default=None, help="Profile to use for Credentials and Cookies (if available)."
|
||||
)
|
||||
@click.option(
|
||||
"--proxy",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Proxy URI to use. If a 2-letter country is provided, it will try get a proxy from the config.",
|
||||
)
|
||||
@click.option("--no-proxy", is_flag=True, default=False, help="Force disable all proxy use.")
|
||||
@click.pass_context
|
||||
def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, proxy: Optional[str] = None):
|
||||
if not ctx.invoked_subcommand:
|
||||
raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.")
|
||||
|
||||
log = logging.getLogger("search")
|
||||
|
||||
service = Services.get_tag(ctx.invoked_subcommand)
|
||||
profile = profile
|
||||
|
||||
if profile:
|
||||
log.info(f"Using profile: '{profile}'")
|
||||
|
||||
with console.status("Loading Service Config...", spinner="dots"):
|
||||
service_config_path = Services.get_path(service) / config.filenames.config
|
||||
if service_config_path.exists():
|
||||
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
||||
log.info("Service Config loaded")
|
||||
else:
|
||||
service_config = {}
|
||||
merge_dict(config.services.get(service), service_config)
|
||||
|
||||
proxy_providers = []
|
||||
if no_proxy:
|
||||
ctx.params["proxy"] = None
|
||||
else:
|
||||
with console.status("Loading Proxy Providers...", spinner="dots"):
|
||||
if config.proxy_providers.get("basic"):
|
||||
proxy_providers.append(Basic(**config.proxy_providers["basic"]))
|
||||
if config.proxy_providers.get("nordvpn"):
|
||||
proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
|
||||
if binaries.HolaProxy:
|
||||
proxy_providers.append(Hola())
|
||||
for proxy_provider in proxy_providers:
|
||||
log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
|
||||
|
||||
if proxy:
|
||||
requested_provider = None
|
||||
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||
# requesting proxy from a specific proxy provider
|
||||
requested_provider, proxy = proxy.split(":", maxsplit=1)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
proxy = proxy.lower()
|
||||
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
|
||||
if requested_provider:
|
||||
proxy_provider = next(
|
||||
(x for x in proxy_providers if x.__class__.__name__.lower() == requested_provider), None
|
||||
)
|
||||
if not proxy_provider:
|
||||
log.error(f"The proxy provider '{requested_provider}' was not recognised.")
|
||||
sys.exit(1)
|
||||
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||
if not proxy_uri:
|
||||
log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}")
|
||||
sys.exit(1)
|
||||
proxy = ctx.params["proxy"] = proxy_uri
|
||||
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
else:
|
||||
for proxy_provider in proxy_providers:
|
||||
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||
if proxy_uri:
|
||||
proxy = ctx.params["proxy"] = proxy_uri
|
||||
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
break
|
||||
else:
|
||||
log.info(f"Using explicit Proxy: {proxy}")
|
||||
|
||||
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
||||
|
||||
|
||||
@search.result_callback()
|
||||
def result(service: Service, profile: Optional[str] = None, **_: Any) -> None:
|
||||
log = logging.getLogger("search")
|
||||
|
||||
service_tag = service.__class__.__name__
|
||||
|
||||
with console.status("Authenticating with Service...", spinner="dots"):
|
||||
cookies = dl.get_cookie_jar(service_tag, profile)
|
||||
credential = dl.get_credentials(service_tag, profile)
|
||||
service.authenticate(cookies, credential)
|
||||
if cookies or credential:
|
||||
log.info("Authenticated with Service")
|
||||
|
||||
search_results = Tree("Search Results", hide_root=True)
|
||||
with console.status("Searching...", spinner="dots"):
|
||||
for result in service.search():
|
||||
result_text = f"[bold text]{result.title}[/]"
|
||||
if result.url:
|
||||
result_text = f"[link={result.url}]{result_text}[/link]"
|
||||
if result.label:
|
||||
result_text += f" [pink]{result.label}[/]"
|
||||
if result.description:
|
||||
result_text += f"\n[text2]{result.description}[/]"
|
||||
result_text += f"\n[bright_black]id: {result.id}[/]"
|
||||
search_results.add(result_text + "\n")
|
||||
|
||||
# update cookies
|
||||
cookie_file = dl.get_cookie_path(service_tag, profile)
|
||||
if cookie_file:
|
||||
dl.save_cookies(cookie_file, service.session.cookies)
|
||||
|
||||
console.print(Padding(Rule(f"[rule.text]{len(search_results.children)} Search Results"), (1, 2)))
|
||||
|
||||
if search_results.children:
|
||||
console.print(Padding(search_results, (0, 5)))
|
||||
else:
|
||||
console.print(
|
||||
Padding("[bold text]No matches[/]\n[bright_black]Please check spelling and search again....[/]", (0, 5))
|
||||
)
|
||||
45
unshackle/commands/serve.py
Normal file
45
unshackle/commands/serve.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.command(short_help="Serve your Local Widevine Devices for Remote Access.", context_settings=context_settings)
|
||||
@click.option("-h", "--host", type=str, default="0.0.0.0", help="Host to serve from.")
|
||||
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||
@click.option("--caddy", is_flag=True, default=False, help="Also serve with Caddy.")
|
||||
def serve(host: str, port: int, caddy: bool) -> None:
|
||||
"""
|
||||
Serve your Local Widevine Devices for Remote Access.
|
||||
|
||||
\b
|
||||
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
||||
|
||||
\b
|
||||
You may serve with Caddy at the same time with --caddy. You can use Caddy
|
||||
as a reverse-proxy to serve with HTTPS. The config used will be the Caddyfile
|
||||
next to the unshackle config.
|
||||
"""
|
||||
from pywidevine import serve
|
||||
|
||||
if caddy:
|
||||
if not binaries.Caddy:
|
||||
raise click.ClickException('Caddy executable "caddy" not found but is required for --caddy.')
|
||||
caddy_p = subprocess.Popen(
|
||||
[binaries.Caddy, "run", "--config", str(config.directories.user_configs / "Caddyfile")]
|
||||
)
|
||||
else:
|
||||
caddy_p = None
|
||||
|
||||
try:
|
||||
if not config.serve.get("devices"):
|
||||
config.serve["devices"] = []
|
||||
config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd")))
|
||||
serve.run(config.serve, host, port)
|
||||
finally:
|
||||
if caddy_p:
|
||||
caddy_p.kill()
|
||||
267
unshackle/commands/util.py
Normal file
267
unshackle/commands/util.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from pymediainfo import MediaInfo
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.group(short_help="Various helper scripts and programs.", context_settings=context_settings)
|
||||
def util() -> None:
|
||||
"""Various helper scripts and programs."""
|
||||
|
||||
|
||||
@util.command()
|
||||
@click.argument("path", type=Path)
|
||||
@click.argument("aspect", type=str)
|
||||
@click.option(
|
||||
"--letter/--pillar",
|
||||
default=True,
|
||||
help="Specify which direction to crop. Top and Bottom would be --letter, Sides would be --pillar.",
|
||||
)
|
||||
@click.option("-o", "--offset", type=int, default=0, help="Fine tune the computed crop area if not perfectly centered.")
|
||||
@click.option(
|
||||
"-p",
|
||||
"--preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Instantly preview the newly-set aspect crop in MPV (or ffplay if mpv is unavailable).",
|
||||
)
|
||||
def crop(path: Path, aspect: str, letter: bool, offset: int, preview: bool) -> None:
|
||||
"""
|
||||
Losslessly crop H.264 and H.265 video files at the bit-stream level.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
Note: If you notice that the values you put in are not quite working, try
|
||||
tune -o/--offset. This may be necessary on videos with sub-sampled chroma.
|
||||
|
||||
Do note that you may not get an ideal lossless cropping result on some
|
||||
cases, again due to sub-sampled chroma.
|
||||
|
||||
It's recommended that you try -o about 10 or so pixels and lower it until
|
||||
you get as close in as possible. Do make sure it's not over-cropping either
|
||||
as it may go from being 2px away from a perfect crop, to 20px over-cropping
|
||||
again due to sub-sampled chroma.
|
||||
"""
|
||||
if not binaries.FFMPEG:
|
||||
raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.')
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
try:
|
||||
video_track = next(iter(MediaInfo.parse(video_path).video_tracks or []))
|
||||
except StopIteration:
|
||||
raise click.ClickException("There's no video tracks in the provided file.")
|
||||
|
||||
crop_filter = {"HEVC": "hevc_metadata", "AVC": "h264_metadata"}.get(video_track.commercial_name)
|
||||
if not crop_filter:
|
||||
raise click.ClickException(f"{video_track.commercial_name} Codec not supported.")
|
||||
|
||||
aspect_w, aspect_h = list(map(float, aspect.split(":")))
|
||||
if letter:
|
||||
crop_value = (video_track.height - (video_track.width / (aspect_w * aspect_h))) / 2
|
||||
left, top, right, bottom = map(int, [0, crop_value + offset, 0, crop_value - offset])
|
||||
else:
|
||||
crop_value = (video_track.width - (video_track.height * (aspect_w / aspect_h))) / 2
|
||||
left, top, right, bottom = map(int, [crop_value + offset, 0, crop_value - offset, 0])
|
||||
crop_filter += f"=crop_left={left}:crop_top={top}:crop_right={right}:crop_bottom={bottom}"
|
||||
|
||||
if min(left, top, right, bottom) < 0:
|
||||
raise click.ClickException("Cannot crop less than 0, are you cropping in the right direction?")
|
||||
|
||||
if preview:
|
||||
out_path = ["-f", "mpegts", "-"] # pipe
|
||||
else:
|
||||
out_path = [
|
||||
str(
|
||||
video_path.with_name(
|
||||
".".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
video_path.stem,
|
||||
video_track.language,
|
||||
"crop",
|
||||
str(offset or ""),
|
||||
{
|
||||
# ffmpeg's MKV muxer does not yet support HDR
|
||||
"HEVC": "h265",
|
||||
"AVC": "h264",
|
||||
}.get(video_track.commercial_name, ".mp4"),
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
ffmpeg_call = subprocess.Popen(
|
||||
[binaries.FFMPEG, "-y", "-i", str(video_path), "-map", "0:v:0", "-c", "copy", "-bsf:v", crop_filter]
|
||||
+ out_path,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
if preview:
|
||||
previewer = binaries.MPV or binaries.FFPlay
|
||||
if not previewer:
|
||||
raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.")
|
||||
subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout)
|
||||
finally:
|
||||
if ffmpeg_call.stdout:
|
||||
ffmpeg_call.stdout.close()
|
||||
ffmpeg_call.wait()
|
||||
|
||||
|
||||
@util.command(name="range")
|
||||
@click.argument("path", type=Path)
|
||||
@click.option("--full/--limited", is_flag=True, help="Full: 0..255, Limited: 16..235 (16..240 YUV luma)")
|
||||
@click.option(
|
||||
"-p",
|
||||
"--preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Instantly preview the newly-set video range in MPV (or ffplay if mpv is unavailable).",
|
||||
)
|
||||
def range_(path: Path, full: bool, preview: bool) -> None:
|
||||
"""
|
||||
Losslessly set the Video Range flag to full or limited at the bit-stream level.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
If you ever notice blacks not being quite black, and whites not being quite white,
|
||||
then you're video may have the range set to the wrong value. Flip its range to the
|
||||
opposite value and see if that fixes it.
|
||||
"""
|
||||
if not binaries.FFMPEG:
|
||||
raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.')
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
try:
|
||||
video_track = next(iter(MediaInfo.parse(video_path).video_tracks or []))
|
||||
except StopIteration:
|
||||
raise click.ClickException("There's no video tracks in the provided file.")
|
||||
|
||||
metadata_key = {"HEVC": "hevc_metadata", "AVC": "h264_metadata"}.get(video_track.commercial_name)
|
||||
if not metadata_key:
|
||||
raise click.ClickException(f"{video_track.commercial_name} Codec not supported.")
|
||||
|
||||
if preview:
|
||||
out_path = ["-f", "mpegts", "-"] # pipe
|
||||
else:
|
||||
out_path = [
|
||||
str(
|
||||
video_path.with_name(
|
||||
".".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
video_path.stem,
|
||||
video_track.language,
|
||||
"range",
|
||||
["limited", "full"][full],
|
||||
{
|
||||
# ffmpeg's MKV muxer does not yet support HDR
|
||||
"HEVC": "h265",
|
||||
"AVC": "h264",
|
||||
}.get(video_track.commercial_name, ".mp4"),
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
ffmpeg_call = subprocess.Popen(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-c",
|
||||
"copy",
|
||||
"-bsf:v",
|
||||
f"{metadata_key}=video_full_range_flag={int(full)}",
|
||||
]
|
||||
+ out_path,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
if preview:
|
||||
previewer = binaries.MPV or binaries.FFPlay
|
||||
if not previewer:
|
||||
raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.")
|
||||
subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout)
|
||||
finally:
|
||||
if ffmpeg_call.stdout:
|
||||
ffmpeg_call.stdout.close()
|
||||
ffmpeg_call.wait()
|
||||
|
||||
|
||||
@util.command()
|
||||
@click.argument("path", type=Path)
|
||||
@click.option(
|
||||
"-m", "--map", "map_", type=str, default="0", help="Test specific streams by setting FFmpeg's -map parameter."
|
||||
)
|
||||
def test(path: Path, map_: str) -> None:
|
||||
"""
|
||||
Decode an entire video and check for any corruptions or errors using FFmpeg.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
Tests all streams within the file by default. Subtitles cannot be tested.
|
||||
You may choose specific streams using the -m/--map parameter. E.g.,
|
||||
'0:v:0' to test the first video stream, or '0:a' to test all audio streams.
|
||||
"""
|
||||
if not binaries.FFMPEG:
|
||||
raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.')
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
print("Starting...")
|
||||
p = subprocess.Popen(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-benchmark",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-map",
|
||||
map_,
|
||||
"-sn",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
reached_output = False
|
||||
errors = 0
|
||||
for line in p.stderr:
|
||||
line = line.strip()
|
||||
if "speed=" in line:
|
||||
reached_output = True
|
||||
if not reached_output:
|
||||
continue
|
||||
if line.startswith("["): # error of some kind
|
||||
errors += 1
|
||||
stream, error = line.split("] ", maxsplit=1)
|
||||
stream = stream.split(" @ ")[0]
|
||||
line = f"{stream} ERROR: {error}"
|
||||
print(line)
|
||||
p.stderr.close()
|
||||
print(f"Finished with {errors} Errors, Cleaning up...")
|
||||
p.terminate()
|
||||
p.wait()
|
||||
272
unshackle/commands/wvd.py
Normal file
272
unshackle/commands/wvd.py
Normal file
@@ -0,0 +1,272 @@
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
import yaml
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from pywidevine.device import Device, DeviceTypes
|
||||
from pywidevine.license_protocol_pb2 import FileHashes
|
||||
from rich.prompt import Prompt
|
||||
from unidecode import UnidecodeError, unidecode
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
|
||||
|
||||
@click.group(
|
||||
short_help="Manage configuration and creation of WVD (Widevine Device) files.", context_settings=context_settings
|
||||
)
|
||||
def wvd() -> None:
|
||||
"""Manage configuration and creation of WVD (Widevine Device) files."""
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("paths", type=Path, nargs=-1)
|
||||
def add(paths: list[Path]) -> None:
|
||||
"""Add one or more WVD (Widevine Device) files to the WVDs Directory."""
|
||||
log = logging.getLogger("wvd")
|
||||
for path in paths:
|
||||
dst_path = config.directories.wvds / path.name
|
||||
|
||||
if not path.exists():
|
||||
log.error(f"The WVD path '{path}' does not exist...")
|
||||
elif dst_path.exists():
|
||||
log.error(f"WVD named '{path.stem}' already exists...")
|
||||
else:
|
||||
# TODO: Check for and log errors
|
||||
_ = Device.load(path) # test if WVD is valid
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(path, dst_path)
|
||||
log.info(f"Added {path.stem}")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("names", type=str, nargs=-1)
|
||||
def delete(names: list[str]) -> None:
|
||||
"""Delete one or more WVD (Widevine Device) files from the WVDs Directory."""
|
||||
log = logging.getLogger("wvd")
|
||||
for name in names:
|
||||
path = (config.directories.wvds / name).with_suffix(".wvd")
|
||||
if not path.exists():
|
||||
log.error(f"No WVD file exists by the name '{name}'...")
|
||||
continue
|
||||
|
||||
answer = Prompt.ask(
|
||||
f"[red]Deleting '{name}'[/], are you sure you want to continue?",
|
||||
choices=["y", "n"],
|
||||
default="n",
|
||||
console=console,
|
||||
)
|
||||
if answer == "n":
|
||||
log.info("Aborting...")
|
||||
continue
|
||||
|
||||
Path.unlink(path)
|
||||
log.info(f"Deleted {name}")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("path", type=Path)
|
||||
def parse(path: Path) -> None:
|
||||
"""
|
||||
Parse a .WVD Widevine Device file to check information.
|
||||
Relative paths are relative to the WVDs directory.
|
||||
"""
|
||||
try:
|
||||
named = not path.suffix and path.relative_to(Path(""))
|
||||
except ValueError:
|
||||
named = False
|
||||
if named:
|
||||
path = config.directories.wvds / f"{path.name}.wvd"
|
||||
|
||||
log = logging.getLogger("wvd")
|
||||
|
||||
if not path.exists():
|
||||
console.log(f"[bright_blue]{path.absolute()}[/] does not exist...")
|
||||
return
|
||||
|
||||
device = Device.load(path)
|
||||
|
||||
log.info(f"System ID: {device.system_id}")
|
||||
log.info(f"Security Level: {device.security_level}")
|
||||
log.info(f"Type: {device.type}")
|
||||
log.info(f"Flags: {device.flags}")
|
||||
log.info(f"Private Key: {bool(device.private_key)}")
|
||||
log.info(f"Client ID: {bool(device.client_id)}")
|
||||
log.info(f"VMP: {bool(device.client_id.vmp_data)}")
|
||||
|
||||
log.info("Client ID:")
|
||||
log.info(device.client_id)
|
||||
|
||||
log.info("VMP:")
|
||||
if device.client_id.vmp_data:
|
||||
file_hashes = FileHashes()
|
||||
file_hashes.ParseFromString(device.client_id.vmp_data)
|
||||
log.info(str(file_hashes))
|
||||
else:
|
||||
log.info("None")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("wvd_paths", type=Path, nargs=-1)
|
||||
@click.argument("out_dir", type=Path, nargs=1)
|
||||
def dump(wvd_paths: list[Path], out_dir: Path) -> None:
|
||||
"""
|
||||
Extract data from a .WVD Widevine Device file to a folder structure.
|
||||
|
||||
If the path is relative, with no file extension, it will dump the WVD in the WVDs
|
||||
directory.
|
||||
"""
|
||||
log = logging.getLogger("wvd")
|
||||
|
||||
if wvd_paths == ():
|
||||
if not config.directories.wvds.exists():
|
||||
console.log(f"[bright_blue]{config.directories.wvds.absolute()}[/] does not exist...")
|
||||
wvd_paths = list(x for x in config.directories.wvds.iterdir() if x.is_file() and x.suffix.lower() == ".wvd")
|
||||
if not wvd_paths:
|
||||
console.log(f"[bright_blue]{config.directories.wvds.absolute()}[/] is empty...")
|
||||
|
||||
for i, (wvd_path, out_path) in enumerate(zip(wvd_paths, (out_dir / x.stem for x in wvd_paths))):
|
||||
if i > 0:
|
||||
log.info("")
|
||||
|
||||
try:
|
||||
named = not wvd_path.suffix and wvd_path.relative_to(Path(""))
|
||||
except ValueError:
|
||||
named = False
|
||||
if named:
|
||||
wvd_path = config.directories.wvds / f"{wvd_path.stem}.wvd"
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log.info(f"Dumping: {wvd_path}")
|
||||
device = Device.load(wvd_path)
|
||||
|
||||
log.info(f"L{device.security_level} {device.system_id} {device.type.name}")
|
||||
log.info(f"Saving to: {out_path}")
|
||||
|
||||
device_meta = {
|
||||
"wvd": {"device_type": device.type.name, "security_level": device.security_level, **device.flags},
|
||||
"client_info": {},
|
||||
"capabilities": MessageToDict(device.client_id, preserving_proto_field_name=True)["client_capabilities"],
|
||||
}
|
||||
for client_info in device.client_id.client_info:
|
||||
device_meta["client_info"][client_info.name] = client_info.value
|
||||
|
||||
device_meta_path = out_path / "metadata.yml"
|
||||
device_meta_path.write_text(yaml.dump(device_meta), encoding="utf8")
|
||||
log.info(" + Device Metadata")
|
||||
|
||||
if device.private_key:
|
||||
private_key_path = out_path / "private_key.pem"
|
||||
private_key_path.write_text(data=device.private_key.export_key().decode(), encoding="utf8")
|
||||
private_key_path.with_suffix(".der").write_bytes(device.private_key.export_key(format="DER"))
|
||||
log.info(" + Private Key")
|
||||
else:
|
||||
log.warning(" - No Private Key available")
|
||||
|
||||
if device.client_id:
|
||||
client_id_path = out_path / "client_id.bin"
|
||||
client_id_path.write_bytes(device.client_id.SerializeToString())
|
||||
log.info(" + Client ID")
|
||||
else:
|
||||
log.warning(" - No Client ID available")
|
||||
|
||||
if device.client_id.vmp_data:
|
||||
vmp_path = out_path / "vmp.bin"
|
||||
vmp_path.write_bytes(device.client_id.vmp_data)
|
||||
log.info(" + VMP (File Hashes)")
|
||||
else:
|
||||
log.info(" - No VMP (File Hashes) available")
|
||||
|
||||
|
||||
@wvd.command()
|
||||
@click.argument("name", type=str)
|
||||
@click.argument("private_key", type=Path)
|
||||
@click.argument("client_id", type=Path)
|
||||
@click.argument("file_hashes", type=Path, required=False)
|
||||
@click.option(
|
||||
"-t",
|
||||
"--type",
|
||||
"type_",
|
||||
type=click.Choice([x.name for x in DeviceTypes], case_sensitive=False),
|
||||
default="Android",
|
||||
help="Device Type",
|
||||
)
|
||||
@click.option("-l", "--level", type=click.IntRange(1, 3), default=1, help="Device Security Level")
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Directory")
|
||||
@click.pass_context
|
||||
def new(
|
||||
ctx: click.Context,
|
||||
name: str,
|
||||
private_key: Path,
|
||||
client_id: Path,
|
||||
file_hashes: Optional[Path],
|
||||
type_: str,
|
||||
level: int,
|
||||
output: Optional[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Create a new .WVD Widevine provision file.
|
||||
|
||||
name: The origin device name of the provided data. e.g. `Nexus 6P`. You do not need to
|
||||
specify the security level, that will be done automatically.
|
||||
private_key: A PEM file of a Device's private key.
|
||||
client_id: A binary blob file which follows the Widevine ClientIdentification protobuf
|
||||
schema.
|
||||
file_hashes: A binary blob file with follows the Widevine FileHashes protobuf schema.
|
||||
Also known as VMP as it's used for VMP (Verified Media Path) assurance.
|
||||
"""
|
||||
try:
|
||||
# TODO: Remove need for name, create name based on Client IDs ClientInfo values
|
||||
name = unidecode(name.strip().lower().replace(" ", "_"))
|
||||
except UnidecodeError as e:
|
||||
raise click.UsageError(f"name: Failed to sanitize name, {e}", ctx)
|
||||
if not name:
|
||||
raise click.UsageError("name: Empty after sanitizing, please make sure the name is valid.", ctx)
|
||||
if not private_key.is_file():
|
||||
raise click.UsageError("private_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if not client_id.is_file():
|
||||
raise click.UsageError("client_id: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if file_hashes and not file_hashes.is_file():
|
||||
raise click.UsageError("file_hashes: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
device = Device(
|
||||
type_=DeviceTypes[type_.upper()],
|
||||
security_level=level,
|
||||
flags=None,
|
||||
private_key=private_key.read_bytes(),
|
||||
client_id=client_id.read_bytes(),
|
||||
)
|
||||
|
||||
if file_hashes:
|
||||
device.client_id.vmp_data = file_hashes.read_bytes()
|
||||
|
||||
out_path = (output or config.directories.wvds) / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
||||
device.dump(out_path)
|
||||
|
||||
log = logging.getLogger("wvd")
|
||||
|
||||
log.info(f"Created binary WVD file, {out_path.name}")
|
||||
log.info(f" + Saved to: {out_path.absolute()}")
|
||||
|
||||
log.info(f"System ID: {device.system_id}")
|
||||
log.info(f"Security Level: {device.security_level}")
|
||||
log.info(f"Type: {device.type}")
|
||||
log.info(f"Flags: {device.flags}")
|
||||
log.info(f"Private Key: {bool(device.private_key)}")
|
||||
log.info(f"Client ID: {bool(device.client_id)}")
|
||||
log.info(f"VMP: {bool(device.client_id.vmp_data)}")
|
||||
|
||||
log.info("Client ID:")
|
||||
log.info(device.client_id)
|
||||
|
||||
log.info("VMP:")
|
||||
if device.client_id.vmp_data:
|
||||
file_hashes = FileHashes()
|
||||
file_hashes.ParseFromString(device.client_id.vmp_data)
|
||||
log.info(str(file_hashes))
|
||||
else:
|
||||
log.info("None")
|
||||
Reference in New Issue
Block a user