update and cleanup repo

This commit is contained in:
2026-05-26 15:32:01 -07:00
parent 5041d9fbb9
commit 0db899d0c3
72 changed files with 788 additions and 1884 deletions
+7 -7
View File
@@ -21,7 +21,7 @@ _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.store.<type>.<instance_key>.
# under config.plugin.<type>.<instance_key>.
_FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {}
@@ -186,12 +186,12 @@ class BackendRegistry:
self._load_backends()
def _load_backends(self) -> None:
store_cfg = self._config.get("store")
if not isinstance(store_cfg, dict):
store_cfg = {}
plugin_cfg = self._config.get("plugin")
if not isinstance(plugin_cfg, dict):
plugin_cfg = {}
self._backend_types = {}
for raw_backend_type, instances in store_cfg.items():
for raw_backend_type, instances in plugin_cfg.items():
if not isinstance(instances, dict):
continue
@@ -357,7 +357,7 @@ 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 ("store", "plugin", "provider"):
for section_name in ("plugin",):
section_cfg = (config or {}).get(section_name) or {}
if not isinstance(section_cfg, dict):
continue
@@ -403,7 +403,7 @@ def get_backend_instance(
return None
desired = str(backend_name or "").strip().lower()
for section_name in ("store", "plugin", "provider"):
for section_name in ("plugin",):
section_cfg = (config or {}).get(section_name) or {}
if not isinstance(section_cfg, dict):
continue
+5 -14
View File
@@ -323,20 +323,11 @@ class Provider(ABC):
def plugin_config_root(self) -> Dict[str, Any]:
if not isinstance(self.config, dict):
return {}
# Check plugin/provider section first (preferred new format)
for section in ("plugin", "provider"):
section_cfg = self.config.get(section)
if isinstance(section_cfg, dict):
entry = section_cfg.get(self.plugin_config_key())
if isinstance(entry, dict):
return dict(entry)
# Backward compat: fall back to store section.
# store config uses {type: {instance: {key: val}}} — one level deeper.
store_cfg = self.config.get("store")
if isinstance(store_cfg, dict):
store_entries = store_cfg.get(self.plugin_config_key())
if isinstance(store_entries, dict):
return dict(store_entries)
section_cfg = self.config.get("plugin")
if isinstance(section_cfg, dict):
entry = section_cfg.get(self.plugin_config_key())
if isinstance(entry, dict):
return dict(entry)
return {}
def plugin_instance_configs(self) -> Dict[str, Dict[str, Any]]:
+17 -9
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
import importlib.util
from importlib import import_module
from pathlib import Path
import pkgutil
from typing import Any, Callable, Dict, Iterable, Sequence
@@ -64,22 +65,29 @@ def _register_command_object(cmdlet_obj: Any, registry: Dict[str, CmdletFn]) ->
def iter_plugin_command_module_names() -> list[str]:
try:
repo_root = Path(__file__).resolve().parent.parent
package = import_module("plugins")
except Exception:
return []
plugins_dir = repo_root / "plugins"
if not plugins_dir.is_dir():
package_path = getattr(package, "__path__", None)
if not package_path:
return []
module_names: list[str] = []
for entry in sorted(plugins_dir.iterdir(), key=lambda path: path.name.lower()):
if not entry.is_dir() or entry.name.startswith("."):
seen: set[str] = set()
for _, module_name, is_package in pkgutil.iter_modules(package_path):
if not is_package or module_name.startswith("_"):
continue
if not (entry / "__init__.py").is_file():
commands_module = f"plugins.{module_name}.commands"
try:
if importlib.util.find_spec(commands_module) is None:
continue
except Exception:
continue
if (entry / "commands.py").is_file() or (entry / "commands" / "__init__.py").is_file():
module_names.append(f"plugins.{entry.name}.commands")
if commands_module in seen:
continue
seen.add(commands_module)
module_names.append(commands_module)
return module_names
+19 -12
View File
@@ -1,4 +1,4 @@
"""Inline query helpers for providers (choice normalization and filter resolution)."""
"""Inline query helpers for plugins (choice normalization and filter resolution)."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
@@ -24,7 +24,7 @@ def collect_choice(provider: Any) -> Dict[str, List[Dict[str, Any]]]:
"""Collect normalized inline/query argument choice entries from a provider.
Supports QUERY_ARG_CHOICES, INLINE_QUERY_FIELD_CHOICES, and the
helper methods valued by Providers (`query_field_choices` /
helper methods exposed by plugins (`query_field_choices` /
`inline_query_field_choices`). Each choice is normalized to {value,text,aliases}.
"""
@@ -48,24 +48,31 @@ def collect_choice(provider: Any) -> Dict[str, List[Dict[str, Any]]]:
if normalized:
mapping[target_key] = normalized
base = getattr(provider, "QUERY_ARG_CHOICES", None)
if isinstance(base, dict):
for k, v in base.items():
def _merge_mapping(source: Any) -> None:
if not isinstance(source, dict):
return
for k, v in source.items():
key_norm = str(k).strip().lower()
if not key_norm:
continue
_ingest(v, key_norm)
base = getattr(provider, "QUERY_ARG_CHOICES", None)
if not isinstance(base, dict):
base = getattr(provider, "INLINE_QUERY_FIELD_CHOICES", None)
_merge_mapping(base)
try:
fn = getattr(provider, "query_field_choices", None)
if callable(fn):
_merge_mapping(fn())
except Exception:
pass
try:
fn = getattr(provider, "inline_query_field_choices", None)
if callable(fn):
extra = fn()
if isinstance(extra, dict):
for k, v in extra.items():
key_norm = str(k).strip().lower()
if not key_norm:
continue
_ingest(v, key_norm)
_merge_mapping(fn())
except Exception:
pass
+33 -111
View File
@@ -23,6 +23,7 @@ from urllib.parse import urlparse
from SYS.logger import log, debug
from PluginCore.base import Provider, SearchResult
from PluginCore.inline_utils import collect_choice, resolve_filter
_EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
@@ -150,6 +151,14 @@ class PluginInfo:
exposed = True
return exposed and _class_supports_method(self.plugin_class, "upload", Provider.upload)
@property
def supports_download(self) -> bool:
return (
_class_supports_method(self.plugin_class, "handle_url", Provider.handle_url)
or _class_supports_method(self.plugin_class, "download_url", Provider.download_url)
or _class_supports_method(self.plugin_class, "download", Provider.download)
)
@property
def is_multi_instance(self) -> bool:
"""True if the plugin declares MULTI_INSTANCE = True."""
@@ -542,6 +551,7 @@ def get_plugin_capabilities(
"supported_cmdlets": [],
"supports_search": False,
"supports_upload": False,
"supports_download": False,
"supports_pipe_download": False,
"supports_delete_file": False,
"supports_url_association": False,
@@ -582,6 +592,7 @@ def get_plugin_capabilities(
"supported_cmdlets": supported_cmdlets,
"supports_search": bool(info.supports_search),
"supports_upload": bool(info.supports_upload),
"supports_download": bool(info.supports_download),
"supports_pipe_download": bool(supports_pipe_download),
"supports_delete_file": bool(supports_delete_file),
"supports_url_association": bool(supports_url_association),
@@ -627,6 +638,14 @@ def _supports_upload(provider: Provider) -> bool:
return exposed and _class_supports_method(provider.__class__, "upload", Provider.upload)
def _supports_download(provider: Provider) -> bool:
return (
_class_supports_method(provider.__class__, "handle_url", Provider.handle_url)
or _class_supports_method(provider.__class__, "download_url", Provider.download_url)
or _class_supports_method(provider.__class__, "download", Provider.download)
)
def _supports_pipe_result_download(provider: Provider) -> bool:
return _class_supports_method(
provider.__class__,
@@ -647,6 +666,8 @@ def _supports_capability(provider: Provider, capability: str) -> bool:
return _supports_search(provider)
if capability_key in {"upload", "file", "file-provider"}:
return _supports_upload(provider)
if capability_key in {"download", "download-file", "download_file"}:
return _supports_download(provider)
if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
return _supports_pipe_result_download(provider)
if capability_key in {"delete-file", "delete_file", "delete"}:
@@ -672,6 +693,8 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
return bool(info.supports_search)
if capability_key in {"upload", "file", "file-provider"}:
return bool(info.supports_upload)
if capability_key in {"download", "download-file", "download_file"}:
return bool(info.supports_download)
if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
return _class_supports_method(
info.plugin_class,
@@ -697,62 +720,6 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
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:
@@ -838,12 +805,11 @@ def list_configured_plugin_names_with_capability(
"""Return plugin names that support `capability` AND have configuration present.
For MULTI_INSTANCE plugins (e.g. hydrusnetwork, ftp) the plugin must have at
least one configured instance. For single-instance plugins the plugin's section
must exist under config["plugin"] or config["provider"].
least one configured instance. For single-instance plugins the plugin's section
must exist under config["plugin"].
"""
cfg = config or {}
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment]
provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment]
result: List[str] = []
for info in REGISTRY.iter_plugins():
@@ -862,7 +828,7 @@ def list_configured_plugin_names_with_capability(
pass
else:
pname = name.lower()
if isinstance(plugin_section.get(pname), dict) or isinstance(provider_section.get(pname), dict):
if isinstance(plugin_section.get(pname), dict):
result.append(name)
return sorted(result)
@@ -890,7 +856,7 @@ def list_plugin_names_for_cmdlet(
fallback_capability = {
"search-file": "search",
"add-file": "upload",
"download-file": "search",
"download-file": "download",
"delete-file": "delete-file",
}.get(cmd)
@@ -905,12 +871,11 @@ def list_plugin_names_for_cmdlet(
if fallback_capability:
configured.update(list_configured_plugin_names_with_capability(fallback_capability, cfg))
# Keep cmdlet-declared plugins if they appear configured in plugin/provider sections.
# Keep cmdlet-declared plugins if they appear configured in the plugin section.
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment]
provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment]
for name in supported:
key = str(name or "").strip().lower()
if isinstance(plugin_section.get(key), dict) or isinstance(provider_section.get(key), dict):
if isinstance(plugin_section.get(key), dict):
configured.add(name)
return sorted(configured)
@@ -995,13 +960,13 @@ def plugin_inline_query_choices(
mapping: Dict[str, List[Dict[str, Any]]] = {}
info = REGISTRY.get(pname)
if info is not None:
mapping = _collect_inline_choice_mapping(info.plugin_class)
mapping = collect_choice(info.plugin_class)
if not mapping:
plugin = get_plugin(pname, config)
if plugin is None:
return []
mapping = _collect_inline_choice_mapping(plugin)
mapping = collect_choice(plugin)
if not mapping:
return []
@@ -1065,52 +1030,9 @@ def resolve_inline_filters(
*,
field_transforms: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
"""Map inline query args to provider filter values using declared choices.
"""Map inline query args to plugin filter values using the canonical helper."""
- 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
return resolve_filter(provider, inline_args, field_transforms=field_transforms)
def clear_plugin_cache() -> None: