diff --git a/unshackle/commands/env.py b/unshackle/commands/env.py index 7813ad32..152272c3 100644 --- a/unshackle/commands/env.py +++ b/unshackle/commands/env.py @@ -19,10 +19,28 @@ from unshackle.core.temp import TASK_PREFIX, is_stale def get_dependencies() -> list[dict]: """Binary dependency inventory shared by `env check` and the API env check.""" - return [ - {"name": "FFmpeg", "binary": binaries.FFMPEG, "required": True, "desc": "Media processing", "cat": "Core"}, - {"name": "FFprobe", "binary": binaries.FFProbe, "required": True, "desc": "Media analysis", "cat": "Core"}, - {"name": "MKVToolNix", "binary": binaries.MKVToolNix, "required": True, "desc": "MKV muxing", "cat": "Core"}, + deps = [ + { + "name": "FFmpeg", + "binary": binaries.FFMPEG, + "required": True, + "desc": "Media processing", + "cat": "Core", + }, + { + "name": "FFprobe", + "binary": binaries.FFProbe, + "required": True, + "desc": "Media analysis", + "cat": "Core", + }, + { + "name": "MKVToolNix", + "binary": binaries.MKVToolNix, + "required": True, + "desc": "MKV muxing", + "cat": "Core", + }, { "name": "mkvpropedit", "binary": binaries.Mkvpropedit, @@ -44,7 +62,13 @@ def get_dependencies() -> list[dict]: "desc": "DRM decryption", "cat": "DRM", }, - {"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", + }, { "name": "HDR10Plus_tool", "binary": binaries.HDR10PlusTool, @@ -66,8 +90,20 @@ def get_dependencies() -> list[dict]: "desc": "CC extraction", "cat": "Subtitle", }, - {"name": "FFplay", "binary": binaries.FFPlay, "required": False, "desc": "Simple player", "cat": "Player"}, - {"name": "MPV", "binary": binaries.MPV, "required": False, "desc": "Advanced player", "cat": "Player"}, + { + "name": "FFplay", + "binary": binaries.FFPlay, + "required": False, + "desc": "Simple player", + "cat": "Player", + }, + { + "name": "MPV", + "binary": binaries.MPV, + "required": False, + "desc": "Advanced player", + "cat": "Player", + }, { "name": "HolaProxy", "binary": binaries.HolaProxy, @@ -75,11 +111,42 @@ def get_dependencies() -> list[dict]: "desc": "Proxy service", "cat": "Network", }, - {"name": "Caddy", "binary": binaries.Caddy, "required": False, "desc": "Web server", "cat": "Network"}, - {"name": "Docker", "binary": binaries.Docker, "required": False, "desc": "Gluetun VPN", "cat": "Network"}, - {"name": "git", "binary": binaries.Git, "required": False, "desc": "Service repos", "cat": "Network"}, + { + "name": "Caddy", + "binary": binaries.Caddy, + "required": False, + "desc": "Web server", + "cat": "Network", + }, + { + "name": "Docker", + "binary": binaries.Docker, + "required": False, + "desc": "Gluetun VPN", + "cat": "Network", + }, + { + "name": "git", + "binary": binaries.Git, + "required": False, + "desc": "Service repos", + "cat": "Network", + }, ] + for reg in binaries.get_registered_dependencies(): + deps.append( + { + "name": reg["name"], + "binary": getattr(binaries, reg["attr"], None), + "required": False, + "desc": reg["desc"], + "cat": "Service", + } + ) + + return deps + def clear_directory(path: Path) -> tuple[int, int]: """Delete a directory's contents, returning (files_removed, freed_bytes). Recreates the dir. diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index daa77506..ef58166c 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -6,18 +6,31 @@ from typing import Optional __shaka_platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform) -def find(*names: str) -> Optional[Path]: +def find(*names: str, search_dirs: Optional[list[Path]] = None) -> Optional[Path]: """Find the path of the first found binary name.""" current_dir = Path(__file__).resolve().parent.parent local_binaries_dir = current_dir / "binaries" + services_dir = current_dir / "services" + + dirs_to_check: list[Path] = [local_binaries_dir] + if services_dir.exists(): + for s_dir in services_dir.iterdir(): + s_bin = s_dir / "binaries" + if s_bin.is_dir(): + dirs_to_check.append(s_bin) + + if search_dirs: + dirs_to_check.extend(search_dirs) ext = ".exe" if sys.platform == "win32" else "" for name in names: - if local_binaries_dir.exists(): - candidate_paths = [local_binaries_dir / f"{name}{ext}", local_binaries_dir / name / f"{name}{ext}"] + for base_dir in dirs_to_check: + if not base_dir.exists(): + continue + candidate_paths = [base_dir / f"{name}{ext}", base_dir / name / f"{name}{ext}"] - for subdir in local_binaries_dir.iterdir(): + for subdir in base_dir.iterdir(): if subdir.is_dir(): candidate_paths.append(subdir / f"{name}{ext}") @@ -63,11 +76,50 @@ __binaries = { "Git": ("git",), } +_registered_binaries: dict[str, dict] = {} + + +def register( + attr_name: str, + *candidates: str, + desc: str = "Custom tool", +) -> None: + """Register a custom binary dynamically.""" + file_candidates = candidates if candidates else (attr_name.lower(),) + __binaries[attr_name] = file_candidates + _registered_binaries[attr_name] = { + "name": attr_name, + "attr": attr_name, + "desc": desc, + } + + +def get_registered_dependencies() -> list[dict]: + """Return all registered custom binary dependencies for env check.""" + return list(_registered_binaries.values()) + + +_services_loaded = False + def __getattr__(name: str) -> Optional[Path]: + global _services_loaded candidates = __binaries.get(name) + if candidates is None and not _services_loaded: + _services_loaded = True + try: + candidates = __binaries.get(name) + except Exception: + pass + if candidates is None: + # Fallback to direct name and lowercase candidate + resolved = find(name, name.lower()) + if resolved is not None: + globals()[name] = resolved + return resolved raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + # Cache the result (including None for an absent binary) so later access is a plain attr hit. resolved = find(*candidates) globals()[name] = resolved @@ -93,4 +145,6 @@ __all__ = ( "ML_Worker", "Git", "find", + "register", + "get_registered_dependencies", ) diff --git a/unshackle/core/service.py b/unshackle/core/service.py index c654026b..804ef57b 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -405,6 +405,14 @@ class Service(metaclass=ABCMeta): session.mount("http://", session.adapters["https://"]) return session + @staticmethod + def get_binaries() -> list[dict]: + """ + Declare custom binary dependencies required by this service. + :returns: List of dicts specifying name, candidates, desc, etc. + """ + return [] + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: """ Authenticate the Service with Cookies and/or Credentials (Email/Username and Password). diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 94b5665a..e2ba18f6 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -10,6 +10,7 @@ from typing import Any, Iterable import click +from unshackle.core import binaries from unshackle.core.config import config from unshackle.core.service import Service from unshackle.core.service_repo import DirtyServiceRepo, is_repo_spec, refresh_repo, resolve_service_repo @@ -102,7 +103,26 @@ def load_services(paths: list[Path]) -> tuple[dict[str, object], list[str]]: return modules, errors +def register_service_binaries(modules: dict[str, object]) -> None: + """Register custom binary dependencies declared by services via get_binaries().""" + + for tag, service_cls in modules.items(): + get_binaries_fn = getattr(service_cls, "get_binaries", None) + if callable(get_binaries_fn): + try: + for dep in get_binaries_fn(): + name = dep.get("name") + if not name: + continue + candidates = dep.get("candidates") or (name.lower(),) + desc = dep.get("desc", f"{tag} dependency") + binaries.register(name, *candidates, desc=desc) + except Exception as e: + log.warning(f"Failed to register custom binaries for service {tag}: {e}") + + MODULES, LOAD_ERRORS = load_services(SERVICES) +register_service_binaries(MODULES) ALIASES = {tag: getattr(module, "ALIASES", ()) for tag, module in MODULES.items()} @@ -143,6 +163,7 @@ def reload_services(tags: Iterable[str]) -> list[str]: except Exception as e: errors.append(str(e)) LOAD_ERRORS.extend(errors) + register_service_binaries(MODULES) PENDING.difference_update(wanted) return errors