"""Configured plugin-backed backend registry. Backends are discovered from their owning plugins and instantiated from config. """ from __future__ import annotations import importlib import inspect import re from typing import Any, Dict, Optional, Type from SYS.logger import debug from SYS.utils import expand_path from PluginCore.backend_base import BackendBase _SHA256_HEX_RE = re.compile(r"^[0-9a-fA-F]{64}$") _PLUGIN_DISCOVERED_CLASSES_CACHE: Dict[str, Optional[Type[BackendBase]]] = {} # Backends that failed to initialize earlier in the current process. # Keyed by (backend_type, instance_key) where instance_key is the configured name # under config.plugin... _FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {} def _normalize_backend_type(value: str) -> str: return "".join(ch.lower() for ch in str(value or "") if ch.isalnum()) def _normalize_config_key(value: str) -> str: return str(value or "").strip().upper() def _get_case_insensitive(mapping: Dict[str, Any], key: str) -> Any: if key in mapping: return mapping[key] desired = _normalize_config_key(key) for current_key, value in mapping.items(): if _normalize_config_key(current_key) == desired: return value return None def _extract_backend_classes(owner: Any) -> Dict[str, Type[BackendBase]]: discovered: Dict[str, Type[BackendBase]] = {} def _add_candidate(key: Any, candidate: Any) -> None: if not inspect.isclass(candidate): return if candidate is BackendBase: return if not issubclass(candidate, BackendBase): return normalized = _normalize_backend_type(str(key or candidate.__name__)) if normalized: discovered[normalized] = candidate if owner is None: return discovered if inspect.isclass(owner): _add_candidate(None, owner) return discovered if isinstance(owner, dict): for key, candidate in owner.items(): _add_candidate(key, candidate) return discovered if isinstance(owner, (list, tuple, set, frozenset)): for candidate in owner: _add_candidate(None, candidate) return discovered try: for key, candidate in vars(owner).items(): _add_candidate(key, candidate) except Exception: pass return discovered def _discover_plugin_backend_class(backend_type: str) -> Optional[Type[BackendBase]]: normalized = _normalize_backend_type(backend_type) if not normalized: return None cached = _PLUGIN_DISCOVERED_CLASSES_CACHE.get(normalized, None) if normalized in _PLUGIN_DISCOVERED_CLASSES_CACHE: return cached try: plugin_module = importlib.import_module(f"plugins.{normalized}") except Exception: _PLUGIN_DISCOVERED_CLASSES_CACHE[normalized] = None return None discovered: Dict[str, Type[BackendBase]] = {} backend_hook = getattr(plugin_module, "get_store_backend_classes", None) if callable(backend_hook): try: discovered.update(_extract_backend_classes(backend_hook())) except Exception as exc: debug(f"[BackendRegistry] Failed to load plugin backends for '{normalized}': {exc}") discovered.update(_extract_backend_classes(getattr(plugin_module, "STORE_BACKENDS", None))) if normalized not in discovered: discovered.update(_extract_backend_classes(plugin_module)) resolved = discovered.get(normalized) if resolved is None and len(discovered) == 1: resolved = next(iter(discovered.values())) _PLUGIN_DISCOVERED_CLASSES_CACHE[normalized] = resolved return resolved def _resolve_backend_class(backend_type: str) -> Optional[Type[BackendBase]]: normalized = _normalize_backend_type(backend_type) if not normalized: return None return _discover_plugin_backend_class(normalized) def _required_keys_for(backend_cls: Type[BackendBase]) -> list[str]: if hasattr(backend_cls, "config_schema") and callable(backend_cls.config_schema): try: schema = backend_cls.config_schema() keys = [] if isinstance(schema, list): for field in schema: if isinstance(field, dict) and field.get("required"): key = field.get("key") if key: keys.append(str(key)) if keys: return keys except Exception: pass return [] _PROVIDER_ONLY_BACKEND_NAMES = frozenset(("debrid", "alldebrid")) def _build_kwargs(backend_cls: Type[BackendBase], instance_name: str, instance_config: Any) -> Dict[str, Any]: cfg_dict = dict(instance_config) if isinstance(instance_config, dict) else {} required = _required_keys_for(backend_cls) if any(_normalize_config_key(key) == "NAME" for key in required) and _get_case_insensitive(cfg_dict, "NAME") is None: cfg_dict["NAME"] = str(instance_name) kwargs: Dict[str, Any] = {} missing: list[str] = [] for key in required: value = _get_case_insensitive(cfg_dict, key) if value is None or value == "": missing.append(str(key)) continue kwargs[str(key)] = value if missing: raise ValueError( f"Missing required keys for {backend_cls.__name__}: {', '.join(missing)}" ) return kwargs class BackendRegistry: def __init__( self, config: Optional[Dict[str, Any]] = None, suppress_debug: bool = False, ) -> None: self._config = config or {} self._suppress_debug = suppress_debug self._backends: Dict[str, BackendBase] = {} self._backend_errors: Dict[str, str] = {} self._backend_types: Dict[str, str] = {} self._load_backends() def _load_backends(self) -> None: plugin_cfg = self._config.get("plugin") if not isinstance(plugin_cfg, dict): plugin_cfg = {} self._backend_types = {} for raw_backend_type, instances in plugin_cfg.items(): if not isinstance(instances, dict): continue backend_type = _normalize_backend_type(str(raw_backend_type)) if backend_type == "folder": continue backend_cls = _resolve_backend_class(backend_type) if backend_cls is None: if backend_type not in _PROVIDER_ONLY_BACKEND_NAMES and not self._suppress_debug: debug(f"[BackendRegistry] Unknown backend type '{raw_backend_type}'") continue for instance_name, instance_config in instances.items(): backend_name = str(instance_name) cache_key = (backend_type, str(instance_name)) cached_error = _FAILED_BACKEND_CACHE.get(cache_key) if cached_error: self._backend_errors[str(instance_name)] = str(cached_error) if isinstance(instance_config, dict): override_name = _get_case_insensitive(dict(instance_config), "NAME") if override_name: self._backend_errors[str(override_name)] = str(cached_error) continue try: kwargs = _build_kwargs( backend_cls, str(instance_name), instance_config, ) for key in list(kwargs.keys()): if _normalize_config_key(key) in {"PATH", "LOCATION"}: kwargs[key] = str(expand_path(kwargs[key])) backend = backend_cls(**kwargs) backend_name = str(kwargs.get("NAME") or instance_name) self._backends[backend_name] = backend self._backend_types[backend_name] = backend_type except Exception as exc: err_text = str(exc) self._backend_errors[str(instance_name)] = err_text _FAILED_BACKEND_CACHE[cache_key] = err_text if not self._suppress_debug: debug( f"[BackendRegistry] Failed to register {backend_cls.__name__} instance '{instance_name}': {exc}" ) def _resolve_backend_name(self, backend_name: str) -> tuple[Optional[str], Optional[str]]: requested = str(backend_name or "") if requested in self._backends: return requested, None requested_norm = _normalize_backend_type(requested) ci_matches = [ name for name in self._backends if _normalize_backend_type(name) == requested_norm ] if len(ci_matches) == 1: return ci_matches[0], None if len(ci_matches) > 1: return None, f"Ambiguous backend alias '{backend_name}' matches {ci_matches}" type_matches = [ name for name, backend_type in self._backend_types.items() if backend_type == requested_norm ] if len(type_matches) == 1: return type_matches[0], None if len(type_matches) > 1: return None, ( f"Ambiguous backend alias '{backend_name}' matches type '{requested_norm}': {type_matches}" ) prefix_matches = [ name for name, backend_type in self._backend_types.items() if backend_type.startswith(requested_norm) ] if len(prefix_matches) == 1: return prefix_matches[0], None if len(prefix_matches) > 1: return None, ( f"Ambiguous backend alias '{backend_name}' matches type prefix '{requested_norm}': {prefix_matches}" ) return None, None def get_backend_error(self, backend_name: str) -> Optional[str]: return self._backend_errors.get(str(backend_name)) def list_backends(self) -> list[str]: return sorted(self._backends.keys()) def list_searchable_backends(self) -> list[str]: def _rank(name: str) -> int: normalized_name = str(name or "").strip().lower() if normalized_name == "temp": return 0 if normalized_name == "default": return 2 return 1 chosen: Dict[int, str] = {} for name, backend in self._backends.items(): if type(backend).search is BackendBase.search: continue key = id(backend) prev = chosen.get(key) if prev is None or _rank(name) < _rank(prev): chosen[key] = name return sorted(chosen.values()) def __getitem__(self, backend_name: str) -> BackendBase: resolved, err = self._resolve_backend_name(backend_name) if resolved: return self._backends[resolved] if err: raise KeyError(f"Unknown backend: {backend_name}. {err}") raise KeyError(f"Unknown backend: {backend_name}. Available: {list(self._backends.keys())}") def is_available(self, backend_name: str) -> bool: resolved, _err = self._resolve_backend_name(backend_name) return resolved is not None def try_add_url_for_pipe_object(self, pipe_obj: Any, url: str) -> bool: """Best-effort helper: if `pipe_obj` contains `store` + `hash`, add `url` to that backend.""" try: url_text = str(url or "").strip() if not url_text: return False store_name = None file_hash = None if isinstance(pipe_obj, dict): store_name = pipe_obj.get("store") file_hash = pipe_obj.get("hash") else: store_name = getattr(pipe_obj, "store", None) file_hash = getattr(pipe_obj, "hash", None) store_name = str(store_name).strip() if store_name is not None else "" file_hash = str(file_hash).strip() if file_hash is not None else "" if not store_name or not file_hash: return False if not _SHA256_HEX_RE.fullmatch(file_hash): return False backend = self[store_name] add_url = getattr(backend, "add_url", None) if not callable(add_url): return False ok = add_url(file_hash.lower(), [url_text]) return bool(ok) if ok is not None else True except Exception: return False def list_configured_backend_names(config: Optional[Dict[str, Any]]) -> list[str]: """Return configured backend instance names without instantiating backends.""" try: names: list[str] = [] for section_name in ("plugin",): section_cfg = (config or {}).get(section_name) or {} if not isinstance(section_cfg, dict): continue for raw_backend_type, instances in section_cfg.items(): if not isinstance(instances, dict): continue backend_type = _normalize_backend_type(str(raw_backend_type)) if backend_type == "folder" or backend_type in _PROVIDER_ONLY_BACKEND_NAMES: continue backend_cls = _resolve_backend_class(backend_type) if backend_cls is None: continue for instance_name, instance_config in instances.items(): try: _build_kwargs(backend_cls, str(instance_name), instance_config) except Exception: continue if isinstance(instance_config, dict): override_name = _get_case_insensitive(dict(instance_config), "NAME") if override_name: names.append(str(override_name)) else: names.append(str(instance_name)) else: names.append(str(instance_name)) return sorted(set(names)) except Exception: return [] def get_backend_instance( config: Optional[Dict[str, Any]], backend_name: str, *, suppress_debug: bool = False, ) -> Optional[BackendBase]: """Instantiate and return a single configured backend by name.""" if not backend_name: return None desired = str(backend_name or "").strip().lower() for section_name in ("plugin",): section_cfg = (config or {}).get(section_name) or {} if not isinstance(section_cfg, dict): continue for raw_backend_type, instances in section_cfg.items(): if not isinstance(instances, dict): continue backend_type = _normalize_backend_type(str(raw_backend_type)) backend_cls = _resolve_backend_class(backend_type) if backend_cls is None: continue for instance_name, instance_cfg in instances.items(): candidate_alias = None if isinstance(instance_cfg, dict): candidate_alias = instance_cfg.get("NAME") or instance_cfg.get("name") candidate_alias = str(candidate_alias or instance_name).strip() if candidate_alias.lower() != desired: continue try: kwargs = _build_kwargs(backend_cls, str(instance_name), instance_cfg) except Exception as exc: if not suppress_debug: debug( f"[BackendRegistry] Can't build kwargs for '{instance_name}' ({backend_type}/{section_name}): {exc}" ) return None try: for key in list(kwargs.keys()): if _normalize_config_key(key) in {"PATH", "LOCATION"}: kwargs[key] = str(expand_path(kwargs[key])) except Exception: pass try: return backend_cls(**kwargs) except Exception as exc: if not suppress_debug: debug(f"[BackendRegistry] Failed to instantiate backend '{candidate_alias}': {exc}") return None for instance_name, instance_cfg in instances.items(): try: kwargs = _build_kwargs(backend_cls, str(instance_name), instance_cfg) except Exception: continue alias = str(kwargs.get("NAME") or instance_name).strip() if alias.lower() != desired: continue try: for key in list(kwargs.keys()): if _normalize_config_key(key) in {"PATH", "LOCATION"}: kwargs[key] = str(expand_path(kwargs[key])) except Exception: pass try: return backend_cls(**kwargs) except Exception as exc: if not suppress_debug: debug(f"[BackendRegistry] Failed to instantiate backend '{alias}': {exc}") return None if not suppress_debug: debug(f"[BackendRegistry] Backend '{backend_name}' not found in config") return None __all__ = [ "BackendBase", "BackendRegistry", "get_backend_instance", "list_configured_backend_names", ]