"""Plugin registry. Bundled plugins live in the ``plugins`` package. Additional drop-in plugins can be discovered from external plugin directories configured via the environment or current working directory. """ from __future__ import annotations from functools import lru_cache import hashlib import importlib import importlib.util import os import pkgutil import sys from dataclasses import dataclass, field from pathlib import Path from types import ModuleType from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type from urllib.parse import urlparse from SYS.logger import log, debug from ProviderCore.base import Provider, SearchResult _EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH") def _class_supports_method( plugin_class: Type[Provider], method_name: str, base_method: Any, ) -> bool: try: method = getattr(plugin_class, method_name, None) except Exception: return False return callable(method) and method is not base_method def _repo_root() -> Path: try: return Path(__file__).resolve().parents[1] except Exception: return Path.cwd() def _iter_external_plugin_dirs() -> Tuple[Path, ...]: seen: set[str] = set() dirs: List[Path] = [] candidates: List[Path] = [_repo_root() / "plugins"] try: cwd_plugins = Path.cwd() / "plugins" if cwd_plugins not in candidates: candidates.append(cwd_plugins) except Exception: pass for env_name in _EXTERNAL_PLUGIN_ENV_VARS: raw_value = str(os.environ.get(env_name, "") or "").strip() if not raw_value: continue for chunk in raw_value.split(os.pathsep): text = str(chunk or "").strip().strip('"') if not text: continue candidates.append(Path(text).expanduser()) for candidate in candidates: try: resolved = candidate.resolve() except Exception: resolved = candidate key = str(resolved).lower() if key in seen: continue seen.add(key) try: if resolved.exists() and resolved.is_dir(): dirs.append(resolved) except Exception: continue return tuple(dirs) def _iter_external_plugin_entries(plugin_dir: Path) -> Iterable[Tuple[str, Path, bool]]: try: children = sorted(plugin_dir.iterdir(), key=lambda entry: entry.name.lower()) except Exception: return () out: List[Tuple[str, Path, bool]] = [] for child in children: name = str(child.name or "").strip() if not name or name.startswith("."): continue if child.is_file() and child.suffix.lower() == ".py" and child.stem != "__init__": fingerprint = hashlib.sha1(str(child).encode("utf-8", errors="ignore")).hexdigest()[:10] out.append((f"_medeia_plugin_{child.stem}_{fingerprint}", child, False)) continue if child.is_dir(): init_py = child / "__init__.py" if not init_py.exists() or not init_py.is_file(): continue fingerprint = hashlib.sha1(str(child).encode("utf-8", errors="ignore")).hexdigest()[:10] out.append((f"_medeia_plugin_pkg_{child.name}_{fingerprint}", init_py, True)) return tuple(out) @dataclass(frozen=True) class PluginInfo: """Metadata about a single plugin entry.""" canonical_name: str plugin_class: Type[Provider] module: str alias_names: Tuple[str, ...] = field(default_factory=tuple) @property def supports_search(self) -> bool: return _class_supports_method(self.plugin_class, "search", Provider.search) @property def supports_upload(self) -> bool: try: exposed = bool(getattr(self.plugin_class, "EXPOSE_AS_FILE_PROVIDER", True)) except Exception: exposed = True return exposed and _class_supports_method(self.plugin_class, "upload", Provider.upload) class PluginRegistry: """Handles discovery, registration, and lookup of built-in and external plugins.""" def __init__(self, package_name: str) -> None: self.package_name = (package_name or "").strip() self._infos: Dict[str, PluginInfo] = {} self._lookup: Dict[str, PluginInfo] = {} self._modules: set[str] = set() self._external_modules: set[str] = set() self._builtin_package_dirs: Tuple[Path, ...] = () self._discovered = False self._external_dirs_scanned = False def _ensure_builtin_package_dirs(self) -> None: if self._builtin_package_dirs or not self.package_name: return try: package = importlib.import_module(self.package_name) except Exception: return package_path = getattr(package, "__path__", None) if not package_path: return builtin_dirs: List[Path] = [] for entry in package_path: try: builtin_dirs.append(Path(str(entry)).resolve()) except Exception: builtin_dirs.append(Path(str(entry))) self._builtin_package_dirs = tuple(builtin_dirs) def _is_builtin_package_dir(self, candidate: Path) -> bool: self._ensure_builtin_package_dirs() try: resolved = candidate.resolve() except Exception: resolved = candidate for package_dir in self._builtin_package_dirs: try: if resolved == package_dir: return True except Exception: continue return False def _normalize(self, value: Any) -> str: return str(value or "").strip().lower() def _candidate_names(self, plugin_class: Type[Provider], override_name: Optional[str]) -> List[str]: names: List[str] = [] seen: set[str] = set() def _add(value: Any) -> None: text = str(value or "").strip() normalized = text.lower() if not text or normalized in seen: return seen.add(normalized) names.append(text) if override_name: _add(override_name) else: _add(getattr(plugin_class, "PLUGIN_NAME", None)) _add(getattr(plugin_class, "__name__", None)) for alias in getattr(plugin_class, "PLUGIN_ALIASES", ()) or (): _add(alias) return names def register( self, plugin_class: Type[Provider], *, override_name: Optional[str] = None, extra_aliases: Optional[Sequence[str]] = None, module_name: Optional[str] = None, replace: bool = False, ) -> PluginInfo: """Register a plugin class with canonical and alias names.""" candidates = self._candidate_names(plugin_class, override_name) if not candidates: raise ValueError("plugin name candidates are required") canonical = self._normalize(candidates[0]) if not canonical: raise ValueError("plugin name must not be empty") alias_names: List[str] = [] alias_seen: set[str] = set() for candidate in candidates[1:]: normalized = self._normalize(candidate) if not normalized or normalized == canonical or normalized in alias_seen: continue alias_seen.add(normalized) alias_names.append(normalized) for alias in extra_aliases or (): normalized = self._normalize(alias) if not normalized or normalized == canonical or normalized in alias_seen: continue alias_seen.add(normalized) alias_names.append(normalized) info = PluginInfo( canonical_name=canonical, plugin_class=plugin_class, module=module_name or getattr(plugin_class, "__module__", "") or "", alias_names=tuple(alias_names), ) existing = self._infos.get(canonical) if existing is not None and not replace: return existing self._infos[canonical] = info for lookup in (canonical,) + tuple(alias_names): self._lookup[lookup] = info return info def _register_module(self, module: ModuleType) -> None: module_name = getattr(module, "__name__", "") if not module_name or module_name in self._modules: return self._modules.add(module_name) # Iterate module dict directly (faster than dir()+getattr()). for candidate in vars(module).values(): if not isinstance(candidate, type): continue if not issubclass(candidate, Provider): continue if candidate is Provider: continue if getattr(candidate, "__module__", "") != module_name: continue try: self.register(candidate, module_name=module_name) except Exception as exc: log(f"[plugin] Failed to register {module_name}.{candidate.__name__}: {exc}", file=sys.stderr) def _discover_external_plugins(self) -> None: if self._external_dirs_scanned: return self._external_dirs_scanned = True self._ensure_builtin_package_dirs() for plugin_dir in _iter_external_plugin_dirs(): if self._is_builtin_package_dir(plugin_dir): continue try: plugin_dir_str = str(plugin_dir) if plugin_dir_str and plugin_dir_str not in sys.path: sys.path.insert(0, plugin_dir_str) except Exception: pass for module_name, module_path, is_package in _iter_external_plugin_entries(plugin_dir): if module_name in self._external_modules: continue try: if is_package: spec = importlib.util.spec_from_file_location( module_name, str(module_path), submodule_search_locations=[str(module_path.parent)], ) else: spec = importlib.util.spec_from_file_location(module_name, str(module_path)) if spec is None or spec.loader is None: raise ImportError("missing module spec loader") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) self._external_modules.add(module_name) self._register_module(module) except Exception as exc: log(f"[plugin] Failed to load external plugin {module_path}: {exc}", file=sys.stderr) def discover(self) -> None: """Import and register plugins from the package.""" if self._discovered or not self.package_name: return self._discovered = True try: package = importlib.import_module(self.package_name) except Exception as exc: log(f"[plugin] Failed to import package {self.package_name}: {exc}", file=sys.stderr) return self._register_module(package) self._ensure_builtin_package_dirs() package_path = getattr(package, "__path__", None) if not package_path: self._discover_external_plugins() return for finder, module_name, _ in pkgutil.iter_modules(package_path): if module_name.startswith("_"): continue module_path = f"{self.package_name}.{module_name}" try: module = importlib.import_module(module_path) except Exception as exc: log(f"[plugin] Failed to load {module_path}: {exc}", file=sys.stderr) continue self._register_module(module) # Pick up any Provider subclasses loaded via other mechanisms. self._sync_subclasses() self._discover_external_plugins() def _try_import_for_name(self, normalized_name: str) -> None: """Best-effort import for a single plugin module. This avoids importing every provider module when the caller only needs one plugin (common for CLI usage). """ name = str(normalized_name or "").strip().lower() if not name or not self.package_name: return candidates: List[str] = [name] if "-" in name: candidates.append(name.replace("-", "_")) if "." in name: candidates.append(name.split(".", 1)[0]) for mod_name in candidates: if not mod_name: continue module_path = f"{self.package_name}.{mod_name}" if module_path in self._modules: continue try: module = importlib.import_module(module_path) except Exception: continue self._register_module(module) # Pick up subclasses in case the module registers indirectly. self._sync_subclasses() return def get(self, name: str) -> Optional[PluginInfo]: if not name: return None normalized = self._normalize(name) info = self._lookup.get(normalized) if info is not None: return info # If we haven't done a full discovery yet, try importing just the # module that matches the requested name. if not self._discovered: self._try_import_for_name(normalized) self._discover_external_plugins() info = self._lookup.get(normalized) if info is not None: return info # Fall back to full package scan. self.discover() return self._lookup.get(normalized) def iter_plugins(self) -> Iterable[PluginInfo]: self.discover() return tuple(self._infos.values()) def has_name(self, name: str) -> bool: return self.get(name) is not None def _sync_subclasses(self) -> None: """Walk all plugin subclasses in memory and register them.""" def _walk(cls: Type[Provider]) -> None: for sub in cls.__subclasses__(): try: self.register(sub) except Exception: pass _walk(sub) _walk(Provider) REGISTRY = PluginRegistry("plugins") PLUGIN_REGISTRY = REGISTRY @lru_cache(maxsize=512) def _plugin_url_patterns(plugin_class: Type[Provider]) -> Sequence[str]: try: return list(plugin_class.url_patterns()) except Exception: return [] def register_plugin( plugin_class: Type[Provider], *, name: Optional[str] = None, aliases: Optional[Sequence[str]] = None, module_name: Optional[str] = None, replace: bool = False, ) -> PluginInfo: return REGISTRY.register( plugin_class, override_name=name, extra_aliases=aliases, module_name=module_name, replace=replace, ) def get_plugin_class(name: str) -> Optional[Type[Provider]]: info = REGISTRY.get(name) if info is None: return None return info.plugin_class def selection_auto_stage_for_table( table_type: str, stage_args: Optional[Sequence[str]] = None, ) -> Optional[list[str]]: t = str(table_type or "").strip().lower() if not t: return None plugin_key = t.split(".", 1)[0] if "." in t else t plugin_class = get_plugin_class(plugin_key) or get_plugin_class(t) if plugin_class is None: return None try: return plugin_class.selection_auto_stage(t, stage_args) except Exception: return None def is_known_plugin_name(name: str) -> bool: return REGISTRY.has_name(name) def _supports_search(provider: Provider) -> bool: return _class_supports_method(provider.__class__, "search", Provider.search) def _supports_upload(provider: Provider) -> bool: try: exposed = bool(getattr(provider.__class__, "EXPOSE_AS_FILE_PROVIDER", True)) except Exception: exposed = True return exposed and _class_supports_method(provider.__class__, "upload", Provider.upload) def _supports_capability(provider: Provider, capability: str) -> bool: capability_key = str(capability or "").strip().lower() if capability_key == "search": return _supports_search(provider) if capability_key in {"upload", "file", "file-provider"}: return _supports_upload(provider) if capability_key in {"pipe-item-context", "pipe-context"}: return _class_supports_method( provider.__class__, "resolve_pipe_item_context", Provider.resolve_pipe_item_context, ) if capability_key in {"playlist-store", "playback-store"}: return _class_supports_method( provider.__class__, "infer_playlist_store", Provider.infer_playlist_store, ) return False def _info_supports_capability(info: PluginInfo, capability: str) -> bool: capability_key = str(capability or "").strip().lower() if capability_key == "search": return bool(info.supports_search) if capability_key in {"upload", "file", "file-provider"}: return bool(info.supports_upload) if capability_key in {"pipe-item-context", "pipe-context"}: return _class_supports_method( info.plugin_class, "resolve_pipe_item_context", Provider.resolve_pipe_item_context, ) if capability_key in {"playlist-store", "playback-store"}: return _class_supports_method( info.plugin_class, "infer_playlist_store", Provider.infer_playlist_store, ) return False def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]: if entry is None: return None if isinstance(entry, dict): value = entry.get("value") text = entry.get("text") or entry.get("label") or value aliases = entry.get("alias") or entry.get("aliases") or [] value_str = str(value) if value is not None else (str(text) if text is not None else None) text_str = str(text) if text is not None else value_str if not value_str or not text_str: return None alias_list = [str(a) for a in aliases if a is not None] return {"value": value_str, "text": text_str, "aliases": alias_list} return {"value": str(entry), "text": str(entry), "aliases": []} def _collect_inline_choice_mapping(provider: Provider) -> Dict[str, List[Dict[str, Any]]]: mapping: Dict[str, List[Dict[str, Any]]] = {} base = getattr(provider, "QUERY_ARG_CHOICES", None) if not isinstance(base, dict): base = getattr(provider, "INLINE_QUERY_FIELD_CHOICES", None) def _merge_from(obj: Any) -> None: if not isinstance(obj, dict): return for key, value in obj.items(): normalized: List[Dict[str, Any]] = [] seq = value try: if callable(seq): seq = seq() except Exception: seq = value if isinstance(seq, dict): seq = seq.get("choices") or seq.get("values") or seq if isinstance(seq, (list, tuple, set)): for entry in seq: n = _normalize_choice_entry(entry) if n: normalized.append(n) if normalized: mapping[str(key).strip().lower()] = normalized _merge_from(base) try: fn = getattr(provider, "inline_query_field_choices", None) if callable(fn): _merge_from(fn()) except Exception: pass return mapping def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]: info = REGISTRY.get(name) if info is None: debug(f"[plugin] Unknown plugin: {name}") return None try: plugin = info.plugin_class(config) if not plugin.validate(): debug(f"[plugin] Plugin '{name}' is not available") return None return plugin except Exception as exc: debug(f"[plugin] Error initializing '{name}': {exc}") return None def list_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]: availability: Dict[str, bool] = {} for info in REGISTRY.iter_plugins(): try: plugin = info.plugin_class(config) availability[info.canonical_name] = plugin.validate() except Exception: availability[info.canonical_name] = False return availability def get_plugin_with_capability( name: str, capability: str, config: Optional[Dict[str, Any]] = None, ) -> Optional[Provider]: plugin = get_plugin(name, config) if plugin is None: return None if not _supports_capability(plugin, capability): debug(f"[plugin] Plugin '{name}' does not support capability '{capability}'") return None return plugin def list_plugins_with_capability( capability: str, config: Optional[Dict[str, Any]] = None, ) -> Dict[str, bool]: availability: Dict[str, bool] = {} for info in REGISTRY.iter_plugins(): try: plugin = info.plugin_class(config) availability[info.canonical_name] = bool( plugin.validate() and _supports_capability(plugin, capability) ) except Exception: availability[info.canonical_name] = False return availability def list_plugin_names_with_capability(capability: str) -> List[str]: return sorted( info.canonical_name for info in REGISTRY.iter_plugins() if _info_supports_capability(info, capability) ) def match_plugin_name_for_url(url: str) -> Optional[str]: raw_url = str(url or "").strip() raw_url_lower = raw_url.lower() try: parsed = urlparse(raw_url) host = (parsed.hostname or "").strip().lower() path = (parsed.path or "").strip() except Exception: host = "" path = "" def _norm_host(h: str) -> str: h_norm = str(h or "").strip().lower() if h_norm.startswith("www."): h_norm = h_norm[4:] return h_norm host_norm = _norm_host(host) if host_norm: if host_norm == "openlibrary.org" or host_norm.endswith(".openlibrary.org"): return "openlibrary" if REGISTRY.has_name("openlibrary") else None if host_norm == "archive.org" or host_norm.endswith(".archive.org"): low_path = str(path or "").lower() is_borrowish = ( low_path.startswith("/borrow/") or low_path.startswith("/stream/") or low_path.startswith("/services/loans/") or "/services/loans/" in low_path ) if is_borrowish: return "openlibrary" if REGISTRY.has_name("openlibrary") else None return "internetarchive" if REGISTRY.has_name("internetarchive") else None for info in REGISTRY.iter_plugins(): domains = _plugin_url_patterns(info.plugin_class) if not domains: continue for domain in domains: dom_raw = str(domain or "").strip() dom = dom_raw.lower() if not dom: continue if "://" in dom or dom.startswith("magnet:") or dom.endswith(":") or "🧲" in dom: if raw_url_lower.startswith(dom): return info.canonical_name continue dom_norm = _norm_host(dom) if not dom_norm or not host_norm: continue if host_norm == dom_norm or host_norm.endswith("." + dom_norm): return info.canonical_name return None def plugin_inline_query_choices( plugin_name: str, field_name: str, config: Optional[Dict[str, Any]] = None, ) -> List[str]: """Return plugin-declared inline query choices for a field (e.g., system:GBA). Plugins can expose a mapping via ``QUERY_ARG_CHOICES`` (preferred) or ``INLINE_QUERY_FIELD_CHOICES`` / ``inline_query_field_choices()``. The helper keeps completion logic simple and reusable. """ pname = str(plugin_name or "").strip().lower() field = str(field_name or "").strip().lower() if not pname or not field: return [] try: mapping: Dict[str, List[Dict[str, Any]]] = {} info = REGISTRY.get(pname) if info is not None: mapping = _collect_inline_choice_mapping(info.plugin_class) if not mapping: plugin = get_plugin(pname, config) if plugin is None: return [] mapping = _collect_inline_choice_mapping(plugin) if not mapping: return [] entries = mapping.get(field, []) if not entries: return [] seen: set[str] = set() out: List[str] = [] for entry in entries: text = entry.get("text") or entry.get("value") if not text: continue text_str = str(text) if text_str in seen: continue seen.add(text_str) out.append(text_str) for alias in entry.get("aliases", []): alias_str = str(alias) if alias_str and alias_str not in seen: seen.add(alias_str) out.append(alias_str) return out except Exception: return [] def get_plugin_for_url(url: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]: name = match_plugin_name_for_url(url) if not name: return None return get_plugin(name, config) def list_selection_url_prefixes() -> List[str]: prefixes: List[str] = [] seen: set[str] = set() for info in REGISTRY.iter_plugins(): try: values = info.plugin_class.selection_url_prefixes() except Exception: values = () for value in values or (): try: normalized = str(value or "").strip().lower() except Exception: continue if not normalized or normalized in seen: continue seen.add(normalized) prefixes.append(normalized) return prefixes def resolve_inline_filters( provider: Provider, inline_args: Dict[str, Any], *, field_transforms: Optional[Dict[str, Any]] = None, ) -> Dict[str, str]: """Map inline query args to provider filter values using declared choices. - Uses provider's inline choice mapping (value/text/aliases) to resolve user text. - Applies optional per-field transforms (e.g., str.upper). - Returns normalized filters suitable for provider.search. """ filters: Dict[str, str] = {} if not inline_args: return filters mapping = _collect_inline_choice_mapping(provider) transforms = field_transforms or {} for raw_key, raw_val in inline_args.items(): if raw_val is None: continue key = str(raw_key or "").strip().lower() val_str = str(raw_val).strip() if not key or not val_str: continue entries = mapping.get(key, []) resolved: Optional[str] = None val_lower = val_str.lower() for entry in entries: text = str(entry.get("text") or "").strip() value = str(entry.get("value") or "").strip() aliases = [str(a).strip() for a in entry.get("aliases", []) if a is not None] if val_lower in {text.lower(), value.lower()} or val_lower in {a.lower() for a in aliases}: resolved = value or text or val_str break if resolved is None: resolved = val_str transform = transforms.get(key) if callable(transform): try: resolved = transform(resolved) except Exception: pass if resolved: filters[key] = str(resolved) return filters __all__ = [ "PluginInfo", "Provider", "SearchResult", "PluginRegistry", "PLUGIN_REGISTRY", "register_plugin", "get_plugin", "list_plugins", "get_plugin_with_capability", "list_plugins_with_capability", "list_plugin_names_with_capability", "match_plugin_name_for_url", "get_plugin_for_url", "list_selection_url_prefixes", "get_plugin_class", "selection_auto_stage_for_table", "plugin_inline_query_choices", "is_known_plugin_name", ]