Files

493 lines
18 KiB
Python
Raw Permalink Normal View History

2026-05-23 13:49:47 -07:00
"""Configured plugin-backed backend registry.
2025-12-11 19:04:02 -08:00
2026-05-23 13:49:47 -07:00
Backends are discovered from their owning plugins and instantiated from config.
2025-12-11 19:04:02 -08:00
"""
from __future__ import annotations
2025-12-13 00:18:30 -08:00
import importlib
import importlib.util
2025-12-13 00:18:30 -08:00
import inspect
2025-12-19 03:25:52 -08:00
import re
2026-01-19 03:14:30 -08:00
from typing import Any, Dict, Optional, Type
2025-12-11 19:04:02 -08:00
from SYS.logger import debug
2026-01-11 00:52:54 -08:00
from SYS.utils import expand_path
2025-12-11 19:04:02 -08:00
2026-05-23 13:49:47 -07:00
from PluginCore.backend_base import BackendBase
2025-12-13 00:18:30 -08:00
2025-12-19 03:25:52 -08:00
_SHA256_HEX_RE = re.compile(r"^[0-9a-fA-F]{64}$")
2026-05-23 13:49:47 -07:00
_PLUGIN_DISCOVERED_CLASSES_CACHE: Dict[str, Optional[Type[BackendBase]]] = {}
2026-01-12 13:51:26 -08:00
2025-12-13 12:09:50 -08:00
# Backends that failed to initialize earlier in the current process.
2026-05-23 13:49:47 -07:00
# Keyed by (backend_type, instance_key) where instance_key is the configured name
2026-05-26 15:32:01 -07:00
# under config.plugin.<type>.<instance_key>.
2026-01-19 21:25:44 -08:00
_FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {}
2025-12-13 12:09:50 -08:00
2026-05-23 13:49:47 -07:00
def _normalize_backend_type(value: str) -> str:
2025-12-13 00:18:30 -08:00
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)
2026-05-23 13:49:47 -07:00
for current_key, value in mapping.items():
if _normalize_config_key(current_key) == desired:
return value
2025-12-13 00:18:30 -08:00
return None
2026-05-23 13:49:47 -07:00
def _extract_backend_classes(owner: Any) -> Dict[str, Type[BackendBase]]:
discovered: Dict[str, Type[BackendBase]] = {}
2026-04-30 18:56:22 -07:00
def _add_candidate(key: Any, candidate: Any) -> None:
if not inspect.isclass(candidate):
return
2026-05-23 13:49:47 -07:00
if candidate is BackendBase:
2026-04-30 18:56:22 -07:00
return
2026-05-23 13:49:47 -07:00
if not issubclass(candidate, BackendBase):
2026-04-30 18:56:22 -07:00
return
2026-05-23 13:49:47 -07:00
normalized = _normalize_backend_type(str(key or candidate.__name__))
2026-04-30 18:56:22 -07:00
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
2026-05-23 13:49:47 -07:00
def _discover_plugin_backend_class(backend_type: str) -> Optional[Type[BackendBase]]:
normalized = _normalize_backend_type(backend_type)
2026-04-30 18:56:22 -07:00
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
2026-05-23 13:49:47 -07:00
discovered: Dict[str, Type[BackendBase]] = {}
2026-04-30 18:56:22 -07:00
backend_hook = getattr(plugin_module, "get_store_backend_classes", None)
if callable(backend_hook):
try:
2026-05-23 13:49:47 -07:00
discovered.update(_extract_backend_classes(backend_hook()))
2026-04-30 18:56:22 -07:00
except Exception as exc:
2026-05-23 13:49:47 -07:00
debug(f"[BackendRegistry] Failed to load plugin backends for '{normalized}': {exc}")
2026-04-30 18:56:22 -07:00
2026-05-23 13:49:47 -07:00
discovered.update(_extract_backend_classes(getattr(plugin_module, "STORE_BACKENDS", None)))
2026-04-30 18:56:22 -07:00
if normalized not in discovered:
2026-05-23 13:49:47 -07:00
discovered.update(_extract_backend_classes(plugin_module))
2026-04-30 18:56:22 -07:00
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
2026-05-23 13:49:47 -07:00
def _resolve_backend_class(backend_type: str) -> Optional[Type[BackendBase]]:
normalized = _normalize_backend_type(backend_type)
2026-04-30 18:56:22 -07:00
if not normalized:
return None
2026-05-23 13:49:47 -07:00
return _discover_plugin_backend_class(normalized)
2026-04-30 18:56:22 -07:00
2026-05-23 13:49:47 -07:00
def _required_keys_for(backend_cls: Type[BackendBase]) -> list[str]:
if hasattr(backend_cls, "config_schema") and callable(backend_cls.config_schema):
2026-01-11 03:24:49 -08:00
try:
2026-05-23 13:49:47 -07:00
schema = backend_cls.config_schema()
2026-01-11 03:24:49 -08:00
keys = []
if isinstance(schema, list):
for field in schema:
if isinstance(field, dict) and field.get("required"):
2026-05-23 13:49:47 -07:00
key = field.get("key")
if key:
keys.append(str(key))
2026-01-11 03:24:49 -08:00
if keys:
return keys
except Exception:
pass
2026-02-07 14:58:13 -08:00
return []
2025-12-13 00:18:30 -08:00
2026-05-23 13:49:47 -07:00
_PROVIDER_ONLY_BACKEND_NAMES = frozenset(("debrid", "alldebrid"))
2026-02-02 11:40:51 -08:00
2026-05-23 13:49:47 -07:00
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 {}
2025-12-13 00:18:30 -08:00
2026-05-23 13:49:47 -07:00
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:
2025-12-13 00:18:30 -08:00
cfg_dict["NAME"] = str(instance_name)
2026-01-19 21:25:44 -08:00
kwargs: Dict[str, Any] = {}
2025-12-13 00:18:30 -08:00
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(
2026-05-23 13:49:47 -07:00
f"Missing required keys for {backend_cls.__name__}: {', '.join(missing)}"
)
2025-12-13 00:18:30 -08:00
return kwargs
2025-12-11 19:04:02 -08:00
2026-05-23 13:49:47 -07:00
class BackendRegistry:
2025-12-29 17:05:03 -08:00
def __init__(
self,
2026-05-23 13:49:47 -07:00
config: Optional[Dict[str, Any]] = None,
suppress_debug: bool = False,
2025-12-29 17:05:03 -08:00
) -> None:
2025-12-11 19:04:02 -08:00
self._config = config or {}
self._suppress_debug = suppress_debug
2026-05-23 13:49:47 -07:00
self._backends: Dict[str, BackendBase] = {}
self._backend_errors: Dict[str, str] = {}
self._backend_types: Dict[str, str] = {}
2025-12-11 19:04:02 -08:00
self._load_backends()
def _load_backends(self) -> None:
2026-05-26 15:32:01 -07:00
plugin_cfg = self._config.get("plugin")
if not isinstance(plugin_cfg, dict):
plugin_cfg = {}
2025-12-11 19:04:02 -08:00
2026-01-05 07:51:19 -08:00
self._backend_types = {}
2026-05-26 15:32:01 -07:00
for raw_backend_type, instances in plugin_cfg.items():
2025-12-13 00:18:30 -08:00
if not isinstance(instances, dict):
continue
2025-12-11 19:04:02 -08:00
2026-05-23 13:49:47 -07:00
backend_type = _normalize_backend_type(str(raw_backend_type))
if backend_type == "folder":
2026-01-22 01:53:13 -08:00
continue
2026-05-23 13:49:47 -07:00
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)
):
2026-05-23 13:49:47 -07:00
debug(f"[BackendRegistry] Unknown backend type '{raw_backend_type}'")
2025-12-13 00:18:30 -08:00
continue
2025-12-11 19:04:02 -08:00
2025-12-13 00:18:30 -08:00
for instance_name, instance_config in instances.items():
2025-12-13 12:09:50 -08:00
backend_name = str(instance_name)
2026-05-23 13:49:47 -07:00
cache_key = (backend_type, str(instance_name))
2025-12-13 12:09:50 -08:00
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):
2026-05-23 13:49:47 -07:00
override_name = _get_case_insensitive(dict(instance_config), "NAME")
2025-12-13 12:09:50 -08:00
if override_name:
self._backend_errors[str(override_name)] = str(cached_error)
continue
2026-05-23 13:49:47 -07:00
2025-12-11 19:04:02 -08:00
try:
kwargs = _build_kwargs(
2026-05-23 13:49:47 -07:00
backend_cls,
str(instance_name),
2026-05-23 13:49:47 -07:00
instance_config,
)
2025-12-13 00:18:30 -08:00
for key in list(kwargs.keys()):
2026-01-19 21:25:44 -08:00
if _normalize_config_key(key) in {"PATH", "LOCATION"}:
2026-01-11 00:52:54 -08:00
kwargs[key] = str(expand_path(kwargs[key]))
2025-12-13 00:18:30 -08:00
2026-05-23 13:49:47 -07:00
backend = backend_cls(**kwargs)
2025-12-13 00:18:30 -08:00
backend_name = str(kwargs.get("NAME") or instance_name)
self._backends[backend_name] = backend
2026-05-23 13:49:47 -07:00
self._backend_types[backend_name] = backend_type
2025-12-11 19:04:02 -08:00
except Exception as exc:
2025-12-13 12:09:50 -08:00
err_text = str(exc)
self._backend_errors[str(instance_name)] = err_text
_FAILED_BACKEND_CACHE[cache_key] = err_text
2025-12-11 19:04:02 -08:00
if not self._suppress_debug:
2025-12-13 00:18:30 -08:00
debug(
2026-05-23 13:49:47 -07:00
f"[BackendRegistry] Failed to register {backend_cls.__name__} instance '{instance_name}': {exc}"
2025-12-13 00:18:30 -08:00
)
2025-12-11 19:04:02 -08:00
2026-01-19 21:25:44 -08:00
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
2026-05-23 13:49:47 -07:00
requested_norm = _normalize_backend_type(requested)
2026-01-19 21:25:44 -08:00
ci_matches = [
name for name in self._backends
2026-05-23 13:49:47 -07:00
if _normalize_backend_type(name) == requested_norm
2026-01-19 21:25:44 -08:00
]
if len(ci_matches) == 1:
return ci_matches[0], None
if len(ci_matches) > 1:
2026-05-23 13:49:47 -07:00
return None, f"Ambiguous backend alias '{backend_name}' matches {ci_matches}"
2026-01-19 21:25:44 -08:00
type_matches = [
2026-05-23 13:49:47 -07:00
name for name, backend_type in self._backend_types.items()
if backend_type == requested_norm
2026-01-19 21:25:44 -08:00
]
if len(type_matches) == 1:
return type_matches[0], None
if len(type_matches) > 1:
return None, (
2026-05-23 13:49:47 -07:00
f"Ambiguous backend alias '{backend_name}' matches type '{requested_norm}': {type_matches}"
2026-01-19 21:25:44 -08:00
)
prefix_matches = [
2026-05-23 13:49:47 -07:00
name for name, backend_type in self._backend_types.items()
if backend_type.startswith(requested_norm)
2026-01-19 21:25:44 -08:00
]
if len(prefix_matches) == 1:
return prefix_matches[0], None
if len(prefix_matches) > 1:
return None, (
2026-05-23 13:49:47 -07:00
f"Ambiguous backend alias '{backend_name}' matches type prefix '{requested_norm}': {prefix_matches}"
2026-01-19 21:25:44 -08:00
)
return None, None
2025-12-13 12:09:50 -08:00
def get_backend_error(self, backend_name: str) -> Optional[str]:
return self._backend_errors.get(str(backend_name))
2025-12-11 19:04:02 -08:00
def list_backends(self) -> list[str]:
return sorted(self._backends.keys())
def list_searchable_backends(self) -> list[str]:
2025-12-14 00:53:52 -08:00
def _rank(name: str) -> int:
2026-05-23 13:49:47 -07:00
normalized_name = str(name or "").strip().lower()
if normalized_name == "temp":
2025-12-14 00:53:52 -08:00
return 0
2026-05-23 13:49:47 -07:00
if normalized_name == "default":
2025-12-14 00:53:52 -08:00
return 2
return 1
2026-05-23 13:49:47 -07:00
chosen: Dict[int, str] = {}
2025-12-11 19:04:02 -08:00
for name, backend in self._backends.items():
2026-05-23 13:49:47 -07:00
if type(backend).search is BackendBase.search:
2025-12-14 00:53:52 -08:00
continue
key = id(backend)
prev = chosen.get(key)
if prev is None or _rank(name) < _rank(prev):
chosen[key] = name
return sorted(chosen.values())
2025-12-11 19:04:02 -08:00
2026-05-23 13:49:47 -07:00
def __getitem__(self, backend_name: str) -> BackendBase:
2026-01-05 07:51:19 -08:00
resolved, err = self._resolve_backend_name(backend_name)
if resolved:
return self._backends[resolved]
if err:
2026-05-23 13:49:47 -07:00
raise KeyError(f"Unknown backend: {backend_name}. {err}")
raise KeyError(f"Unknown backend: {backend_name}. Available: {list(self._backends.keys())}")
2025-12-11 19:04:02 -08:00
def is_available(self, backend_name: str) -> bool:
2026-01-05 07:51:19 -08:00
resolved, _err = self._resolve_backend_name(backend_name)
return resolved is not None
2025-12-19 03:25:52 -08:00
def try_add_url_for_pipe_object(self, pipe_obj: Any, url: str) -> bool:
2026-05-23 13:49:47 -07:00
"""Best-effort helper: if `pipe_obj` contains `store` + `hash`, add `url` to that backend."""
2025-12-19 03:25:52 -08:00
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]:
2026-05-23 13:49:47 -07:00
"""Return configured backend instance names without instantiating backends."""
try:
names: list[str] = []
2026-05-26 15:32:01 -07:00
for section_name in ("plugin",):
section_cfg = (config or {}).get(section_name) or {}
if not isinstance(section_cfg, dict):
2026-03-21 22:56:37 -07:00
continue
2026-05-23 13:49:47 -07:00
for raw_backend_type, instances in section_cfg.items():
if not isinstance(instances, dict):
2026-03-21 22:56:37 -07:00
continue
2026-05-23 13:49:47 -07:00
backend_type = _normalize_backend_type(str(raw_backend_type))
if backend_type == "folder" or backend_type in _PROVIDER_ONLY_BACKEND_NAMES:
continue
2026-05-23 13:49:47 -07:00
backend_cls = _resolve_backend_class(backend_type)
if backend_cls is None:
continue
for instance_name, instance_config in instances.items():
try:
2026-05-23 13:49:47 -07:00
_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 []
2026-01-19 21:25:44 -08:00
2026-05-23 13:49:47 -07:00
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."""
2026-01-19 21:25:44 -08:00
if not backend_name:
return None
desired = str(backend_name or "").strip().lower()
2026-05-26 15:32:01 -07:00
for section_name in ("plugin",):
section_cfg = (config or {}).get(section_name) or {}
if not isinstance(section_cfg, dict):
2026-01-19 21:25:44 -08:00
continue
2026-05-23 13:49:47 -07:00
for raw_backend_type, instances in section_cfg.items():
if not isinstance(instances, dict):
continue
2026-05-23 13:49:47 -07:00
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):
2026-05-23 13:49:47 -07:00
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
2026-01-19 21:25:44 -08:00
try:
2026-05-23 13:49:47 -07:00
kwargs = _build_kwargs(backend_cls, str(instance_name), instance_cfg)
2026-01-19 21:25:44 -08:00
except Exception as exc:
if not suppress_debug:
2026-05-23 13:49:47 -07:00
debug(
f"[BackendRegistry] Can't build kwargs for '{instance_name}' ({backend_type}/{section_name}): {exc}"
)
2026-01-19 21:25:44 -08:00
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:
2026-05-23 13:49:47 -07:00
return backend_cls(**kwargs)
2026-01-19 21:25:44 -08:00
except Exception as exc:
if not suppress_debug:
2026-05-23 13:49:47 -07:00
debug(f"[BackendRegistry] Failed to instantiate backend '{candidate_alias}': {exc}")
2026-01-19 21:25:44 -08:00
return None
for instance_name, instance_cfg in instances.items():
try:
2026-05-23 13:49:47 -07:00
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:
2026-05-23 13:49:47 -07:00
return backend_cls(**kwargs)
except Exception as exc:
if not suppress_debug:
2026-05-23 13:49:47 -07:00
debug(f"[BackendRegistry] Failed to instantiate backend '{alias}': {exc}")
return None
2026-01-19 21:25:44 -08:00
if not suppress_debug:
2026-05-23 13:49:47 -07:00
debug(f"[BackendRegistry] Backend '{backend_name}' not found in config")
2026-01-19 21:25:44 -08:00
return None
2026-05-23 13:49:47 -07:00
__all__ = [
"BackendBase",
"BackendRegistry",
"get_backend_instance",
"list_configured_backend_names",
]