feat(binary): add custom binary registration for services

- Allow services to declare required binaries via `@staticmethod def get_binaries()`
- Support accessing registered custom binaries directly via `binaries.<Name>`
- Automatically search service local `binaries/` directories
This commit is contained in:
CodeName393
2026-09-02 21:34:48 +09:00
committed by GitHub
parent f8f58f86ef
commit 13fc47546f
4 changed files with 164 additions and 14 deletions
+77 -10
View File
@@ -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.
+58 -4
View File
@@ -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",
)
+8
View File
@@ -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).
+21
View File
@@ -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