Files
Medios-Macina/PluginCore/backend_registry.py
T

493 lines
18 KiB
Python

"""Configured plugin-backed backend registry.
Backends are discovered from their owning plugins and instantiated from config.
"""
from __future__ import annotations
import importlib
import importlib.util
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.<type>.<instance_key>.
_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 _plugin_module_exists(plugin_name: str) -> bool:
normalized = _normalize_backend_type(plugin_name)
if not normalized:
return False
try:
return importlib.util.find_spec(f"plugins.{normalized}") is not None
except Exception:
return False
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
and not _plugin_module_exists(backend_type)
):
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",
]