493 lines
18 KiB
Python
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",
|
|
] |