diff --git a/API/HTTP.py b/API/HTTP.py index 14b79c1..5912e9d 100644 --- a/API/HTTP.py +++ b/API/HTTP.py @@ -33,7 +33,6 @@ except Exception: # pragma: no cover - optional dependency logger = logging.getLogger(__name__) from API.ssl_certs import resolve_verify_value as _resolve_verify_value -from API.ssl_certs import get_requests_verify_value from API.httpx_shared import get_shared_httpx_client # Default configuration @@ -924,11 +923,6 @@ def download_direct_file( ) raise DownloadError(f"Error downloading file: {exc}") from exc - -# Back-compat alias -_download_direct_file = download_direct_file - - class AsyncHTTPClient: """Unified async HTTP client with asyncio support.""" diff --git a/API/ssl_certs.py b/API/ssl_certs.py index 2c31888..bc36997 100644 --- a/API/ssl_certs.py +++ b/API/ssl_certs.py @@ -101,9 +101,3 @@ def resolve_verify_value(verify_ssl: bool) -> Union[bool, str]: logger.exception("Failed to probe certifi for trust bundle") return True - - -def get_requests_verify_value(verify_ssl: bool = True) -> Union[bool, str]: - """Backwards-friendly alias for call sites that only care about requests.""" - - return resolve_verify_value(verify_ssl) diff --git a/CLI.py b/CLI.py index 56c4159..9b0d09d 100644 --- a/CLI.py +++ b/CLI.py @@ -60,7 +60,7 @@ from SYS.rich_display import ( from cmdnat._status_shared import ( add_startup_check as _shared_add_startup_check, collect_plugin_startup_checks as _collect_plugin_startup_checks, - has_tool as _has_tool, + has_provider as _has_provider, ) @@ -479,7 +479,7 @@ class CmdletIntrospection: def store_choices(config: Dict[str, Any], force: bool = False) -> List[str]: try: # Use the cached startup check from SharedArgs - from cmdlet._shared import SharedArgs + from SYS.cmdlet_spec import SharedArgs return SharedArgs.get_store_choices(config, force=force) except Exception: return [] @@ -1361,7 +1361,7 @@ class CmdletCompleter(Completer): ) if choices: choice_list = choices - if normalized_prev in {"plugin", "provider"} and current_token: + if normalized_prev == "plugin" and current_token: current_lower = current_token.lower() filtered = [c for c in choices if c.lower().startswith(current_lower)] if filtered: @@ -1665,20 +1665,10 @@ class CmdletExecutor: mod = import_cmd_module(cmd_name, reload_loaded=True) data = getattr(mod, "CMDLET", None) if mod else None if data and hasattr(data, "exec") and callable(getattr(data, "exec")): + from SYS.cmdlet_spec import collect_registered_cmdlet_names + run_fn = getattr(data, "exec") - registered_names = set() - raw_name = getattr(data, "name", None) - if raw_name: - registered_names.add(str(raw_name).replace("_", "-").lower()) - registered_names.add(str(cmd_name).replace("_", "-").lower()) - for alias_attr in ("alias", "aliases"): - alias_values = getattr(data, alias_attr, None) - if alias_values: - for alias in alias_values: - alias_text = str(alias or "").replace("_", "-").lower().strip() - if alias_text: - registered_names.add(alias_text) - for registered_name in registered_names: + for registered_name in collect_registered_cmdlet_names(data, fallback_name=cmd_name): REGISTRY[registered_name] = run_fn cmd_fn = run_fn except Exception: @@ -2336,7 +2326,7 @@ class CLI: # Initialize the store choices cache at startup (filters disabled stores) try: - from cmdlet._shared import SharedArgs + from SYS.cmdlet_spec import SharedArgs config = self._config_loader.load() SharedArgs._refresh_store_choices_cache(config) except Exception: @@ -2622,17 +2612,17 @@ Come to love it when others take what you share, as there is no greater joy files=check.get("files"), ) - # Tool checks (configured via [tool=...]) - if _has_tool(config, "florencevision"): + # Plugin support checks (configured via [plugin=...]) + if _has_provider(config, "florencevision"): try: - tool_cfg = config.get("tool") - fv_cfg = tool_cfg.get("florencevision") if isinstance(tool_cfg, dict) else None + plugin_cfg = config.get("plugin") + fv_cfg = plugin_cfg.get("florencevision") if isinstance(plugin_cfg, dict) else None enabled = bool(fv_cfg.get("enabled")) if isinstance(fv_cfg, dict) else False if not enabled: _add_startup_check( "DISABLED", "FlorenceVision", - provider="tool", + provider="plugin", detail="Not enabled", ) else: @@ -2643,21 +2633,21 @@ Come to love it when others take what you share, as there is no greater joy _add_startup_check( "DISABLED", "FlorenceVision", - provider="tool", + provider="plugin", detail="Missing: " + ", ".join(missing), ) else: _add_startup_check( "ENABLED", "FlorenceVision", - provider="tool", + provider="plugin", detail="Ready", ) except Exception as exc: _add_startup_check( "DISABLED", "FlorenceVision", - provider="tool", + provider="plugin", detail=str(exc), ) except Exception as exc: diff --git a/PluginCore/backend_registry.py b/PluginCore/backend_registry.py index 1267461..6d16931 100644 --- a/PluginCore/backend_registry.py +++ b/PluginCore/backend_registry.py @@ -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... +# under config.plugin... _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 diff --git a/PluginCore/base.py b/PluginCore/base.py index df65d45..8718181 100644 --- a/PluginCore/base.py +++ b/PluginCore/base.py @@ -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]]: diff --git a/PluginCore/commands.py b/PluginCore/commands.py index 009252b..b32192f 100644 --- a/PluginCore/commands.py +++ b/PluginCore/commands.py @@ -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 diff --git a/PluginCore/inline_utils.py b/PluginCore/inline_utils.py index 3e17c58..5da21a6 100644 --- a/PluginCore/inline_utils.py +++ b/PluginCore/inline_utils.py @@ -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 diff --git a/PluginCore/registry.py b/PluginCore/registry.py index d661b25..dbbd421 100644 --- a/PluginCore/registry.py +++ b/PluginCore/registry.py @@ -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: diff --git a/SYS/cmdlet_catalog.py b/SYS/cmdlet_catalog.py index 7ed2425..5001bf5 100644 --- a/SYS/cmdlet_catalog.py +++ b/SYS/cmdlet_catalog.py @@ -410,10 +410,10 @@ def get_cmdlet_arg_choices( matrix_conf = {} try: - providers = config.get("provider") or {} - matrix_conf = providers.get("matrix") or {} + plugins = config.get("plugin") or {} + matrix_conf = plugins.get("matrix") or {} except Exception as exc: - logger.exception("Failed to read matrix provider config: %s", exc) + logger.exception("Failed to read matrix plugin config: %s", exc) matrix_conf = {} raw = None diff --git a/SYS/cmdlet_spec.py b/SYS/cmdlet_spec.py index d25cb53..9145e3b 100644 --- a/SYS/cmdlet_spec.py +++ b/SYS/cmdlet_spec.py @@ -77,6 +77,50 @@ def QueryArg( ) +def collect_registered_cmdlet_names( + cmdlet_obj: Any, + *, + fallback_name: Optional[str] = None, +) -> List[str]: + """Return normalized registration keys for a cmdlet object. + + Prefers the cmdlet object's own `_collect_names()` implementation when + available, then normalizes names to the registry key form used by callers. + """ + + raw_names: List[Any] = [] + + collector = getattr(cmdlet_obj, "_collect_names", None) + if callable(collector): + try: + raw_names.extend(list(collector() or [])) + except Exception: + pass + + if fallback_name: + raw_names.append(fallback_name) + + if not raw_names: + raw_name = getattr(cmdlet_obj, "name", None) + if raw_name: + raw_names.append(raw_name) + for alias_attr in ("alias", "aliases"): + alias_values = getattr(cmdlet_obj, alias_attr, None) + if not alias_values: + continue + raw_names.extend(list(alias_values)) + + seen: Set[str] = set() + normalized_names: List[str] = [] + for raw_name in raw_names: + key = str(raw_name or "").replace("_", "-").lower().strip() + if not key or key in seen: + continue + seen.add(key) + normalized_names.append(key) + return normalized_names + + class SharedArgs: """Registry of shared CmdletArg definitions used across multiple cmdlet.""" @@ -100,6 +144,13 @@ class SharedArgs: description="selects plugin", ) + INSTANCE = CmdletArg( + name="instance", + type="string", + description="Selects a plugin instance", + query_key="instance", + ) + @staticmethod def get_store_choices(config: Optional[Dict[str, Any]] = None, force: bool = False) -> List[str]: if not force and hasattr(SharedArgs, "_cached_available_stores"): diff --git a/SYS/config.py b/SYS/config.py index 638a24f..2b79882 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -95,8 +95,6 @@ def clear_config_cache() -> None: def _log_config_load_summary(config: Dict[str, Any]) -> None: try: plugin_block = config.get("plugin") - if not isinstance(plugin_block, dict): - plugin_block = config.get("provider") if isinstance(plugin_block, dict): # Count distinct plugin names; note multi-instance plugins appear once per name plugin_names = list(plugin_block.keys()) @@ -265,7 +263,9 @@ def set_nested_config_value( def get_hydrus_instance( config: Dict[str, Any], instance_name: str = "home" ) -> Optional[Dict[str, Any]]: - """Get a specific Hydrus instance config by name from plugin/provider config.""" + """Get a specific Hydrus instance config by name from plugin config.""" + _canonicalize_plugin_config(config) + def _lookup_in(source: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not isinstance(source, dict) or not source: return None @@ -286,16 +286,13 @@ def get_hydrus_instance( candidate = source.get(first_key) if first_key else None return candidate if isinstance(candidate, dict) else None - # New format: config["plugin"]["hydrusnetwork"] or config["provider"]["hydrusnetwork"] - # (both point to the same dict after normalization) - for section in ("plugin", "provider"): - section_cfg = config.get(section) - if isinstance(section_cfg, dict): - hydrus_cfg = section_cfg.get("hydrusnetwork") - if isinstance(hydrus_cfg, dict): - result = _lookup_in(hydrus_cfg) - if result is not None: - return result + plugin_cfg = config.get("plugin") + if isinstance(plugin_cfg, dict): + hydrus_cfg = plugin_cfg.get("hydrusnetwork") + if isinstance(hydrus_cfg, dict): + result = _lookup_in(hydrus_cfg) + if result is not None: + return result return None @@ -339,17 +336,17 @@ def get_hydrus_url(config: Dict[str, Any], instance_name: str = "home") -> Optio return str(url).strip() if url else None -def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]: - _normalize_plugin_config_aliases(config) - provider_cfg = config.get("provider") - if not isinstance(provider_cfg, dict): +def get_plugin_block(config: Dict[str, Any], name: str) -> Dict[str, Any]: + _canonicalize_plugin_config(config) + plugin_cfg = config.get("plugin") + if not isinstance(plugin_cfg, dict): return {} normalized = _normalize_provider_name(name) if normalized: - block = provider_cfg.get(normalized) + block = plugin_cfg.get(normalized) if isinstance(block, dict): return block - for key, block in provider_cfg.items(): + for key, block in plugin_cfg.items(): if not isinstance(block, dict): continue if _normalize_provider_name(key) == normalized: @@ -358,13 +355,13 @@ def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]: def get_soulseek_username(config: Dict[str, Any]) -> Optional[str]: - block = get_provider_block(config, "soulseek") + block = get_plugin_block(config, "soulseek") val = block.get("username") or block.get("USERNAME") return str(val).strip() if val else None def get_soulseek_password(config: Dict[str, Any]) -> Optional[str]: - block = get_provider_block(config, "soulseek") + block = get_plugin_block(config, "soulseek") val = block.get("password") or block.get("PASSWORD") return str(val).strip() if val else None @@ -415,33 +412,33 @@ def resolve_output_dir(config: Dict[str, Any]) -> Path: def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]: - """Get local storage path from config. + """Return the configured default local plugin destination path. - Supports multiple formats: - - Old: config["storage"]["local"]["path"] - - Old: config["Local"]["path"] - - Args: - config: Configuration dict - - Returns: - Path object if found, None otherwise + This helper is intentionally narrow: it reports a real local library/export + root only when the canonical `plugin.local` config defines one. Callers that + want a staging/output directory should use `resolve_output_dir(...)` instead. """ - # Fall back to storage.local.path format - storage = config.get("storage", {}) - if isinstance(storage, dict): - local_config = storage.get("local", {}) - if isinstance(local_config, dict): - path_str = local_config.get("path") - if path_str: - return expand_path(path_str) + local_block = get_plugin_block(config, "local") + if not isinstance(local_block, dict) or not local_block: + return None - # Fall back to old Local format - local_config = config.get("Local", {}) - if isinstance(local_config, dict): - path_str = local_config.get("path") - if path_str: - return expand_path(path_str) + if _is_multi_instance_plugin_config(local_block): + if "default" in local_block and isinstance(local_block.get("default"), dict): + local_config = local_block.get("default") + else: + local_config = next( + (value for value in local_block.values() if isinstance(value, dict)), + None, + ) + else: + local_config = local_block + + if not isinstance(local_config, dict): + return None + + path_str = local_config.get("path") or local_config.get("PATH") + if path_str: + return expand_path(path_str) return None @@ -449,7 +446,7 @@ def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]: def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> Optional[str]: """Get Debrid API key from config. - Checks the plugin/provider block first (canonical format). + Checks the plugin block first (canonical format). Args: config: Configuration dict @@ -458,37 +455,23 @@ def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> O Returns: API key string if found, None otherwise """ - # 1) Canonical plugin/provider block: config["plugin"]["alldebrid"]["api_key"] - provider_block = config.get("provider") or config.get("plugin") - if isinstance(provider_block, dict): - alldebrid_entry = provider_block.get("alldebrid") + _canonicalize_plugin_config(config) + + # 1) Canonical plugin block: config["plugin"]["alldebrid"]["api_key"] + plugin_block = config.get("plugin") + if isinstance(plugin_block, dict): + alldebrid_entry = plugin_block.get("alldebrid") if isinstance(alldebrid_entry, dict): for k in ("api_key", "API_KEY", "apikey", "APIKEY"): val = alldebrid_entry.get(k) if isinstance(val, str) and val.strip(): return val.strip() - # 2) Migrated legacy debrid plugin entry: config["plugin"]["debrid"]["all-debrid"]["api_key"] - if isinstance(provider_block, dict): - service_key = str(service).strip().lower() - debrid_plugin = provider_block.get("debrid") - if isinstance(debrid_plugin, dict): - entry = debrid_plugin.get(service_key) - if isinstance(entry, dict): - api_key = entry.get("api_key") - return str(api_key).strip() if api_key else None - if isinstance(entry, str): - return entry.strip() or None - return None -def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]: - """Get provider credentials (email/password) from config. - - Supports both formats: - - New: config["provider"][provider] = {"email": "...", "password": "..."} - - Old: config[provider.capitalize()] = {"email": "...", "password": "..."} +def get_plugin_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]: + """Get plugin credentials (email/password) from config. Args: config: Configuration dict @@ -497,22 +480,11 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[ Returns: Dict with credentials if found, None otherwise """ - # Try new format first - provider_config = config.get("provider", {}) - if isinstance(provider_config, dict): - creds = provider_config.get(provider.lower(), {}) - if isinstance(creds, dict) and creds: - return creds + _canonicalize_plugin_config(config) - # Fall back to old format (capitalized key) - old_key_map = { - "openlibrary": "OpenLibrary", - "archive": "Archive", - "soulseek": "Soulseek", - } - old_key = old_key_map.get(provider.lower()) - if old_key: - creds = config.get(old_key, {}) + plugin_config = config.get("plugin", {}) + if isinstance(plugin_config, dict): + creds = plugin_config.get(provider.lower(), {}) if isinstance(creds, dict) and creds: return creds @@ -522,19 +494,19 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[ def resolve_cookies_path( config: Dict[str, Any], script_dir: Optional[Path] = None ) -> Optional[Path]: - # Only support modular config style: - # [tool=ytdlp] + # Only support plugin config style: + # [plugin=ytdlp] # cookies="C:\\path\\cookies.txt" values: list[Any] = [] try: - tool = config.get("tool") - if isinstance(tool, dict): - ytdlp = tool.get("ytdlp") + plugin = config.get("plugin") + if isinstance(plugin, dict): + ytdlp = plugin.get("ytdlp") if isinstance(ytdlp, dict): values.append(ytdlp.get("cookies")) values.append(ytdlp.get("cookiefile")) except Exception as exc: - logger.debug("resolve_cookies_path: failed to read tool.ytdlp cookies: %s", exc, exc_info=True) + logger.debug("resolve_cookies_path: failed to read plugin.ytdlp cookies: %s", exc, exc_info=True) base_dir = _resolve_app_root(script_dir) for value in values: @@ -627,54 +599,26 @@ def resolve_plugin_asset_path( return None -def _normalize_plugin_config_aliases(config: Dict[str, Any]) -> None: +def _canonicalize_plugin_config(config: Dict[str, Any]) -> None: if not isinstance(config, dict): return + config.pop("provider", None) + config.pop("store", None) plugin_block = config.get("plugin") - provider_block = config.get("provider") - normalized_provider: Dict[str, Any] = {} - - if isinstance(provider_block, dict): - for key, value in provider_block.items(): - normalized_key = _normalize_provider_name(key) - if normalized_key and normalized_key not in normalized_provider: - normalized_provider[normalized_key] = value + normalized_plugin: Dict[str, Any] = {} if isinstance(plugin_block, dict): for key, value in plugin_block.items(): normalized_key = _normalize_provider_name(key) - if normalized_key and normalized_key not in normalized_provider: - normalized_provider[normalized_key] = value + if normalized_key: + normalized_plugin[normalized_key] = value - # Fold legacy config["store"] entries into the plugin namespace. - # store format: {type: {instance_name: {key: val}}} — multi-instance. - # After folding, remove config["store"] so it is no longer consulted. - store_block = config.pop("store", None) - if isinstance(store_block, dict): - for store_type, instances in store_block.items(): - if not isinstance(instances, dict): - continue - normalized_key = _normalize_provider_name(store_type) - if not normalized_key: - continue - existing = normalized_provider.get(normalized_key) - if not isinstance(existing, dict): - existing = {} - normalized_provider[normalized_key] = existing - for instance_name, settings in instances.items(): - if isinstance(settings, dict) and instance_name not in existing: - existing[instance_name] = dict(settings) - - if normalized_provider: - config["provider"] = normalized_provider - config["plugin"] = normalized_provider + if normalized_plugin or isinstance(plugin_block, dict): + config["plugin"] = normalized_plugin else: - if isinstance(provider_block, dict): - config["plugin"] = provider_block - elif isinstance(plugin_block, dict): - config["provider"] = plugin_block + config.pop("plugin", None) def _extract_api_key(value: Any) -> Optional[str]: if isinstance(value, dict): @@ -698,40 +642,24 @@ def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None: if not isinstance(config, dict): return - _normalize_plugin_config_aliases(config) - - providers = config.get("provider") - if not isinstance(providers, dict): - providers = {} - config["provider"] = providers - - provider_entry = providers.get("alldebrid") - provider_section: Dict[str, Any] | None = None - provider_key = None - if isinstance(provider_entry, dict): - provider_section = provider_entry - provider_key = _extract_api_key(provider_section) - elif isinstance(provider_entry, str): - provider_key = provider_entry.strip() - if provider_key: - provider_section = {"api_key": provider_key} - providers["alldebrid"] = provider_section - - # If no key found in provider block, check for a migrated debrid plugin entry. - # (rows_to_config migrates store.debrid.all-debrid → plugin.debrid.all-debrid) - if not provider_key: - plugin_block = config.get("plugin") or providers - debrid_plugin = plugin_block.get("debrid") if isinstance(plugin_block, dict) else None - if isinstance(debrid_plugin, dict): - service_entry = debrid_plugin.get("all-debrid") - legacy_key = _extract_api_key(service_entry) if service_entry else None - if legacy_key: - if provider_section is None: - provider_section = {} - providers["alldebrid"] = provider_section - provider_section.setdefault("api_key", legacy_key) + _canonicalize_plugin_config(config) + plugins = config.get("plugin") + if not isinstance(plugins, dict): + plugins = {} + config["plugin"] = plugins + plugin_entry = plugins.get("alldebrid") + plugin_section: Dict[str, Any] | None = None + plugin_key = None + if isinstance(plugin_entry, dict): + plugin_section = plugin_entry + plugin_key = _extract_api_key(plugin_section) + elif isinstance(plugin_entry, str): + plugin_key = plugin_entry.strip() + if plugin_key: + plugin_section = {"api_key": plugin_key} + plugins["alldebrid"] = plugin_section def _is_multi_instance_plugin_config(value: Any) -> bool: """Return True if `value` looks like a multi-instance plugin config (dict-of-dicts). @@ -754,12 +682,9 @@ def _is_multi_instance_plugin_config(value: Any) -> bool: def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]: entries: Dict[Tuple[str, str, str, str], Any] = {} - _normalize_plugin_config_aliases(config) + _canonicalize_plugin_config(config) for key, value in config.items(): - if key == 'plugin': - # plugin == provider after normalization; skip duplicate - continue - if key == 'provider' and isinstance(value, dict): + if key == 'plugin' and isinstance(value, dict): for subtype, plugin_cfg in value.items(): if not isinstance(plugin_cfg, dict): continue @@ -773,21 +698,13 @@ def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, else: # Single-instance: {key: val} for k, v in plugin_cfg.items(): - entries[('provider', subtype, 'default', k)] = v - elif key in ('store', 'tool') and isinstance(value, dict): + entries[('plugin', subtype, 'default', k)] = v + elif key == 'tool' and isinstance(value, dict): for subtype, instances in value.items(): if not isinstance(instances, dict): continue - if key == 'store': - # Legacy store: migrate to plugin category - for name, settings in instances.items(): - if not isinstance(settings, dict): - continue - for k, v in settings.items(): - entries[('plugin', subtype, name, k)] = v - else: # tool - for k, v in instances.items(): - entries[(key, subtype, 'default', k)] = v + for k, v in instances.items(): + entries[('tool', subtype, 'default', k)] = v elif not key.startswith('_') and value is not None: entries[('global', 'none', 'none', key)] = value return entries @@ -817,14 +734,6 @@ def _config_from_flattened_entries( config[key] = value continue - if category == "store": - # Legacy: migrate to plugin namespace at reconstitution time - plugin_block = config.setdefault("plugin", {}) - subtype_block = plugin_block.setdefault(subtype, {}) - item_block = subtype_block.setdefault(item_name, {}) - item_block[key] = value - continue - if category == "plugin": plugin_block = config.setdefault("plugin", {}) subtype_block = plugin_block.setdefault(subtype, {}) @@ -835,7 +744,7 @@ def _config_from_flattened_entries( item_block[key] = value continue - if category in {"provider", "tool"}: + if category == "tool": category_block = config.setdefault(category, {}) subtype_block = category_block.setdefault(subtype, {}) subtype_block[key] = value @@ -849,7 +758,7 @@ def _config_from_flattened_entries( if isinstance(item_block, dict): item_block[key] = value - _normalize_plugin_config_aliases(config) + _canonicalize_plugin_config(config) _sync_alldebrid_api_key(config) return config @@ -880,9 +789,9 @@ def _merge_non_conflicting_config_changes( def _extract_expected_alldebrid_key(config: Dict[str, Any]) -> Optional[str]: expected_key = None try: - providers = config.get("provider", {}) if isinstance(config, dict) else {} - if isinstance(providers, dict): - entry = providers.get("alldebrid") + plugins = config.get("plugin", {}) if isinstance(config, dict) else {} + if isinstance(plugins, dict): + entry = plugins.get("alldebrid") if entry is not None: if isinstance(entry, dict): for k in ("api_key", "API_KEY", "apikey", "APIKEY"): @@ -908,18 +817,10 @@ def load_config(*, emit_summary: bool = False) -> Dict[str, Any]: _CONFIG_SUMMARY_PENDING = False return _CONFIG_CACHE - # One-time DB migration: move category='store' rows to category='plugin'. - # This is idempotent — a no-op if no store rows exist. - try: - from SYS.database import migrate_store_category_to_plugin - migrate_store_category_to_plugin() - except Exception: - logger.debug("Store→plugin DB migration skipped or failed", exc_info=True) - # Load strictly from database db_config = get_config_all() if db_config: - _normalize_plugin_config_aliases(db_config) + _canonicalize_plugin_config(db_config) _sync_alldebrid_api_key(db_config) _CONFIG_CACHE = db_config _LAST_SAVED_CONFIG = deepcopy(db_config) @@ -1007,7 +908,7 @@ def _release_save_lock(lock_dir: Path) -> None: def save_config(config: Dict[str, Any]) -> int: global _CONFIG_CACHE, _LAST_SAVED_CONFIG - _normalize_plugin_config_aliases(config) + _canonicalize_plugin_config(config) _sync_alldebrid_api_key(config) # Acquire cross-process save lock to avoid concurrent saves from different @@ -1065,31 +966,39 @@ def save_config(config: Dict[str, Any]) -> int: # Proceed with writing when no conflicting external changes detected conn.execute("DELETE FROM config") for key, value in config_to_write.items(): - if key in ('store', 'provider', 'tool') and isinstance(value, dict): + if key in ('plugin', 'tool') and isinstance(value, dict): for subtype, instances in value.items(): if not isinstance(instances, dict): continue - if key == 'store': - for name, settings in instances.items(): - if isinstance(settings, dict): + if key == 'plugin': + normalized_subtype = _normalize_provider_name(subtype) + if not normalized_subtype: + continue + if _is_multi_instance_plugin_config(instances): + for name, settings in instances.items(): + if not isinstance(settings, dict): + continue for k, v in settings.items(): val_str = json.dumps(v) if not isinstance(v, str) else v conn.execute( "INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", - (key, subtype, name, k, val_str), + ("plugin", normalized_subtype, name, k, val_str), ) count += 1 + else: + for k, v in instances.items(): + val_str = json.dumps(v) if not isinstance(v, str) else v + conn.execute( + "INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", + ("plugin", normalized_subtype, "default", k, val_str), + ) + count += 1 else: - normalized_subtype = subtype - if key == 'provider': - normalized_subtype = _normalize_provider_name(subtype) - if not normalized_subtype: - continue for k, v in instances.items(): val_str = json.dumps(v) if not isinstance(v, str) else v conn.execute( "INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", - (key, normalized_subtype, "default", k, val_str), + ("tool", subtype, "default", k, val_str), ) count += 1 else: @@ -1197,30 +1106,15 @@ def save_config_and_verify(config: Dict[str, Any], retries: int = 3, delay: floa # Nothing special to verify; return success. return saved - # Reload directly from disk and compare the canonical debrid/provider keys + # Reload directly from disk and compare the canonical plugin key. clear_config_cache() reloaded = load_config() - # Provider-level key - prov_block = reloaded.get("provider", {}) if isinstance(reloaded, dict) else {} - prov_key = None - if isinstance(prov_block, dict): - aentry = prov_block.get("alldebrid") - if isinstance(aentry, dict): - for k in ("api_key", "API_KEY", "apikey", "APIKEY"): - v = aentry.get(k) - if isinstance(v, str) and v.strip(): - prov_key = v.strip() - break - elif isinstance(aentry, str) and aentry.strip(): - prov_key = aentry.strip() - - # Store-level key try: - store_key = get_debrid_api_key(reloaded, service="All-debrid") + reloaded_key = _extract_expected_alldebrid_key(reloaded) except Exception: - store_key = None + reloaded_key = None - if prov_key == expected_key or store_key == expected_key: + if reloaded_key == expected_key: try: # Log a short, masked fingerprint to aid debugging without exposing the key itself import hashlib diff --git a/SYS/database.py b/SYS/database.py index 846eb0c..29c8140 100644 --- a/SYS/database.py +++ b/SYS/database.py @@ -504,67 +504,29 @@ def rows_to_config(rows) -> Dict[str, Any]: if cat == 'global': config[key] = parsed_val else: - # Modular structure: config[cat][sub][name][key] - if cat in ('provider', 'tool'): + # Modular structure: config[category][subtype][item_name?][key] + if cat == 'plugin': + cat_dict = config.setdefault('plugin', {}) + sub_dict = cat_dict.setdefault(sub, {}) + if str(name or '').strip().lower() == 'default': + sub_dict[key] = parsed_val + else: + name_dict = sub_dict.setdefault(name, {}) + name_dict[key] = parsed_val + elif cat in ('provider', 'store'): + continue + elif cat == 'tool': cat_dict = config.setdefault(cat, {}) sub_dict = cat_dict.setdefault(sub, {}) sub_dict[key] = parsed_val - elif cat == 'store': - # Migrate legacy store rows into the unified plugin namespace. - # store config used a 4-level path: (store, type, instance_name, key). - # Plugin config uses: config["plugin"][type][instance_name][key]. - cat_dict = config.setdefault('plugin', {}) - sub_dict = cat_dict.setdefault(sub, {}) - name_dict = sub_dict.setdefault(name, {}) - name_dict[key] = parsed_val else: config.setdefault(cat, {})[key] = parsed_val return config -def migrate_store_category_to_plugin() -> int: - """One-time migration: re-key category='store' DB rows to category='plugin'. - - The 'store' category used ``(store, type, instance_name, key)`` tuples; - the unified plugin system uses the same 4-level path under category='plugin'. - Existing 'plugin' rows for the same (subtype, item_name, key) are overwritten. - - Returns the number of rows that were migrated (0 if already migrated). - """ - try: - count_row = db.fetchone( - "SELECT COUNT(*) AS n FROM config WHERE category='store' AND subtype != 'folder'" - ) - count = int(count_row['n']) if count_row else 0 - if count == 0: - # Also clean up any lingering folder-store rows - db.execute("DELETE FROM config WHERE category='store'") - with db._conn_lock: - db.conn.commit() - return 0 - # Copy store rows to plugin, replacing any pre-existing plugin rows for - # the same (subtype, item_name, key), then delete the old store rows. - db.execute( - """ - INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) - SELECT 'plugin', subtype, item_name, key, value - FROM config - WHERE category = 'store' AND subtype != 'folder' - """ - ) - db.execute("DELETE FROM config WHERE category = 'store'") - with db._conn_lock: - db.conn.commit() - logger.info("Migrated %d config rows from category='store' to category='plugin'", count) - return count - except Exception: - logger.exception("Failed to migrate store config rows to plugin category") - return 0 - - def get_config_all() -> Dict[str, Any]: - """Retrieve all configuration from the database in the legacy dict format.""" + """Retrieve all configuration from the database in the canonical plugin-centric dict format.""" rows = db.fetchall("SELECT category, subtype, item_name, key, value FROM config") return rows_to_config(rows) diff --git a/SYS/html_table.py b/SYS/html_table.py index 37daf5d..80ef37c 100644 --- a/SYS/html_table.py +++ b/SYS/html_table.py @@ -277,12 +277,12 @@ def extract_records(doc_or_html: Any, base_url: Optional[str] = None, xpaths: Op return normed, chosen -# Small convenience: convert records to SearchResult. Providers can call this or +# Small convenience: convert records to SearchResult. Plugins can call this or # use their own mapping when they need full SearchResult objects. from PluginCore.base import SearchResult # local import to avoid circular issues -def records_to_search_results(records: List[Dict[str, str]], table: str = "provider") -> List[SearchResult]: +def records_to_search_results(records: List[Dict[str, str]], table: str = "plugin") -> List[SearchResult]: out: List[SearchResult] = [] for rec in records: title = rec.get("title") or rec.get("name") or "" diff --git a/SYS/models.py b/SYS/models.py index 600a1e7..3d8f120 100644 --- a/SYS/models.py +++ b/SYS/models.py @@ -49,7 +49,7 @@ class PipeObject: hash: str store: str - provider: Optional[str] = None + plugin: Optional[str] = None tag: List[str] = field(default_factory=list) title: Optional[str] = None url: Optional[str] = None @@ -144,8 +144,8 @@ class PipeObject: "store": self.store, } - if self.provider: - data["provider"] = self.provider + if self.plugin: + data["plugin"] = self.plugin if self.tag: data["tag"] = self.tag diff --git a/SYS/optional_deps.py b/SYS/optional_deps.py index 6c04553..cf138aa 100644 --- a/SYS/optional_deps.py +++ b/SYS/optional_deps.py @@ -61,12 +61,12 @@ def florencevision_missing_modules() -> List[str]: def _provider_missing_modules(config: Dict[str, Any]) -> Dict[str, List[str]]: missing: Dict[str, List[str]] = {} - provider_cfg = (config or {}).get("provider") - if not isinstance(provider_cfg, dict): + plugin_cfg = (config or {}).get("plugin") + if not isinstance(plugin_cfg, dict): return missing for provider_name, requirements in _PROVIDER_DEPENDENCIES.items(): - block = provider_cfg.get(provider_name) + block = plugin_cfg.get(provider_name) if not isinstance(block, dict) or not block: continue missing_for_provider = [ diff --git a/SYS/pipe_object.py b/SYS/pipe_object.py index 2b3da3a..ec01523 100644 --- a/SYS/pipe_object.py +++ b/SYS/pipe_object.py @@ -191,11 +191,11 @@ def coerce_to_pipe_object( pipe_obj = models.PipeObject( hash=hash_val, store=store_val, - provider=str( - value.get("provider") + plugin=str( + value.get("plugin") or value.get("prov") or value.get("source") - or extra.get("provider") + or extra.get("plugin") or extra.get("source") or "" ).strip() @@ -253,7 +253,7 @@ def coerce_to_pipe_object( pipe_obj = models.PipeObject( hash=hash_val, store=store_val, - provider=None, + plugin=None, path=str(path_val) if path_val and path_val != "unknown" else None, title=title_val, url=url_val, diff --git a/SYS/pipeline.py b/SYS/pipeline.py index d6c8cbc..82539ec 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -1545,19 +1545,16 @@ class PipelineExecutor: table_meta = meta if isinstance(meta, dict) else None if isinstance(meta, dict): _add(meta.get("plugin")) - _add(meta.get("provider")) except Exception: logger.exception("Failed to inspect current_table/table metadata in _maybe_run_class_selector") for item in selected_items or []: if isinstance(item, dict): _add(item.get("plugin")) - _add(item.get("provider")) _add(item.get("store")) _add(item.get("table")) else: _add(getattr(item, "plugin", None)) - _add(getattr(item, "provider", None)) _add(getattr(item, "store", None)) _add(getattr(item, "table", None)) @@ -1664,17 +1661,14 @@ class PipelineExecutor: meta = None if isinstance(meta, dict): _add(meta.get("plugin")) - _add(meta.get("provider")) for item in selected_items or []: if isinstance(item, dict): _add(item.get("plugin")) - _add(item.get("provider")) _add(item.get("table")) _add(item.get("source")) else: _add(getattr(item, "plugin", None)) - _add(getattr(item, "provider", None)) _add(getattr(item, "table", None)) _add(getattr(item, "source", None)) @@ -3129,20 +3123,10 @@ class PipelineExecutor: mod = import_cmd_module(cmd_name, reload_loaded=True) data = getattr(mod, "CMDLET", None) if mod else None if data and hasattr(data, "exec") and callable(getattr(data, "exec")): + from SYS.cmdlet_spec import collect_registered_cmdlet_names + run_fn = getattr(data, "exec") - registered_names = set() - raw_name = getattr(data, "name", None) - if raw_name: - registered_names.add(str(raw_name).replace("_", "-").lower()) - registered_names.add(str(cmd_name).replace("_", "-").lower()) - for alias_attr in ("alias", "aliases"): - alias_values = getattr(data, alias_attr, None) - if alias_values: - for alias in alias_values: - alias_text = str(alias or "").replace("_", "-").lower().strip() - if alias_text: - registered_names.add(alias_text) - for registered_name in registered_names: + for registered_name in collect_registered_cmdlet_names(data, fallback_name=cmd_name): REGISTRY[registered_name] = run_fn cmd_fn = run_fn except Exception: diff --git a/SYS/plugin_config.py b/SYS/plugin_config.py index 84652ce..2cd52f0 100644 --- a/SYS/plugin_config.py +++ b/SYS/plugin_config.py @@ -14,6 +14,36 @@ logger = logging.getLogger(__name__) ConfigField = Dict[str, Any] +def _import_plugin_support_module(plugin_name: str) -> Optional[Any]: + normalized = str(plugin_name or "").strip() + if not normalized: + return None + try: + return importlib.import_module(f"plugins.{normalized}") + except Exception: + return None + + +def _iter_plugin_module_names() -> List[str]: + names: List[str] = [] + try: + import plugins as plugin_package + except Exception: + logger.exception("Failed to import plugins package for config discovery") + return names + + package_path = getattr(plugin_package, "__path__", None) + if not package_path: + return names + + for module_info in pkgutil.iter_modules(package_path): + name = str(module_info.name or "").strip() + if not name or name.startswith("_"): + continue + names.append(name) + return names + + def _normalize_schema(fields: Optional[Iterable[Any]]) -> List[ConfigField]: normalized: List[ConfigField] = [] seen: set[str] = set() @@ -55,48 +85,38 @@ def _call_schema(owner: Any, label: str) -> List[ConfigField]: def get_store_schema(store_type: str) -> List[ConfigField]: """Return config schema for a store type. - After the store→plugin migration, store types are plugins. We look up the - plugin schema by name; if not found we return an empty list. + Store types are now plugins. We look up the plugin schema by name; if not + found we return an empty list. """ - normalized = str(store_type or "").strip() - # Strip a legacy "store-" prefix so callers using the old type name still work - if normalized.startswith("store-"): - normalized = normalized[len("store-"):] - return get_plugin_schema(normalized) + return get_plugin_schema(str(store_type or "").strip()) def get_plugin_schema(plugin_name: str) -> List[ConfigField]: - plugin_class = get_plugin_class(str(plugin_name or "").strip()) - if plugin_class is None: + normalized_name = str(plugin_name or "").strip() + if not normalized_name: return [] - return _call_schema(plugin_class, f"plugin '{plugin_name}'") + plugin_class = get_plugin_class(normalized_name) + if plugin_class is not None: + schema = _call_schema(plugin_class, f"plugin '{normalized_name}'") + if schema: + return schema -def get_tool_schema(tool_name: str) -> List[ConfigField]: - tool_name = str(tool_name or "").strip() - if not tool_name: + module = _import_plugin_support_module(normalized_name) + if module is None: return [] - try: - module = importlib.import_module(f"tool.{tool_name}") - except Exception: - logger.exception("Failed to import tool module 'tool.%s'", tool_name) - return [] - return _call_schema(module, f"tool '{tool_name}'") + return _call_schema(module, f"plugin support '{normalized_name}'") def get_item_schema(item_type: str, item_name: str) -> List[ConfigField]: normalized_type = str(item_type or "").strip() normalized_name = str(item_name or "").strip() - if normalized_type.startswith("store-"): - return get_store_schema(normalized_type.replace("store-", "", 1)) if normalized_type.startswith("plugin-"): # Multi-instance plugin: plugin-{ptype}; item_name is the instance name ptype = normalized_type[len("plugin-"):] return get_plugin_schema(ptype) - if normalized_type in {"provider", "plugin"}: + if normalized_type == "plugin": return get_plugin_schema(normalized_name) - if normalized_type == "tool": - return get_tool_schema(normalized_name) return [] @@ -143,13 +163,6 @@ def build_default_plugin_config(plugin_name: str) -> Dict[str, Any]: return config -def build_default_tool_config(tool_name: str) -> Dict[str, Any]: - config: Dict[str, Any] = {} - for field in get_tool_schema(tool_name): - config[field["key"]] = field.get("default", "") - return config - - def get_required_config_keys(item_type: str, item_name: str) -> List[str]: normalized_type = str(item_type or "").strip() normalized_name = str(item_name or "").strip() @@ -170,9 +183,9 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]: if field.get("required"): _add_key(field.get("key")) - if normalized_type.startswith("plugin-") or normalized_type.startswith("store-"): - # Multi-instance plugin (plugin-{ptype}) or legacy store-{type}: look up by plugin name - ptype = normalized_type.replace("plugin-", "", 1).replace("store-", "", 1) + if normalized_type.startswith("plugin-"): + # Multi-instance plugin (plugin-{ptype}): look up by plugin name. + ptype = normalized_type.replace("plugin-", "", 1) plugin_class = get_plugin_class(ptype) if plugin_class is not None: try: @@ -180,7 +193,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]: _add_key(required_key) except Exception: logger.exception("Failed to load required config keys for plugin '%s'", ptype) - elif normalized_type in {"provider", "plugin"}: + elif normalized_type == "plugin": plugin_class = get_plugin_class(normalized_name) if plugin_class is not None: try: @@ -211,20 +224,7 @@ def get_configurable_plugin_types() -> List[str]: plugin_cls = info.plugin_class if get_plugin_schema(info.canonical_name) or getattr(plugin_cls, 'MULTI_INSTANCE', False): options.append(info.canonical_name) - return sorted(set(options)) - - -def get_configurable_tool_types() -> List[str]: - options: List[str] = [] - try: - import tool as tool_package - - for module_info in pkgutil.iter_modules(tool_package.__path__): - tool_name = str(module_info.name or "").strip() - if not tool_name: - continue - if get_tool_schema(tool_name): - options.append(tool_name) - except Exception: - logger.exception("Failed to discover configurable tool modules") + for module_name in _iter_plugin_module_names(): + if get_plugin_schema(module_name): + options.append(module_name) return sorted(set(options)) \ No newline at end of file diff --git a/SYS/result_table_adapters.py b/SYS/result_table_adapters.py index 2ce17df..1f69888 100644 --- a/SYS/result_table_adapters.py +++ b/SYS/result_table_adapters.py @@ -51,7 +51,7 @@ class Plugin: raise RuntimeError(f"plugin '{self.name}' adapter failed") from exc cols = self.get_columns(rows) - return ResultTable(provider=self.name, rows=rows, columns=cols, meta=self.metadata or {}) + return ResultTable(plugin=self.name, rows=rows, columns=cols, meta=self.metadata or {}) def serialize_row(self, row: ResultModel) -> Dict[str, Any]: r = ensure_result_model(row) diff --git a/SYS/result_table_api.py b/SYS/result_table_api.py index a4eb992..36e4c33 100644 --- a/SYS/result_table_api.py +++ b/SYS/result_table_api.py @@ -35,21 +35,21 @@ class ResultModel: @dataclass(frozen=True) class ResultTable: - """Concrete, provider-owned table of rows/columns. + """Concrete, plugin-owned table of rows/columns. This is intentionally minimal: it only stores rows, column specs, and optional metadata used by renderers. It does not auto-normalize legacy objects or infer columns. """ - provider: str + plugin: str rows: List[ResultModel] columns: List[ColumnSpec] meta: Dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - if not str(self.provider or "").strip(): - raise ValueError("provider required for ResultTable") + if not str(self.plugin or "").strip(): + raise ValueError("plugin required for ResultTable") object.__setattr__(self, "rows", [ensure_result_model(r) for r in self.rows]) if not self.columns: raise ValueError("columns are required for ResultTable") @@ -70,7 +70,7 @@ class ResultTable: "ext": r.ext, "size_bytes": r.size_bytes, "metadata": r.metadata or {}, - "source": r.source or self.provider, + "source": r.source or self.plugin, "_selection_args": list(selection or []), } diff --git a/SYS/utils.py b/SYS/utils.py index 3feb800..c7e79db 100644 --- a/SYS/utils.py +++ b/SYS/utils.py @@ -516,12 +516,12 @@ def extract_link(result: Any, args: Iterable[str]) -> Any | None: def get_api_key(config: dict[str, Any], service: str, key_path: str) -> str | None: - """Get API key from config with fallback support. + """Get API key from a dot-notation config path. Args: config: Configuration dictionary service: Service name for logging - key_path: Dot-notation path to key (e.g., "Debrid.All-debrid") + key_path: Dot-notation path to key (e.g., "plugin.alldebrid.api_key") Returns: API key if found and not empty, None otherwise diff --git a/SYS/worker_manager.py b/SYS/worker_manager.py index e1e4de2..3bcbb98 100644 --- a/SYS/worker_manager.py +++ b/SYS/worker_manager.py @@ -81,7 +81,7 @@ class Worker: """ try: if self.manager: - self.manager.append_worker_stdout(self.id, text) + self.manager.append_stdout(self.id, text) else: self._stdout_buffer.append(text) except Exception as e: @@ -232,7 +232,7 @@ class WorkerLoggingHandler(logging.StreamHandler): log_text = "\n".join(self.buffer) try: if self.manager: - self.manager.append_worker_stdout( + self.manager.append_stdout( self.worker_id, log_text, channel="log" @@ -872,15 +872,6 @@ class WorkerManager: logger.error(f"[WorkerManager] Error getting stdout: {e}", exc_info=True) return "" - def append_worker_stdout( - self, - worker_id: str, - text: str, - channel: str = "stdout" - ) -> bool: - """Compatibility wrapper for append_stdout.""" - return self.append_stdout(worker_id, text, channel=channel) - def clear_stdout(self, worker_id: str) -> bool: """Clear stdout logs for a worker. diff --git a/TUI.py b/TUI.py index b2dfef9..1dcfd6a 100644 --- a/TUI.py +++ b/TUI.py @@ -595,7 +595,7 @@ class PipelineHubApp(App): # Initialize the store choices cache at startup (filters disabled stores) try: - from cmdlet._shared import SharedArgs + from SYS.cmdlet_spec import SharedArgs config = load_config() SharedArgs._refresh_store_choices_cache(config) except Exception: @@ -617,13 +617,9 @@ class PipelineHubApp(App): try: cfg = load_config() or {} plugin_block = cfg.get("plugin") - if not isinstance(plugin_block, dict): - plugin_block = cfg.get("provider") provs = list(plugin_block.keys()) if isinstance(plugin_block, dict) else [] - stores = list(cfg.get("store", {}).keys()) if isinstance(cfg.get("store"), dict) else [] prov_display = ", ".join(provs[:10]) + ("..." if len(provs) > 10 else "") - store_display = ", ".join(stores[:10]) + ("..." if len(stores) > 10 else "") - self._append_log_line(f"Startup config: plugins={len(provs)} ({prov_display or '(none)'}), stores={len(stores)} ({store_display or '(none)'}), db={db.db_path.name}") + self._append_log_line(f"Startup config: plugins={len(provs)} ({prov_display or '(none)'}), db={db.db_path.name}") except Exception: logger.exception("Failed to produce startup config summary") @@ -836,7 +832,7 @@ class PipelineHubApp(App): """Call when the config modal is dismissed to reload session data.""" try: from SYS.config import load_config, clear_config_cache - from cmdlet._shared import SharedArgs + from SYS.cmdlet_spec import SharedArgs # Force a fresh load from disk clear_config_cache() cfg = load_config() diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index 300faec..3f3300b 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -17,7 +17,6 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from SYS.logger import log, debug, debug_panel from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple -from dataclasses import dataclass, field from SYS import models from SYS import pipeline as pipeline_context from SYS.item_accessors import get_field as _item_accessor_get_field @@ -25,533 +24,11 @@ from SYS.payload_builders import build_file_result_payload, build_table_result_p from SYS.result_publication import publish_result_table from SYS.result_table import Table from SYS.rich_display import stderr_console as get_stderr_console +from SYS.cmdlet_spec import Cmdlet, CmdletArg, QueryArg, SharedArgs, parse_cmdlet_args from rich.prompt import Confirm from contextlib import AbstractContextManager, nullcontext -@dataclass -class CmdletArg: - """Represents a single cmdlet argument with optional enum choices.""" - - name: str - """Argument name, e.g., '-path' or 'location'""" - type: str = "string" - """Argument type: 'string', 'int', 'flag', 'enum', etc.""" - required: bool = False - """Whether this argument is required""" - - description: str = "" - """Human-readable description of the argument""" - choices: List[str] = field(default_factory=list) - """Optional list of valid choices for enum/autocomplete, e.g., ['hydrus', 'local', '0x0.st']""" - alias: str = "" - """Optional alias for the argument name, e.g., 'loc' for 'location'""" - handler: Optional[Any] = None - """Optional handler function/callable for processing this argument's value""" - variadic: bool = False - """Whether this argument accepts multiple values (consumes remaining positional args)""" - usage: str = "" - """dsf""" - requires_db: bool = False - """Whether this argument requires the local DB/library root to be configured.""" - - # Query-mapping support: - # Some cmdlets use a unified `-query` string. When configured, individual args - # can be populated from fields inside `-query` (e.g., -query "hash:"). - query_key: Optional[str] = None - """Field name inside -query that maps to this argument (e.g., 'hash').""" - query_aliases: List[str] = field(default_factory=list) - """Additional field names inside -query that map to this argument.""" - query_only: bool = False - """When True, do not accept a dedicated CLI flag for this arg; only map from -query.""" - - def resolve(self, value: Any) -> Any: - """Resolve/process the argument value using the handler if available. - - Args: - value: The raw argument value to process - - Returns: - Processed value from handler, or original value if no handler - - Example: - # For STORAGE arg with a handler - storage_path = SharedArgs.STORAGE.resolve('local') # Returns Path(tempfile.gettempdir()) - """ - if self.handler is not None and callable(self.handler): - return self.handler(value) - return value - - def to_flags(self) -> tuple[str, ...]: - """Generate all flag variants (short and long form) for this argument. - - Returns a tuple of all valid flag forms for this argument, including: - - Long form with double dash: --name - - Single dash multi-char form: -name (for convenience) - - Short form with single dash: -alias (if alias exists) - - For flags, also generates negation forms: - - --no-name, -name (negation of multi-char form) - - --no-name, -nalias (negation with alias) - - Returns: - Tuple of flag strings, e.g., ('--archive', '-archive', '-arch') - or for flags: ('--archive', '-archive', '-arch', '--no-archive', '-narch') - - Example: - archive_flags = SharedArgs.ARCHIVE.to_flags() - # Returns: ('--archive', '-archive', '-arch', '--no-archive', '-narch') - - storage_flags = SharedArgs.STORAGE.to_flags() - # Returns: ('--storage', '-storage', '-s') - """ - normalized_name = str(self.name or "").lstrip("-") - if not normalized_name: - return tuple() - - flags = [ - f"--{normalized_name}", - f"-{normalized_name}" - ] # Both double-dash and single-dash variants - - # Add short form if alias exists - if self.alias: - flags.append(f"-{self.alias}") - - # Add negation forms for flag type - if self.type == "flag": - flags.append(f"--no-{normalized_name}") - flags.append(f"-no{normalized_name}") # Single-dash negation variant - if self.alias: - flags.append(f"-n{self.alias}") - - return tuple(flags) - - -def QueryArg( - name: str, - *, - key: Optional[str] = None, - aliases: Optional[Sequence[str]] = None, - type: str = "string", - required: bool = False, - description: str = "", - choices: Optional[Sequence[str]] = None, - handler: Optional[Any] = None, - query_only: bool = True, -) -> CmdletArg: - """Create an argument that can be populated from `-query` fields. - - By default, this does NOT create a dedicated flag (query_only=True). This is - useful for deprecating bloat flags like `-hash` while still making `hash:` a - first-class, documented, reusable field. - """ - return CmdletArg( - name=str(name), - type=str(type or "string"), - required=bool(required), - description=str(description or ""), - choices=list(choices or []), - handler=handler, - query_key=str(key or name).strip().lower() - if str(key or name).strip() else None, - query_aliases=[ - str(a).strip().lower() for a in (aliases or []) if str(a).strip() - ], - query_only=bool(query_only), - ) - - -# ============================================================================ -# SHARED ARGUMENTS - Reusable argument definitions across cmdlet -# ============================================================================ - - -class SharedArgs: - """Registry of shared CmdletArg definitions used across multiple cmdlet. - - This class provides a centralized location for common arguments so they're - defined once and used consistently everywhere. Reduces duplication and ensures - all cmdlet handle the same arguments identically. - - Example: - CMDLET = Cmdlet( - name="my-cmdlet", - summary="Does something", - usage="my-cmdlet", - args=[ - SharedArgs.QUERY, # Use predefined shared arg (e.g., -query "hash:") - SharedArgs.LOCATION, # Use another shared arg - CmdletArg(...), # Mix with custom args - ] - ) - """ - - # NOTE: This project no longer exposes a dedicated -hash flag. - # Use SharedArgs.QUERY with `hash:` syntax instead (e.g., -query "hash:"). - - STORE = CmdletArg( - name="store", - type="enum", - choices=[], # Dynamically populated via get_store_choices() - description="Selects a storage backend", - query_key="store", - ) - - INSTANCE = CmdletArg( - name="instance", - type="string", - description="Selects a plugin instance", - query_key="instance", - ) - - URL = CmdletArg( - name="url", - type="string", - description="http parser", - ) - PLUGIN = CmdletArg( - name="plugin", - type="string", - description="selects plugin", - ) - - @staticmethod - def get_store_choices(config: Optional[Dict[str, Any]] = None, force: bool = False) -> List[str]: - """Get list of available store backend names. - - This method returns the cached list of available backends from the most - recent startup check. Stores that failed to initialize are filtered out. - Users must restart to refresh the list if stores are enabled/disabled. - - Args: - config: Optional config dict. Used if force=True or no cache exists. - force: If True, force a fresh check of the backends. - - Returns: - List of backend names (e.g., ['default', 'test', 'home', 'work']) - Only includes backends that successfully initialized at startup. - - Example: - SharedArgs.INSTANCE.choices = SharedArgs.get_store_choices(config) - """ - # Use the cached startup check result if available (unless force=True) - if not force and hasattr(SharedArgs, "_cached_available_stores"): - return SharedArgs._cached_available_stores or [] - - # Autocomplete and shared arg choices must only expose backends that actually - # initialized successfully. Do a full refresh when the cache is missing. - SharedArgs._refresh_store_choices_cache(config, skip_instantiation=False) - return SharedArgs._cached_available_stores or [] - - @staticmethod - def _refresh_store_choices_cache(config: Optional[Dict[str, Any]] = None, skip_instantiation: bool = False) -> None: - """Refresh the cached store choices list. Should be called once at startup. - - Store choices are user-facing and should only include backends that actually - initialized successfully. When `skip_instantiation` is True, this method keeps - the cache empty rather than surfacing configured-but-disabled store names. - - Args: - config: Config dict. If not provided, will try to load from config module. - skip_instantiation: When True, do not instantiate backend classes; use a lightweight list only. - """ - try: - if config is None: - try: - from SYS.config import load_config - config = load_config(emit_summary=False) - except Exception: - SharedArgs._cached_available_stores = [] - return - - SharedArgs._cached_available_stores = [] - - # If caller requested a lightweight pass, avoid exposing configured names - # that may be disabled or unavailable. - if skip_instantiation: - return - - names: set[str] = set() - - # Plugin-based multi-instance backends (config["plugin"] / config["provider"] sections) - try: - from PluginCore.registry import REGISTRY - plugin_instances = REGISTRY.list_storage_plugin_instances(config) - for _plugin_name, instance_names in plugin_instances.items(): - names.update(instance_names) - except Exception: - pass - - if names: - SharedArgs._cached_available_stores = sorted(names) - except Exception: - SharedArgs._cached_available_stores = [] - - LOCATION = CmdletArg( - "location", - type="enum", - choices=["hydrus", - "0x0"], - required=True, - description="Destination location", - ) - - DELETE = CmdletArg( - "delete", - type="flag", - description="Delete the file after successful operation.", - ) - - # Metadata arguments - ARTIST = CmdletArg( - "artist", - type="string", - description="Filter by artist name (case-insensitive, partial match).", - ) - - ALBUM = CmdletArg( - "album", - type="string", - description="Filter by album name (case-insensitive, partial match).", - ) - - TRACK = CmdletArg( - "track", - type="string", - description="Filter by track title (case-insensitive, partial match).", - ) - - # Library/Search arguments - LIBRARY = CmdletArg( - "library", - type="string", - choices=["hydrus", - "local", - "soulseek", - "libgen", - "ftp"], - description="Search library or source location.", - ) - - TIMEOUT = CmdletArg( - "timeout", - type="integer", - description="Search or operation timeout in seconds." - ) - - LIMIT = CmdletArg( - "limit", - type="integer", - description="Maximum number of results to return." - ) - - # Path/File arguments - PATH = CmdletArg("path", type="string", description="File or directory path.") - - - # Generic arguments - QUERY = CmdletArg( - "query", - type="string", - description="Unified query string (e.g., hash:, hash:{

,

}).", - ) - - REASON = CmdletArg( - "reason", - type="string", - description="Reason or explanation for the operation." - ) - - ARCHIVE = CmdletArg( - "archive", - type="flag", - description= - "Archive the URL to Wayback Machine, Archive.today, and Archive.ph (requires URL argument in cmdlet).", - alias="arch", - ) - - @staticmethod - def resolve_storage( - storage_value: Optional[str], - default: Optional[Path] = None - ) -> Path: - """Resolve a storage location name to a filesystem Path. - - Maps storage identifiers to their actual filesystem paths. - This project has been refactored to use system temporary directories - for all staging/downloads by default. - - Args: - storage_value: One of 'hydrus', 'local', 'ftp', or None (currently unified to temp) - default: Path to return if storage_value is None (defaults to temp directory) - - Returns: - Resolved Path object for the storage location (typically system temp) - - Example: - # In a cmdlet: - storage_path = SharedArgs.resolve_storage(parsed.get('storage')) - # Returns Path(tempfile.gettempdir()) - """ - # We no longer maintain a hardcoded map for 'hydrus' (~/.hydrus) or 'local' (~/Videos). - # Everything defaults to the system temp directory unless a specific default is provided. - # This ensures environment independence. - if default is not None: - return default - - return Path(tempfile.gettempdir()) - - @classmethod - def get(cls, name: str) -> Optional[CmdletArg]: - """Get a shared argument by name. - - Args: - name: Uppercase name like 'HASH', 'LOCATION', etc. - - Returns: - CmdletArg if found, None otherwise - - Example: - arg = SharedArgs.get('QUERY') # Returns SharedArgs.QUERY - """ - try: - return getattr(cls, name.upper()) - except AttributeError: - return None - - -@dataclass -class Cmdlet: - """Represents a cmdlet with metadata and arguments. - - Example: - cmd = Cmdlet( - name="add-file", - summary="Upload a media file", - usage="add-file ", - aliases=["add-file-alias"], - args=[ - CmdletArg("location", required=True, description="Destination location"), - CmdletArg("-delete", type="flag", description="Delete after upload"), - ], - details=[ - "- This is a detail line", - "- Another detail", - ] - ) - - # Access properties - log(cmd.name) # "add-file" - log(cmd.summary) # "Upload a media file" - log(cmd.args[0].name) # "location" - """ - - name: str - """""" - summary: str - """One-line summary of the cmdlet""" - usage: str - """Usage string, e.g., 'add-file [-delete]'""" - alias: List[str] = field(default_factory=list) - """List of aliases for this cmdlet, e.g., ['add', 'add-f']""" - arg: List[CmdletArg] = field(default_factory=list) - """List of arguments accepted by this cmdlet""" - detail: List[str] = field(default_factory=list) - """Detailed explanation lines (for help text)""" - examples: List[str] = field(default_factory=list) - """Example invocations shown in `.help`.""" - # Execution function: func(result, args, config) -> int - exec: Optional[Callable[[Any, - Sequence[str], - Dict[str, - Any]], - int]] = field(default=None) - - def _collect_names(self) -> List[str]: - """Collect primary name plus aliases, de-duplicated and normalized.""" - names: List[str] = [] - if self.name: - names.append(self.name) - for alias in self.alias or []: - if alias: - names.append(alias) - for alias in getattr(self, "aliases", None) or []: - if alias: - names.append(alias) - - seen: Set[str] = set() - deduped: List[str] = [] - for name in names: - key = name.replace("_", "-").lower() - if key in seen: - continue - seen.add(key) - deduped.append(name) - return deduped - - def register(self) -> "Cmdlet": - """Register this cmdlet's exec under its name and aliases.""" - if not callable(self.exec): - return self - try: - from . import ( - register_callable as _register_callable, - ) # Local import to avoid circular import cost - except Exception: - return self - - names = self._collect_names() - if not names: - return self - - _register_callable(names, self.exec) - return self - - def get_flags(self, arg_name: str) -> set[str]: - """Generate -name and --name flag variants for an argument. - - Args: - arg_name: The argument name (e.g., 'library', 'tag', 'size') - - Returns: - Set containing both single-dash and double-dash variants - (e.g., {'-library', '--library'}) - - Example: - if low in cmdlet.get_flags('library'): - # handle library flag - """ - return {f"-{arg_name}", - f"--{arg_name}"} - - def build_flag_registry(self) -> Dict[str, set[str]]: - """Build a registry of all flag variants for this cmdlet's arguments. - - Automatically generates all -name and --name variants for each argument. - Useful for parsing command-line arguments without hardcoding flags. - - Returns: - Dict mapping argument names to their flag sets - (e.g., {'library': {'-library', '--library'}, 'tag': {'-tag', '--tag'}}) - - Example: - flags = cmdlet.build_flag_registry() - - if low in flags.get('library', set()): - # handle library - elif low in flags.get('tag', set()): - # handle tag - """ - registry: Dict[str, set[str]] = {} - for arg in self.arg: - try: - registry[arg.name] = {str(flag).lower() for flag in arg.to_flags()} - except Exception: - registry[arg.name] = {flag.lower() for flag in self.get_flags(arg.name)} - return registry - - # Tag groups cache (loaded from JSON config file) _TAG_GROUPS_CACHE: Optional[Dict[str, List[str]]] = None _TAG_GROUPS_MTIME: Optional[float] = None @@ -566,240 +43,6 @@ def set_tag_groups_path(path: Path) -> None: TAG_GROUPS_PATH = path -def parse_cmdlet_args(args: Sequence[str], - cmdlet_spec: Dict[str, - Any] | Cmdlet) -> Dict[str, - Any]: - """Parse command-line arguments based on cmdlet specification. - - Extracts argument values from command-line tokens using the argument names - and types defined in the cmdlet metadata. Automatically supports single-dash - and double-dash variants of flag names. Arguments without dashes in definition - are treated as positional arguments. - - Args: - args: Command-line arguments (e.g., ["-path", "/home/file.txt", "-foo", "bar"]) - cmdlet_spec: Cmdlet metadata dict with "args" key containing list of arg specs, - or a Cmdlet object. Each arg spec should have at least "name" key. - Argument names can be defined with or without prefixes. - - Returns: - Dict mapping canonical arg names to their parsed values. If an arg is not - provided, it will not be in the dict. Lookup will normalize prefixes. - - Example: - cmdlet = { - "args": [ - {"name": "path", "type": "string"}, # Positional - matches bare value or -path/--path - {"name": "count", "type": "int"} # Positional - matches bare value or -count/--count - ] - } - result = parse_cmdlet_args(["value1", "-count", "5"], cmdlet) - # result = {"path": "value1", "count": "5"} - """ - try: - from SYS.cmdlet_spec import parse_cmdlet_args as _parse_cmdlet_args_fast - - return _parse_cmdlet_args_fast(args, cmdlet_spec) - except Exception: - # Fall back to local implementation below to preserve behavior if the - # lightweight parser is unavailable. - pass - - result: Dict[str, - Any] = {} - - # Only accept Cmdlet objects - if not isinstance(cmdlet_spec, Cmdlet): - raise TypeError(f"Expected Cmdlet, got {type(cmdlet_spec).__name__}") - - # Build arg specs from cmdlet - arg_specs: List[CmdletArg] = cmdlet_spec.arg - positional_args: List[CmdletArg] = [] # args without prefix in definition - flagged_args: List[CmdletArg] = [] # args with prefix in definition - query_mapped_args: List[CmdletArg] = [] - - arg_spec_map: Dict[str, - str] = {} # prefix variant -> canonical name (without prefix) - - for spec in arg_specs: - name = spec.name - if not name: - continue - - # Track args that can be populated from -query. - try: - if getattr(spec, "query_key", None): - query_mapped_args.append(spec) - except Exception: - pass - - name_str = str(name) - canonical_name = name_str.lstrip("-") - - # Query-only args do not register dedicated flags/positionals. - try: - if bool(getattr(spec, "query_only", False)): - continue - except Exception: - pass - - # Determine if this is positional (no dashes in original definition) - if "-" not in name_str: - positional_args.append(spec) - else: - flagged_args.append(spec) - - # Register all supported flag variants, including legacy aliases. - arg_spec_map[canonical_name.lower()] = canonical_name # bare canonical name - try: - for flag in spec.to_flags(): - arg_spec_map[str(flag).lower()] = canonical_name - except Exception: - arg_spec_map[f"-{canonical_name}".lower()] = canonical_name - arg_spec_map[f"--{canonical_name}".lower()] = canonical_name - - # Parse arguments - i = 0 - positional_index = 0 # Track which positional arg we're on - - while i < len(args): - token = str(args[i]) - token_lower = token.lower() - - # Legacy guidance: -hash/--hash was removed in favor of -query "hash:...". - # However, some cmdlets may explicitly re-introduce a -hash flag. - if token_lower in {"-hash", - "--hash"} and token_lower not in arg_spec_map: - try: - log( - 'Legacy flag -hash is no longer supported. Use: -query "hash:"', - file=sys.stderr, - ) - except Exception: - pass - i += 1 - continue - - # Check if this token is a known flagged argument - if token_lower in arg_spec_map: - canonical_name = arg_spec_map[token_lower] - spec = next( - ( - s for s in arg_specs - if str(s.name).lstrip("-").lower() == canonical_name.lower() - ), - None, - ) - - # Check if it's a flag type (which doesn't consume next value, just marks presence) - is_flag = spec and spec.type == "flag" - - if is_flag: - # For flags, just mark presence without consuming next token - result[canonical_name] = True - i += 1 - else: - # For non-flags, consume next token as the value - if i + 1 < len(args) and not str(args[i + 1]).startswith("-"): - value = args[i + 1] - - # Check if variadic - is_variadic = spec and spec.variadic - if is_variadic: - if canonical_name not in result: - result[canonical_name] = [] - elif not isinstance(result[canonical_name], list): - result[canonical_name] = [result[canonical_name]] - result[canonical_name].append(value) - else: - result[canonical_name] = value - i += 2 - else: - i += 1 - # Otherwise treat as positional if we have positional args remaining - elif positional_index < len(positional_args): - positional_spec = positional_args[positional_index] - canonical_name = str(positional_spec.name).lstrip("-") - is_variadic = positional_spec.variadic - - if is_variadic: - # For variadic args, append to a list - if canonical_name not in result: - result[canonical_name] = [] - elif not isinstance(result[canonical_name], list): - # Should not happen if logic is correct, but safety check - result[canonical_name] = [result[canonical_name]] - - result[canonical_name].append(token) - # Do not increment positional_index so subsequent tokens also match this arg - # Note: Variadic args should typically be the last positional argument - i += 1 - else: - result[canonical_name] = token - positional_index += 1 - i += 1 - else: - # Unknown token, skip it - i += 1 - - # Populate query-mapped args from the unified -query string. - try: - raw_query = result.get("query") - except Exception: - raw_query = None - - if query_mapped_args and raw_query is not None: - try: - from SYS.cli_syntax import parse_query as _parse_query - - parsed_query = _parse_query(str(raw_query)) - fields = parsed_query.get("fields", - {}) if isinstance(parsed_query, - dict) else {} - norm_fields = ( - { - str(k).strip().lower(): v - for k, v in fields.items() - } if isinstance(fields, - dict) else {} - ) - except Exception: - norm_fields = {} - - for spec in query_mapped_args: - canonical_name = str(getattr(spec, "name", "") or "").lstrip("-") - if not canonical_name: - continue - # Do not override explicit flags. - if canonical_name in result and result.get(canonical_name) not in (None, - ""): - continue - try: - key = str(getattr(spec, "query_key", "") or "").strip().lower() - aliases = getattr(spec, "query_aliases", None) - alias_list = [ - str(a).strip().lower() for a in (aliases or []) if str(a).strip() - ] - except Exception: - key = "" - alias_list = [] - candidates = [k for k in [key, canonical_name] + alias_list if k] - val = None - for k in candidates: - if k in norm_fields: - val = norm_fields.get(k) - break - if val is None: - continue - try: - result[canonical_name] = spec.resolve(val) - except Exception: - result[canonical_name] = val - - return result - - def resolve_target_dir( parsed: Dict[str, Any], config: Dict[str, Any], @@ -3011,20 +2254,6 @@ def collapse_namespace_tags( kept_ns = True result.append(text) return result - - -def collapse_namespace_tag( - tags: Optional[Iterable[Any]], - namespace: str, - prefer: str = "last" -) -> list[str]: - """Singular alias for collapse_namespace_tags. - - Some cmdlet prefer the singular name; keep behavior centralized. - """ - return collapse_namespace_tags(tags, namespace, prefer=prefer) - - def extract_tag_from_result(result: Any) -> list[str]: """Extract all tags from a result dict or PipeObject. @@ -3395,11 +2624,11 @@ def coerce_to_pipe_object( pipe_obj = models.PipeObject( hash=hash_val, store=store_val, - provider=str( - value.get("provider") + plugin=str( + value.get("plugin") or value.get("prov") or value.get("source") - or extra.get("provider") + or extra.get("plugin") or extra.get("source") or "" ).strip() or None, @@ -3456,7 +2685,7 @@ def coerce_to_pipe_object( pipe_obj = models.PipeObject( hash=hash_val, store=store_val, - provider=None, + plugin=None, path=str(path_val) if path_val and path_val != "unknown" else None, title=title_val, url=url_val, diff --git a/cmdlet/file/add.py b/cmdlet/file/add.py index 4e9ab8e..24b167e 100644 --- a/cmdlet/file/add.py +++ b/cmdlet/file/add.py @@ -17,7 +17,7 @@ from SYS.result_publication import overlay_existing_result_table, publish_result from SYS.rich_display import show_available_plugins_panel, show_plugin_config_panel from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS from PluginCore.backend_registry import BackendRegistry -from API.HTTP import _download_direct_file +from API.HTTP import download_direct_file from .. import _shared as sh Cmdlet = sh.Cmdlet @@ -31,7 +31,7 @@ merge_sequences = sh.merge_sequences extract_relationships = sh.extract_relationships extract_duration = sh.extract_duration coerce_to_pipe_object = sh.coerce_to_pipe_object -collapse_namespace_tag = sh.collapse_namespace_tag +collapse_namespace_tags = sh.collapse_namespace_tags resolve_target_dir = sh.resolve_target_dir resolve_media_kind_by_extension = sh.resolve_media_kind_by_extension coerce_to_path = sh.coerce_to_path @@ -102,10 +102,10 @@ def _maybe_apply_florencevision_tags( config: Dict[str, Any], pipe_obj: Optional[models.PipeObject] = None, ) -> List[str]: - """Optionally auto-tag images using the FlorenceVision tool. + """Optionally auto-tag images using the FlorenceVision plugin helper. Controlled via config: - [tool=florencevision] + [plugin=florencevision] enabled=true strict=false @@ -114,8 +114,8 @@ def _maybe_apply_florencevision_tags( """ strict = False try: - tool_block = (config or {}).get("tool") - fv_block = tool_block.get("florencevision") if isinstance(tool_block, dict) else None + plugin_block = (config or {}).get("plugin") + fv_block = plugin_block.get("florencevision") if isinstance(plugin_block, dict) else None enabled = False if isinstance(fv_block, dict): enabled = bool(fv_block.get("enabled")) @@ -123,7 +123,7 @@ def _maybe_apply_florencevision_tags( if not enabled: return tags - from tool.florencevision import FlorenceVisionTool + from plugins.florencevision import FlorenceVisionTool # Special-case: if this file was produced by the `screen-shot` cmdlet, # OCR is more useful than caption/detection for tagging screenshots. @@ -134,12 +134,12 @@ def _maybe_apply_florencevision_tags( if action.lower().startswith("cmdlet:"): cmdlet_name = action.split(":", 1)[1].strip().lower() if cmdlet_name in {"screen-shot", "screen_shot", "screenshot"}: - tool_block2 = dict((config or {}).get("tool") or {}) - fv_block2 = dict(tool_block2.get("florencevision") or {}) + plugin_block2 = dict((config or {}).get("plugin") or {}) + fv_block2 = dict(plugin_block2.get("florencevision") or {}) fv_block2["task"] = "ocr" - tool_block2["florencevision"] = fv_block2 + plugin_block2["florencevision"] = fv_block2 cfg_for_tool = dict(config or {}) - cfg_for_tool["tool"] = tool_block2 + cfg_for_tool["plugin"] = plugin_block2 except Exception: cfg_for_tool = config @@ -1237,7 +1237,7 @@ class Add_File(Cmdlet): except Exception: pass - downloaded = _download_direct_file( + downloaded = download_direct_file( url_text, download_root, quiet=False, @@ -1693,9 +1693,8 @@ class Add_File(Cmdlet): ) -> Tuple[Optional[Path], Optional[str], Optional[Path]]: plugin_key = None for source in ( - pipe_obj.provider, + pipe_obj.plugin, get_field(result, "plugin"), - get_field(result, "provider"), get_field(result, "table"), ): candidate = Add_File._normalize_provider_key(source) @@ -1760,7 +1759,7 @@ class Add_File(Cmdlet): str(r_hash), source_url, ) - downloaded = _download_direct_file( + downloaded = download_direct_file( source_url, download_dir, quiet=True, @@ -2028,7 +2027,7 @@ class Add_File(Cmdlet): *, hash_value: str, store: str, - provider: Optional[str] = None, + plugin: Optional[str] = None, path: Optional[str], tag: List[str], title: Optional[str], @@ -2037,7 +2036,7 @@ class Add_File(Cmdlet): ) -> None: pipe_obj.hash = hash_value pipe_obj.store = store - pipe_obj.provider = provider + pipe_obj.plugin = plugin pipe_obj.is_temp = False pipe_obj.path = path pipe_obj.tag = tag @@ -2260,7 +2259,7 @@ class Add_File(Cmdlet): t for t in tags_from_result if not str(t).strip().lower().startswith("title:") ] - sidecar_tags = collapse_namespace_tag( + sidecar_tags = collapse_namespace_tags( [normalize_title_tag(t) for t in sidecar_tags], "title", prefer="last" @@ -2449,15 +2448,15 @@ class Add_File(Cmdlet): or "unknown" ).strip() or "unknown" store_value = str(payload.get("store") or "").strip() - provider_value = payload.get("provider") - if provider_value is None and plugin_name: - provider_value = plugin_name + plugin_value = payload.get("plugin") + if plugin_value is None and plugin_name: + plugin_value = plugin_name Add_File._update_pipe_object_destination( pipe_obj, hash_value=hash_value, store=store_value, - provider=str(provider_value) if provider_value else None, + plugin=str(plugin_value) if plugin_value else None, path=path_value, tag=tag_values, title=title_value, @@ -2584,7 +2583,7 @@ class Add_File(Cmdlet): pipe_obj, hash_value=f_hash or "unknown", store="", - provider=plugin_name or None, + plugin=plugin_name or None, path=file_path, tag=pipe_obj.tag, title=pipe_obj.title or (media_path.name if media_path else None), diff --git a/cmdlet/file/add_note.py b/cmdlet/file/add_note.py deleted file mode 100644 index 09129c9..0000000 --- a/cmdlet/file/add_note.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -"""Compatibility wrapper for moved metadata note add cmdlet.""" - -from cmdlet.metadata.note_add import * # noqa: F401,F403 diff --git a/cmdlet/file/add_relationship.py b/cmdlet/file/add_relationship.py deleted file mode 100644 index 9e01d63..0000000 --- a/cmdlet/file/add_relationship.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -"""Compatibility wrapper for moved metadata relationship add cmdlet.""" - -from cmdlet.metadata import relationship_add as _relationship_add -from cmdlet.metadata.relationship_add import * # noqa: F401,F403 - -# Preserve direct private helper imports used by tests and legacy callers. -_extract_hash_and_store = _relationship_add._extract_hash_and_store diff --git a/cmdlet/file/add_url.py b/cmdlet/file/add_url.py deleted file mode 100644 index e04195d..0000000 --- a/cmdlet/file/add_url.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -"""Compatibility wrapper for moved metadata URL add cmdlet.""" - -from cmdlet.metadata.url_add import * # noqa: F401,F403 diff --git a/cmdlet/file/archive.py b/cmdlet/file/archive.py index 49407c2..6187979 100644 --- a/cmdlet/file/archive.py +++ b/cmdlet/file/archive.py @@ -49,9 +49,9 @@ def _extract_hash_from_hydrus_file_url(url: str) -> str: def _hydrus_instance_names(config: Dict[str, Any]) -> Set[str]: instances: Set[str] = set() try: - store_cfg = config.get("store") if isinstance(config, dict) else None - if isinstance(store_cfg, dict): - hydrus_cfg = store_cfg.get("hydrusnetwork") + plugin_cfg = config.get("plugin") if isinstance(config, dict) else None + if isinstance(plugin_cfg, dict): + hydrus_cfg = plugin_cfg.get("hydrusnetwork") if isinstance(hydrus_cfg, dict): instances = { str(k).strip().lower() diff --git a/cmdlet/file/delete.py b/cmdlet/file/delete.py index 9008641..3d015c6 100644 --- a/cmdlet/file/delete.py +++ b/cmdlet/file/delete.py @@ -133,13 +133,13 @@ class Delete_File(sh.Cmdlet): provider_name = None full_metadata: Dict[str, Any] = {} if isinstance(item, dict): - provider_name = item.get("provider") or item.get("table") + provider_name = item.get("plugin") or item.get("table") raw_meta = item.get("full_metadata") or item.get("metadata") if isinstance(raw_meta, dict): full_metadata = raw_meta else: try: - provider_name = sh.get_field(item, "provider") or sh.get_field(item, "table") + provider_name = sh.get_field(item, "plugin") or sh.get_field(item, "table") except Exception: pass try: @@ -542,4 +542,4 @@ class Delete_File(sh.Cmdlet): # Instantiate and register the cmdlet -Delete_File() +CMDLET = Delete_File() diff --git a/cmdlet/file/download.py b/cmdlet/file/download.py index af848f7..bd38068 100644 --- a/cmdlet/file/download.py +++ b/cmdlet/file/download.py @@ -19,7 +19,7 @@ import shutil import webbrowser -from API.HTTP import _download_direct_file +from API.HTTP import download_direct_file from SYS.models import DownloadError, DownloadOptions, DownloadMediaResult from SYS.logger import log, debug_panel, is_debug_enabled from SYS.payload_builders import build_file_result_payload, build_table_result_payload @@ -235,7 +235,7 @@ class Download_File(Cmdlet): action = str( result.get("action") - or result.get("provider_action") + or result.get("plugin_action") or "" ).strip().lower() @@ -338,12 +338,12 @@ class Download_File(Cmdlet): path_value: Optional[Any] = path if isinstance(path, dict): - provider_action = str( + plugin_action = str( path.get("action") - or path.get("provider_action") + or path.get("plugin_action") or "" ).strip().lower() - if provider_action == "download_items" or bool(path.get("download_items")): + if plugin_action == "download_items" or bool(path.get("download_items")): request_metadata = path.get("metadata") or path.get("full_metadata") or {} if not isinstance(request_metadata, dict): request_metadata = {} @@ -522,7 +522,7 @@ class Download_File(Cmdlet): # Direct Download Fallback attempted_download = True - result_obj = _download_direct_file( + result_obj = download_direct_file( str(url), final_output_dir, quiet=quiet_mode, @@ -569,7 +569,7 @@ class Download_File(Cmdlet): key = self._normalize_provider_key(table_hint) if key: return key - provider_hint = get_field(item, "provider") + provider_hint = get_field(item, "plugin") key = self._normalize_provider_key(provider_hint) if key: return key @@ -743,7 +743,7 @@ class Download_File(Cmdlet): and isinstance(target, str) and target.startswith("http")): suggested_name = str(title).strip() if title is not None else None - result_obj = _download_direct_file( + result_obj = download_direct_file( target, final_output_dir, quiet=quiet_mode, @@ -926,7 +926,6 @@ class Download_File(Cmdlet): } if provider_hint: payload["plugin"] = str(provider_hint) - payload["provider"] = str(provider_hint) if full_metadata: payload["metadata"] = full_metadata if notes: @@ -1125,7 +1124,7 @@ class Download_File(Cmdlet): filename += ext_text if download_url: - result_obj = _download_direct_file( + result_obj = download_direct_file( download_url, final_output_dir, quiet=True, diff --git a/cmdlet/file/screenshot.py b/cmdlet/file/screenshot.py index a5daca2..c2883dd 100644 --- a/cmdlet/file/screenshot.py +++ b/cmdlet/file/screenshot.py @@ -43,7 +43,7 @@ from SYS import pipeline as pipeline_context # Playwright & Screenshot Dependencies # ============================================================================ -from tool.playwright import PlaywrightTimeoutError, PlaywrightTool +from plugins.playwright import PlaywrightTimeoutError, PlaywrightTool try: from SYS.config import resolve_output_dir @@ -1525,22 +1525,22 @@ def _capture( {}) or {}) except Exception: base_cfg = {} - tool_block = dict(base_cfg.get("tool") or {} + plugin_block = dict(base_cfg.get("plugin") or {} ) if isinstance(base_cfg, dict) else {} pw_block = ( - dict(tool_block.get("playwright") or {}) - if isinstance(tool_block, + dict(plugin_block.get("playwright") or {}) + if isinstance(plugin_block, dict) else {} ) pw_block["browser"] = "chromium" - tool_block["playwright"] = pw_block + plugin_block["playwright"] = pw_block if isinstance(base_cfg, dict): - base_cfg["tool"] = tool_block + base_cfg["plugin"] = plugin_block tool = PlaywrightTool(base_cfg) except Exception: tool = PlaywrightTool({ - "tool": { + "plugin": { "playwright": { "browser": "chromium" } @@ -1888,8 +1888,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: quality_value: Optional[int] = None if not format_value: try: - tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {} - pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None + plugin_cfg = config.get("plugin", {}) if isinstance(config, dict) else {} + pw_cfg = plugin_cfg.get("playwright") if isinstance(plugin_cfg, dict) else None if isinstance(pw_cfg, dict): format_value = pw_cfg.get("format") except Exception: @@ -1901,8 +1901,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: quality_value = _normalize_quality(raw_quality_value) else: try: - tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {} - pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None + plugin_cfg = config.get("plugin", {}) if isinstance(config, dict) else {} + pw_cfg = plugin_cfg.get("playwright") if isinstance(plugin_cfg, dict) else None if isinstance(pw_cfg, dict) and pw_cfg.get("screenshot_quality") not in (None, ""): quality_value = _normalize_quality(pw_cfg.get("screenshot_quality")) except Exception: @@ -1994,18 +1994,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: shared_playwright_tool: Optional[PlaywrightTool] = None try: if isinstance(config, dict): - tool_block = dict(config.get("tool") or {}) - pw_block = dict(tool_block.get("playwright") or {}) + plugin_block = dict(config.get("plugin") or {}) + pw_block = dict(plugin_block.get("playwright") or {}) pw_block["browser"] = "chromium" pw_block["user_agent"] = "native" pw_block["viewport_width"] = int(DEFAULT_VIEWPORT.get("width", 1920)) pw_block["viewport_height"] = int(DEFAULT_VIEWPORT.get("height", 1080)) - tool_block["playwright"] = pw_block + plugin_block["playwright"] = pw_block pw_local_cfg = dict(config) - pw_local_cfg["tool"] = tool_block + pw_local_cfg["plugin"] = plugin_block else: pw_local_cfg = { - "tool": { + "plugin": { "playwright": { "browser": "chromium", "user_agent": "native", diff --git a/cmdlet/file/search.py b/cmdlet/file/search.py index 60b61aa..3e01983 100644 --- a/cmdlet/file/search.py +++ b/cmdlet/file/search.py @@ -164,7 +164,7 @@ def _summarize_worker_results(results: Sequence[Dict[str, Any]], preview_limit: class search_file(Cmdlet): - """Class-based search-file cmdlet for searching backends and providers.""" + """Class-based search-file cmdlet for searching backends and plugins.""" def __init__(self) -> None: super().__init__( @@ -187,9 +187,9 @@ class search_file(Cmdlet): ), ], detail=[ - "Search across configured store backends or plugin providers.", + "Search across configured storage backends or plugins.", "Use -instance to target a specific configured backend/instance by name.", - "Use -plugin with -instance to target a named provider config.", + "Use -plugin with -instance to target a named plugin config.", "URL search: url:* (any URL) or url: (URL substring)", "Extension search: ext: (e.g., ext:png)", "Hydrus-style extension: system:filetype = png", @@ -1216,7 +1216,7 @@ class search_file(Cmdlet): try: table.set_table_metadata( { - "provider": "web", + "plugin": "web", "site": site_host, "query": search_query, "filetype": requested_type, @@ -1490,7 +1490,7 @@ class search_file(Cmdlet): return 1 - # Align with provider default when user did not set -limit. + # Align with plugin default when user did not set -limit. if not limit_set: limit = 50 @@ -1632,7 +1632,7 @@ class search_file(Cmdlet): if "table" not in item_dict: item_dict["table"] = table_type - # Ensure provider source is present so downstream cmdlets (select) can resolve provider + # Ensure plugin source is present so downstream cmdlets can resolve the owner. if "source" not in item_dict: item_dict["source"] = plugin_name diff --git a/cmdlet/file_cmdlet.py b/cmdlet/file_cmdlet.py index f464a6b..73513e0 100644 --- a/cmdlet/file_cmdlet.py +++ b/cmdlet/file_cmdlet.py @@ -125,10 +125,6 @@ class File(Cmdlet): if callable(exec_fn): return int(exec_fn(result, args, config)) - fallback_run = getattr(module, "_run", None) - if callable(fallback_run): - return int(fallback_run(result, args, config)) - log(f"file: cannot dispatch action '{action}' via module '{module_name}'", file=sys.stderr) return 1 diff --git a/cmdlet/metadata/relationship_add.py b/cmdlet/metadata/relationship_add.py index 3fe4301..e861e40 100644 --- a/cmdlet/metadata/relationship_add.py +++ b/cmdlet/metadata/relationship_add.py @@ -640,8 +640,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: except Exception: hydrus_client = None - # Sidecar/tag import fallback DB root (legacy): if a folder store is selected, use it; - # otherwise fall back to configured local storage path. + # Use the selected store root when available; otherwise use the configured + # local plugin root for sidecar/tag import lookup. from SYS.config import get_local_storage_path local_storage_root: Optional[Path] = None @@ -852,8 +852,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: "path", None) - # Legacy LOCAL STORAGE MODE: Handle relationships for local files - # (kept as stub - folder store removed) + # Handle relationships for local-file results using the configured + # local plugin root when available. from SYS.config import get_local_storage_path local_storage_path = get_local_storage_path(config) if config else None @@ -869,7 +869,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: try: file_path_obj = Path(str(file_path_from_result)) except Exception as exc: - log(f"Local storage error: {exc}", file=sys.stderr) + log(f"Local library error: {exc}", file=sys.stderr) return 1 if not file_path_obj.exists(): @@ -879,12 +879,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: if file_path_obj is not None: try: if local_storage_root is None: - log("Local storage path unavailable", file=sys.stderr) + log("Local plugin path unavailable", file=sys.stderr) return 1 with LocalLibrarySearchOptimizer(local_storage_root) as opt: if opt.db is None: - log("Local storage DB unavailable", file=sys.stderr) + log("Local library DB unavailable", file=sys.stderr) return 1 if king_hash: diff --git a/cmdlet/metadata/tag_add.py b/cmdlet/metadata/tag_add.py index ec2f166..576d18c 100644 --- a/cmdlet/metadata/tag_add.py +++ b/cmdlet/metadata/tag_add.py @@ -25,7 +25,7 @@ expand_tag_groups = sh.expand_tag_groups merge_sequences = sh.merge_sequences render_tag_value_templates = sh.render_tag_value_templates parse_cmdlet_args = sh.parse_cmdlet_args -collapse_namespace_tag = sh.collapse_namespace_tag +collapse_namespace_tags = sh.collapse_namespace_tags should_show_help = sh.should_show_help get_field = sh.get_field @@ -800,7 +800,7 @@ class Add_Tag(Cmdlet): file=sys.stderr, ) - item_tag_to_add = collapse_namespace_tag( + item_tag_to_add = collapse_namespace_tags( item_tag_to_add, "title", prefer="last" @@ -843,7 +843,7 @@ class Add_Tag(Cmdlet): ) unresolved_template_count += len(unresolved_templates) - item_tag_to_add = collapse_namespace_tag( + item_tag_to_add = collapse_namespace_tags( item_tag_to_add, "title", prefer="last" @@ -877,7 +877,7 @@ class Add_Tag(Cmdlet): ] updated_tag_list.extend(actual_tag_to_add) - updated_tag_list = collapse_namespace_tag( + updated_tag_list = collapse_namespace_tags( updated_tag_list, "title", prefer="last" @@ -977,7 +977,7 @@ class Add_Tag(Cmdlet): file=sys.stderr, ) - item_tag_to_add = collapse_namespace_tag( + item_tag_to_add = collapse_namespace_tags( item_tag_to_add, "title", prefer="last" @@ -1016,7 +1016,7 @@ class Add_Tag(Cmdlet): ) unresolved_template_count += len(unresolved_templates) - item_tag_to_add = collapse_namespace_tag( + item_tag_to_add = collapse_namespace_tags( item_tag_to_add, "title", prefer="last" @@ -1032,7 +1032,7 @@ class Add_Tag(Cmdlet): ] if len(existing_title_tags) > 1: item_tag_to_add.append(existing_title_tags[-1]) - item_tag_to_add = collapse_namespace_tag( + item_tag_to_add = collapse_namespace_tags( item_tag_to_add, "title", prefer="last" diff --git a/cmdlet/metadata/tag_get.py b/cmdlet/metadata/tag_get.py index 706656f..45023a6 100644 --- a/cmdlet/metadata/tag_get.py +++ b/cmdlet/metadata/tag_get.py @@ -649,7 +649,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: item_title=str(items[0].get("title") or provider.name), path=None, subject={ - "provider": provider.name, + "plugin": provider.name, "url": str(query_hint) }, quiet=emit_mode, @@ -692,7 +692,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: ) payload = { "tag": tags, - "provider": provider.name, + "plugin": provider.name, "title": item.get("title"), "artist": item.get("artist"), "album": item.get("album"), @@ -702,7 +702,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: "path": path_for_payload, "extra": { "tag": tags, - "provider": provider.name, + "plugin": provider.name, }, } selection_payload.append(payload) @@ -721,7 +721,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: # If the current result already carries a tag list (e.g. a selected metadata # row from get-tag -scrape itunes), APPLY those tags to the file in the store. - result_provider = get_field(result, "provider", None) + result_provider = get_field(result, "plugin", None) result_tags = get_field(result, "tag", None) if result_provider and isinstance(result_tags, list) and result_tags: diff --git a/cmdnat/__init__.py b/cmdnat/__init__.py index 6844f10..4629721 100644 --- a/cmdnat/__init__.py +++ b/cmdnat/__init__.py @@ -4,6 +4,8 @@ import os from importlib import import_module from typing import Any, Callable, Dict, Sequence +from SYS.cmdlet_spec import collect_registered_cmdlet_names + CmdletFn = Callable[[Any, Sequence[str], Dict[str, Any]], int] @@ -12,20 +14,8 @@ def _register_cmdlet_object(cmdlet_obj, registry: Dict[str, CmdletFn]) -> None: if not callable(run_fn): return - if hasattr(cmdlet_obj, "name") and cmdlet_obj.name: - registry[cmdlet_obj.name.replace("_", "-").lower()] = run_fn - - # Cmdlet uses 'alias' (List[str]). Some older objects may use 'aliases'. - aliases: list[str] = [] - if hasattr(cmdlet_obj, "alias") and getattr(cmdlet_obj, "alias"): - aliases.extend(getattr(cmdlet_obj, "alias") or []) - if hasattr(cmdlet_obj, "aliases") and getattr(cmdlet_obj, "aliases"): - aliases.extend(getattr(cmdlet_obj, "aliases") or []) - - for alias in aliases: - if not alias: - continue - registry[alias.replace("_", "-").lower()] = run_fn + for registered_name in collect_registered_cmdlet_names(cmdlet_obj): + registry[registered_name] = run_fn def _iter_legacy_native_module_names() -> list[str]: diff --git a/cmdnat/_status_shared.py b/cmdnat/_status_shared.py index 6a1731d..968a32d 100644 --- a/cmdnat/_status_shared.py +++ b/cmdnat/_status_shared.py @@ -34,8 +34,6 @@ def _provider_config_map(config: dict) -> dict[str, Any]: return {} provider_cfg = config.get("plugin") - if not isinstance(provider_cfg, dict): - provider_cfg = config.get("provider") return provider_cfg if isinstance(provider_cfg, dict) else {} @@ -100,34 +98,14 @@ def _resolve_startup_instance_text( return ", ".join(_extract_configured_instance_names(configured_entry)) -def has_store_subtype(cfg: dict, subtype: str) -> bool: - store_cfg = cfg.get("store") - if not isinstance(store_cfg, dict): - return False - bucket = store_cfg.get(subtype) - if not isinstance(bucket, dict): - return False - return any(isinstance(value, dict) and bool(value) for value in bucket.values()) - - def has_provider(cfg: dict, name: str) -> bool: provider_cfg = cfg.get("plugin") - if not isinstance(provider_cfg, dict): - provider_cfg = cfg.get("provider") if not isinstance(provider_cfg, dict): return False block = provider_cfg.get(str(name).strip().lower()) return isinstance(block, dict) and bool(block) -def has_tool(cfg: dict, name: str) -> bool: - tool_cfg = cfg.get("tool") - if not isinstance(tool_cfg, dict): - return False - block = tool_cfg.get(str(name).strip().lower()) - return isinstance(block, dict) and bool(block) - - def ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]: try: from API.HTTP import HTTPClient diff --git a/cmdnat/config.py b/cmdnat/config.py index 50e5c23..f050a41 100644 --- a/cmdnat/config.py +++ b/cmdnat/config.py @@ -14,10 +14,8 @@ from SYS.database import LOG_DB_PATH, db from SYS.logger import log from SYS.plugin_config import ( build_default_plugin_config, - build_default_tool_config, get_configurable_plugin_types, get_configurable_store_types, - get_configurable_tool_types, ) from SYS import pipeline as ctx from SYS.result_table import Table @@ -32,19 +30,15 @@ from cmdnat._parsing import ( _PREFERENCES_BROWSE_PATH = "__preferences__" _PLUGINS_BROWSE_PATH = "__plugins__" -_PLUGIN_CATEGORY_KEYS = ("plugin", "provider", "tool") +_PLUGIN_CATEGORY_KEYS = ("plugin",) _CREATE_INSTANCE_FLAG = "-create-instance" _KNOWN_SECTION_LABELS = { "plugin": "Plugins", - "provider": "Plugins", - "tool": "Plugins", } _KNOWN_SECTION_DESCRIPTIONS = { _PREFERENCES_BROWSE_PATH: "Global preferences and simple values", _PLUGINS_BROWSE_PATH: "All configured plugins and plugin instances", - "provider": "Plugin configuration", "plugin": "Plugin configuration", - "tool": "Plugin configuration", } _SENSITIVE_CONFIG_KEYS = { "access_key", @@ -300,19 +294,6 @@ def _get_configurable_plugin_names() -> List[str]: ] except Exception: return [] - - -def _get_configurable_tool_names() -> List[str]: - try: - return [ - str(name).strip().lower() - for name in (get_configurable_tool_types() or []) - if str(name).strip() - ] - except Exception: - return [] - - def _get_multi_instance_plugin_names() -> set[str]: try: return { @@ -336,7 +317,7 @@ def _is_multi_instance_plugin_root_path(browse_path: Optional[str]) -> bool: parts = _split_config_path(browse_path) return ( len(parts) == 2 - and parts[0] in {"plugin", "provider"} + and parts[0] == "plugin" and _is_multi_instance_plugin_name(parts[1]) ) @@ -397,10 +378,6 @@ def _build_synthetic_plugin_branch(category: str, name: str) -> Optional[Dict[st if not normalized_name: return None - if normalized_category == "tool": - branch = build_default_tool_config(normalized_name) - return dict(branch) if isinstance(branch, dict) else None - branch = build_default_plugin_config(normalized_name) if not isinstance(branch, dict): return None @@ -441,10 +418,7 @@ def _resolve_plugin_branch( if not normalized_name: return None - if normalized_category == "tool": - if normalized_name not in _get_configurable_tool_names(): - return None - elif normalized_name not in _get_configurable_plugin_names(): + if normalized_name not in _get_configurable_plugin_names(): return None synthetic = _build_synthetic_plugin_branch(normalized_category, normalized_name) @@ -557,7 +531,7 @@ def _resolve_config_branch( if resolved is None: return None _, current, _ = resolved - if parts[0] in {"plugin", "provider"} and _is_multi_instance_plugin_name(parts[1]): + if parts[0] == "plugin" and _is_multi_instance_plugin_name(parts[1]): current = _normalize_multi_instance_branch(parts[1], current) for part in parts[2:]: if not isinstance(current, dict): @@ -620,7 +594,7 @@ def _create_or_get_plugin_instance( instance_name: str, ) -> tuple[str, bool]: parts = _split_config_path(instance_target) - if len(parts) != 2 or parts[0] not in {"plugin", "provider"}: + if len(parts) != 2 or parts[0] != "plugin": raise ValueError(f"Unsupported instance target '{instance_target}'") category, plugin_name = parts @@ -663,7 +637,7 @@ def _resolve_update_key(config_data: Dict[str, Any], selection_key: str) -> str: parts = _split_config_path(selection_key) if ( len(parts) >= 4 - and parts[0] in {"plugin", "provider"} + and parts[0] == "plugin" and parts[2].lower() == "default" and _is_multi_instance_plugin_name(parts[1]) ): @@ -797,7 +771,7 @@ def _build_config_header_lines(browse_path: Optional[str]) -> List[str]: parts = _split_config_path(text) if ( len(parts) == 3 - and parts[0] in {"plugin", "provider"} + and parts[0] == "plugin" and _is_multi_instance_plugin_name(parts[1]) ): return [ @@ -951,17 +925,13 @@ def _resolve_direct_browse_path( lowered = text.lower() if lowered in {"preferences", "prefs"}: return _PREFERENCES_BROWSE_PATH - if lowered in {"plugins", "plugin", "providers", "provider", "tools", "tool"}: + if lowered in {"plugins", "plugin"}: return _PLUGINS_BROWSE_PATH plugin_branch = _resolve_plugin_branch(config_data, "plugin", lowered) if plugin_branch is not None: return f"plugin.{plugin_branch[0]}" - tool_branch = _resolve_plugin_branch(config_data, "tool", lowered) - if tool_branch is not None: - return f"tool.{tool_branch[0]}" - branch = _resolve_config_branch(config_data, text) if isinstance(branch, dict): return text diff --git a/cmdnat/help.py b/cmdnat/help.py index 7734f76..babfe2d 100644 --- a/cmdnat/help.py +++ b/cmdnat/help.py @@ -4,7 +4,7 @@ from typing import Any, Dict, Sequence, List, Optional, Tuple import shlex import sys -from SYS.cmdlet_spec import Cmdlet, CmdletArg, parse_cmdlet_args +from SYS.cmdlet_spec import Cmdlet, CmdletArg, collect_registered_cmdlet_names, parse_cmdlet_args from cmdlet import REGISTRY as CMDLET_REGISTRY, ensure_cmdlet_modules_loaded from SYS.logger import log from SYS.result_table import Table @@ -53,26 +53,12 @@ def _normalize_cmdlet_key(name: Optional[str]) -> str: def _cmdlet_aliases(cmdlet_obj: Cmdlet) -> List[str]: - aliases: List[str] = [] - for attr in ("alias", "aliases"): - raw_aliases = getattr(cmdlet_obj, attr, None) - if isinstance(raw_aliases, (list, tuple, set)): - candidates = raw_aliases - else: - candidates = (raw_aliases,) - for alias in candidates or (): - text = str(alias or "").strip() - if text: - aliases.append(text) - seen: set[str] = set() - deduped: List[str] = [] - for alias in aliases: - key = alias.lower() - if key in seen: - continue - seen.add(key) - deduped.append(alias) - return deduped + canonical_name = _normalize_cmdlet_key(getattr(cmdlet_obj, "name", None)) + return [ + registered_name + for registered_name in collect_registered_cmdlet_names(cmdlet_obj) + if registered_name != canonical_name + ] def _cmdlet_arg_to_dict(arg: CmdletArg) -> Dict[str, Any]: diff --git a/docs/result_table.md b/docs/result_table.md index 5724517..ef7b472 100644 --- a/docs/result_table.md +++ b/docs/result_table.md @@ -52,7 +52,7 @@ from SYS.result_table import ResultTable table = ResultTable("Plugin: X results").set_preserve_order(True) table.set_table("plugin_name") -table.set_table_metadata({"provider": "plugin_name", "view": "folders"}) +table.set_table_metadata({"plugin": "plugin_name", "view": "folders"}) table.set_source_command("search-file", ["-plugin", "plugin_name", "query"]) for result in results: @@ -114,8 +114,8 @@ SearchResult( media_kind="folder", full_metadata={ "magnet_id": 123, - "provider": "alldebrid", - "provider_view": "folders", + "plugin": "alldebrid", + "plugin_view": "folders", }, ) ``` @@ -130,8 +130,8 @@ SearchResult( media_kind="file", full_metadata={ "magnet_id": 123, - "provider": "alldebrid", - "provider_view": "files", + "plugin": "alldebrid", + "plugin_view": "files", }, ) ``` diff --git a/plugins/alldebrid/__init__.py b/plugins/alldebrid/__init__.py index 271619b..cc6edbe 100644 --- a/plugins/alldebrid/__init__.py +++ b/plugins/alldebrid/__init__.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple from urllib.parse import urlparse -from API.HTTP import HTTPClient, _download_direct_file +from API.HTTP import HTTPClient, download_direct_file from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file from PluginCore.base import Provider, SearchResult from SYS.plugin_helpers import TablePluginMixin @@ -197,47 +197,14 @@ def refresh_alldebrid_hoster_cache(*, force: bool = False) -> None: def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]: - """Read AllDebrid API key from config. - - Preferred formats: - - config.conf provider block: - [provider=alldebrid] - api_key=... - -> config["provider"]["alldebrid"]["api_key"] - - - plugin-style debrid block: - config["plugin"]["debrid"]["all-debrid"]["api_key"] - - Falls back to some legacy keys if present. - """ - # 1) provider block: [provider=alldebrid] - provider = config.get("provider") - if isinstance(provider, dict): - entry = provider.get("alldebrid") - if isinstance(entry, dict): - for k in ("api_key", "apikey", "API_KEY", "APIKEY"): - val = entry.get(k) - if isinstance(val, str) and val.strip(): - return val.strip() - if isinstance(entry, str) and entry.strip(): - return entry.strip() - - # 2) plugin debrid block + """Read the canonical AllDebrid API key from config.""" try: from SYS.config import get_debrid_api_key key = get_debrid_api_key(config, service="All-debrid") return key.strip() if key else None except Exception: - pass - - # Legacy fallback (kept permissive so older configs still work) - for legacy_key in ("alldebrid_api_key", "AllDebrid", "all_debrid_api_key"): - val = config.get(legacy_key) - if isinstance(val, str) and val.strip(): - return val.strip() - - return None + return None def _consume_bencoded_value(data: bytes, pos: int) -> int: @@ -399,8 +366,8 @@ def _build_queued_magnet_item( metadata: Dict[str, Any] = { "magnet_id": magnet_id, - "provider": "alldebrid", - "provider_view": "files", + "plugin": "alldebrid", + "plugin_view": "files", "magnet_spec": magnet_spec, "source_url": magnet_spec, "status": status_label, @@ -412,7 +379,6 @@ def _build_queued_magnet_item( return { "table": "alldebrid", - "provider": "alldebrid", "plugin": "alldebrid", "path": f"{_ALD_MAGNET_PREFIX}{magnet_id}", "title": title, @@ -539,7 +505,7 @@ def download_magnet( output_dir = target_path try: - result_obj = _download_direct_file( + result_obj = download_direct_file( file_url, output_dir, quiet=quiet_mode, @@ -800,8 +766,8 @@ class AllDebrid(TablePluginMixin, Provider): "title": f"magnet-{magnet_id}", "metadata": { "magnet_id": magnet_id, - "provider": "alldebrid", - "provider_view": "files", + "plugin": "alldebrid", + "plugin_view": "files", }, } @@ -952,7 +918,7 @@ class AllDebrid(TablePluginMixin, Provider): pipe_progress = None try: - dl_res = _download_direct_file( + dl_res = download_direct_file( unlocked_url, Path(output_dir), quiet=quiet, @@ -965,7 +931,7 @@ class AllDebrid(TablePluginMixin, Provider): downloaded_path = Path(str(downloaded_path)) except DownloadError as exc: log( - f"[alldebrid] _download_direct_file rejected URL ({exc}); no further fallback", file=sys.stderr + f"[alldebrid] download_direct_file rejected URL ({exc}); no further fallback", file=sys.stderr ) return None @@ -1360,7 +1326,7 @@ class AllDebrid(TablePluginMixin, Provider): suggested_name = rel_path_obj.name or file_name or f"file-{file_idx}" try: - result_obj = _download_direct_file( + result_obj = download_direct_file( file_url, target_path, quiet=quiet_mode, @@ -1482,8 +1448,8 @@ class AllDebrid(TablePluginMixin, Provider): full_metadata={ "magnet": magnet_status, "magnet_id": magnet_id, - "provider": "alldebrid", - "provider_view": "files", + "plugin": "alldebrid", + "plugin_view": "files", "magnet_name": magnet_name, }, ) @@ -1535,8 +1501,8 @@ class AllDebrid(TablePluginMixin, Provider): "magnet_name": magnet_name, "relpath": relpath, "file": file_node, - "provider": "alldebrid", - "provider_view": "files", + "plugin": "alldebrid", + "plugin_view": "files", # Selection metadata for table system "_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], "_selection_action": ["download-file", "-plugin", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], @@ -1649,8 +1615,8 @@ class AllDebrid(TablePluginMixin, Provider): full_metadata={ "magnet": magnet, "magnet_id": magnet_id, - "provider": "alldebrid", - "provider_view": "folders", + "plugin": "alldebrid", + "plugin_view": "folders", "magnet_name": magnet_name, # Selection metadata: allow @N expansion to drive downloads directly "_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], @@ -1752,7 +1718,7 @@ class AllDebrid(TablePluginMixin, Provider): table = Table(f"AllDebrid Files: {title}")._perseverance(True) table.set_table("alldebrid") try: - table.set_table_metadata({"provider": "alldebrid", "view": "files", "magnet_id": magnet_id}) + table.set_table_metadata({"plugin": "alldebrid", "view": "files", "magnet_id": magnet_id}) except Exception: pass table.set_source_command("download-file", ["-plugin", "alldebrid"]) @@ -1844,7 +1810,7 @@ class AllDebrid(TablePluginMixin, Provider): "Magnet": magnet_name or None, "Magnet ID": magnet_id, "Relative Path": relpath or None, - "View": str(meta.get("provider_view") or meta.get("view") or (table_metadata or {}).get("view") or "").strip() or None, + "View": str(meta.get("plugin_view") or meta.get("view") or (table_metadata or {}).get("view") or "").strip() or None, "Direct Url": direct_url or None, "Selection Url": selection_url or None, }, @@ -1942,7 +1908,7 @@ try: if table_name: metadata.setdefault("table", table_name) metadata.setdefault("source", table_name) - metadata.setdefault("provider", table_name) + metadata.setdefault("plugin", table_name) ext = payload.get("ext") if not ext and isinstance(path_val, str): @@ -2003,8 +1969,8 @@ try: cols.append(metadata_column("ready", "Ready")) if _has_metadata(rows, "relpath"): cols.append(metadata_column("relpath", "File Path")) - if _has_metadata(rows, "provider_view"): - cols.append(metadata_column("provider_view", "View")) + if _has_metadata(rows, "plugin_view"): + cols.append(metadata_column("plugin_view", "View")) if _has_metadata(rows, "size"): cols.append(metadata_column("size", "Size")) return cols @@ -2016,7 +1982,7 @@ try: Selection precedence: 1. Explicit _selection_action (full command args) 2. Explicit _selection_args (URL-specific args) - 3. Magic routing based on provider_view (files vs folders) + 3. Magic routing based on plugin_view (files vs folders) 4. Magnet ID routing for folder-type rows (via alldebrid:magnet:) 5. Direct URL for file rows @@ -2035,7 +2001,7 @@ try: return [str(x) for x in args if x is not None] # Magic routing by view type - view = metadata.get("provider_view") or metadata.get("view") or "" + view = metadata.get("plugin_view") or metadata.get("view") or "" if view == "files": # File rows: pass direct URL for immediate download if row.path: diff --git a/plugins/alldebrid/api/__init__.py b/plugins/alldebrid/api/__init__.py index 19378e9..9e01860 100644 --- a/plugins/alldebrid/api/__init__.py +++ b/plugins/alldebrid/api/__init__.py @@ -1027,7 +1027,7 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any]) unlock-link # Uses URL from pipeline result Requires: - - AllDebrid API key in config under Debrid.All-debrid + - AllDebrid API key in config under plugin.alldebrid.api_key Args: result: Pipeline result object @@ -1054,28 +1054,14 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any]) return None def _get_alldebrid_api_key_from_config(cfg: Dict[str, Any]) -> Optional[str]: - # Current config format try: - provider_cfg = cfg.get("provider") if isinstance(cfg, dict) else None - ad_cfg = provider_cfg.get("alldebrid" - ) if isinstance(provider_cfg, - dict) else None - api_key = ad_cfg.get("api_key") if isinstance(ad_cfg, dict) else None - if isinstance(api_key, str) and api_key.strip(): - return api_key.strip() - except Exception: - pass + from SYS.config import get_debrid_api_key - # Legacy config format fallback (best-effort) - try: - debrid_cfg = cfg.get("Debrid") if isinstance(cfg, dict) else None - api_key = None - if isinstance(debrid_cfg, dict): - api_key = debrid_cfg.get("All-debrid") or debrid_cfg.get("AllDebrid") + api_key = get_debrid_api_key(cfg, service="All-debrid") if isinstance(api_key, str) and api_key.strip(): return api_key.strip() except Exception: - pass + return None return None diff --git a/plugins/bandcamp/__init__.py b/plugins/bandcamp/__init__.py index 647335f..ed2402f 100644 --- a/plugins/bandcamp/__init__.py +++ b/plugins/bandcamp/__init__.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional from PluginCore.base import Provider, SearchResult from SYS.logger import log, debug, debug_panel -from tool.playwright import PlaywrightTool +from plugins.playwright import PlaywrightTool class Bandcamp(Provider): diff --git a/plugins/fileio/__init__.py b/plugins/fileio/__init__.py index deeacd6..ef4ac2e 100644 --- a/plugins/fileio/__init__.py +++ b/plugins/fileio/__init__.py @@ -11,10 +11,10 @@ from SYS.logger import log def _pick_provider_config(config: Any) -> Dict[str, Any]: if not isinstance(config, dict): return {} - provider = config.get("provider") - if not isinstance(provider, dict): + plugin_cfg = config.get("plugin") + if not isinstance(plugin_cfg, dict): return {} - entry = provider.get("file.io") + entry = plugin_cfg.get("file.io") if isinstance(entry, dict): return entry return {} diff --git a/plugins/florencevision/__init__.py b/plugins/florencevision/__init__.py new file mode 100644 index 0000000..042ea3c --- /dev/null +++ b/plugins/florencevision/__init__.py @@ -0,0 +1,27 @@ +"""FlorenceVision support module under the plugin namespace.""" + +from __future__ import annotations + +__all__ = [ + "FlorenceVisionTool", + "FlorenceVisionDefaults", + "config_schema", +] + +_MODULE_ATTRS = { + "FlorenceVisionTool": ".runtime", + "FlorenceVisionDefaults": ".runtime", + "config_schema": ".runtime", +} + + +def __getattr__(name: str) -> object: + submod = _MODULE_ATTRS.get(name) + if submod is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from importlib import import_module + + mod = import_module(submod, package=__name__) + obj = getattr(mod, name) + globals()[name] = obj + return obj \ No newline at end of file diff --git a/tool/florencevision.py b/plugins/florencevision/runtime.py similarity index 94% rename from tool/florencevision.py rename to plugins/florencevision/runtime.py index 596f4aa..2fe2075 100644 --- a/tool/florencevision.py +++ b/plugins/florencevision/runtime.py @@ -291,8 +291,8 @@ class FlorenceVisionTool: Designed to be dependency-light at import time; heavy deps are imported lazily. - Config: - [tool=florencevision] + Config: + [plugin=florencevision] enabled=true strict=false model="microsoft/Florence-2-large" @@ -316,21 +316,21 @@ class FlorenceVisionTool: def _load_defaults(self) -> FlorenceVisionDefaults: cfg = self._config - tool_block = _get_nested(cfg, "tool", "florencevision") - if not isinstance(tool_block, dict): - tool_block = {} + plugin_block = _get_nested(cfg, "plugin", "florencevision") + if not isinstance(plugin_block, dict): + plugin_block = {} base = FlorenceVisionDefaults() defaults = FlorenceVisionDefaults( - enabled=_as_bool(tool_block.get("enabled"), False), - strict=_as_bool(tool_block.get("strict"), False), - model=str(tool_block.get("model") or base.model), - device=str(tool_block.get("device") or base.device), - dtype=(str(tool_block.get("dtype")).strip() if tool_block.get("dtype") else None), - max_tags=_as_int(tool_block.get("max_tags"), base.max_tags), - namespace=str(tool_block.get("namespace") or base.namespace), - task=str(tool_block.get("task") or base.task), + enabled=_as_bool(plugin_block.get("enabled"), False), + strict=_as_bool(plugin_block.get("strict"), False), + model=str(plugin_block.get("model") or base.model), + device=str(plugin_block.get("device") or base.device), + dtype=(str(plugin_block.get("dtype")).strip() if plugin_block.get("dtype") else None), + max_tags=_as_int(plugin_block.get("max_tags"), base.max_tags), + namespace=str(plugin_block.get("namespace") or base.namespace), + task=str(plugin_block.get("task") or base.task), ) return defaults @@ -1022,4 +1022,58 @@ class FlorenceVisionTool: return self.tags_for_image(media_path) -__all__ = ["FlorenceVisionTool", "FlorenceVisionDefaults"] +def config_schema() -> List[Dict[str, Any]]: + defaults = FlorenceVisionDefaults() + return [ + { + "key": "enabled", + "label": "Enable FlorenceVision", + "type": "boolean", + "default": str(defaults.enabled).lower(), + "choices": ["true", "false"], + }, + { + "key": "strict", + "label": "Strict mode", + "type": "boolean", + "default": str(defaults.strict).lower(), + "choices": ["true", "false"], + }, + { + "key": "model", + "label": "Model", + "default": defaults.model, + }, + { + "key": "device", + "label": "Device", + "default": defaults.device, + "choices": ["cpu", "cuda", "mps"], + }, + { + "key": "dtype", + "label": "DType", + "default": defaults.dtype or "", + "choices": ["", "float16", "bfloat16", "float32"], + }, + { + "key": "max_tags", + "label": "Max tags", + "type": "integer", + "default": defaults.max_tags, + }, + { + "key": "namespace", + "label": "Namespace", + "default": defaults.namespace, + }, + { + "key": "task", + "label": "Task", + "default": defaults.task, + "choices": ["tag", "detection", "caption", "ocr"], + }, + ] + + +__all__ = ["FlorenceVisionTool", "FlorenceVisionDefaults", "config_schema"] diff --git a/plugins/ftp/__init__.py b/plugins/ftp/__init__.py index 25f2f0b..9d8520b 100644 --- a/plugins/ftp/__init__.py +++ b/plugins/ftp/__init__.py @@ -359,7 +359,7 @@ class FTP(Provider): table.set_table("ftp") try: table.set_table_metadata({ - "provider": "ftp", + "plugin": "ftp", "instance": instance_name or None, "host": settings.get("host"), "path": target_path, @@ -792,7 +792,7 @@ class FTP(Provider): parent = posixpath.dirname(ftp_path.rstrip("/")) or "/" instance_name = str(settings.get("instance") or "").strip() metadata = { - "provider": "ftp", + "plugin": "ftp", "instance": instance_name or None, "host": settings.get("host"), "ftp_path": ftp_path, diff --git a/plugins/hello/__init__.py b/plugins/hello/__init__.py index b052c46..6492f76 100644 --- a/plugins/hello/__init__.py +++ b/plugins/hello/__init__.py @@ -153,7 +153,7 @@ class HelloProvider(Provider): table = Table(f"Hello Details: {title}")._perseverance(True) table.set_table("hello") try: - table.set_table_metadata({"provider": "hello", "view": "details", "example_index": idx}) + table.set_table_metadata({"plugin": "hello", "view": "details", "example_index": idx}) except Exception: pass diff --git a/plugins/hifi/__init__.py b/plugins/hifi/__init__.py index 13d10c0..2540c83 100644 --- a/plugins/hifi/__init__.py +++ b/plugins/hifi/__init__.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse from plugins.tidal.api import ( - Tidal as TidalApiClient, + Tidal, build_track_tags, coerce_duration_seconds, extract_artists, @@ -97,7 +97,7 @@ class HIFI(Provider): self.api_timeout = float(self.config.get("timeout", 10.0)) except Exception: self.api_timeout = 10.0 - self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls] + self.api_clients = [Tidal(base_url=url, timeout=self.api_timeout) for url in self.api_urls] def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]: normalized, parsed = parse_inline_query_arguments(query) @@ -744,7 +744,7 @@ class HIFI(Provider): try: table.set_table_metadata( { - "provider": "hifi", + "plugin": "hifi", "view": "track", "album_id": album_id, "album_title": album_title, @@ -1376,7 +1376,7 @@ class HIFI(Provider): return False, None - def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]: + def _get_api_client_for_base(self, base_url: str) -> Optional[Tidal]: base = base_url.rstrip("/") for client in self.api_clients: if getattr(client, "base_url", "").rstrip("/") == base: @@ -1935,7 +1935,7 @@ class HIFI(Provider): table = Table(f"HIFI Albums: {artist_name}")._perseverance(False) table.set_table("hifi.album") try: - table.set_table_metadata({"provider": "hifi", "view": "album", "artist_id": artist_id, "artist_name": artist_name}) + table.set_table_metadata({"plugin": "hifi", "view": "album", "artist_id": artist_id, "artist_name": artist_name}) except Exception: pass @@ -1997,7 +1997,7 @@ class HIFI(Provider): try: table.set_table_metadata( { - "provider": "hifi", + "plugin": "hifi", "view": "track", "album_id": album_id, "album_title": album_title, @@ -2061,7 +2061,7 @@ class HIFI(Provider): table = Table("HIFI Track")._perseverance(True) table.set_table("hifi.track") try: - table.set_table_metadata({"provider": "hifi", "view": "track", "resolved_manifest": True}) + table.set_table_metadata({"plugin": "hifi", "view": "track", "resolved_manifest": True}) except Exception: pass results_payload: List[Dict[str, Any]] = [] diff --git a/plugins/internetarchive/__init__.py b/plugins/internetarchive/__init__.py index 9b39615..c87a148 100644 --- a/plugins/internetarchive/__init__.py +++ b/plugins/internetarchive/__init__.py @@ -10,11 +10,11 @@ from typing import Any, Dict, List, Optional from urllib.parse import quote, unquote, urlparse -from API.HTTP import _download_direct_file +from API.HTTP import download_direct_file from PluginCore.base import Provider, SearchResult from SYS.utils import sanitize_filename, unique_path from SYS.logger import log -from SYS.config import get_provider_block +from SYS.config import get_plugin_block # Helper for download-file: render selectable formats for a details URL. def maybe_show_formats_table( @@ -177,26 +177,20 @@ def _ia() -> Any: def _pick_provider_config(config: Any) -> Dict[str, Any]: if not isinstance(config, dict): return {} - provider = config.get("provider") - if not isinstance(provider, dict): - return {} - entry = provider.get("internetarchive") - if isinstance(entry, dict): - return entry - return {} + return get_plugin_block(config, "internetarchive") def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]]: """Resolve Archive.org credentials. Preference order: - 1) provider.internetarchive (email/username + password) - 2) provider.openlibrary (email + password) + 1) plugin.internetarchive (email/username + password) + 2) plugin.openlibrary (email + password) """ if not isinstance(config, dict): return None, None - ia_block = get_provider_block(config, "internetarchive") + ia_block = get_plugin_block(config, "internetarchive") if isinstance(ia_block, dict): email = ( ia_block.get("email") @@ -209,7 +203,7 @@ def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str] if email_text and password_text: return email_text, password_text - ol_block = get_provider_block(config, "openlibrary") + ol_block = get_plugin_block(config, "openlibrary") if isinstance(ol_block, dict): email = ol_block.get("email") password = ol_block.get("password") @@ -744,7 +738,7 @@ class InternetArchive(Provider): if tags: normalized["tags"] = tags normalized["media_kind"] = "book" - normalized["provider_action"] = "borrow" + normalized["plugin_action"] = "borrow" return normalized def validate(self) -> bool: @@ -993,7 +987,7 @@ class InternetArchive(Provider): pipeline_progress = None try: - direct_result = _download_direct_file( + direct_result = download_direct_file( raw_path, output_dir, quiet=quiet_mode, diff --git a/plugins/local/__init__.py b/plugins/local/__init__.py index 036c2ae..fdbe693 100644 --- a/plugins/local/__init__.py +++ b/plugins/local/__init__.py @@ -279,7 +279,7 @@ class Local(Provider): return { "hash": hash_value or "unknown", "store": "local", - "provider": self.name, + "plugin": self.name, "path": str(target_path), "tag": tags, "title": title or target_path.name, diff --git a/plugins/matrix/__init__.py b/plugins/matrix/__init__.py index 83559ce..47060ac 100644 --- a/plugins/matrix/__init__.py +++ b/plugins/matrix/__init__.py @@ -327,10 +327,10 @@ class Matrix(TablePluginMixin, Provider): self._init_reason: Optional[str] = None matrix_conf = ( - self.config.get("provider", - {}).get("matrix", - {}) if isinstance(self.config, - dict) else {} + self.config.get("plugin", + {}).get("matrix", + {}) if isinstance(self.config, + dict) else {} ) homeserver = matrix_conf.get("homeserver") access_token = matrix_conf.get("access_token") @@ -362,16 +362,16 @@ class Matrix(TablePluginMixin, Provider): return False if self._init_ok is False: return False - matrix_conf = self.config.get("provider", - {}).get("matrix", - {}) + matrix_conf = self.config.get("plugin", + {}).get("matrix", + {}) return bool( matrix_conf.get("homeserver") and matrix_conf.get("access_token") ) def status_summary(self) -> Dict[str, Any]: - matrix_conf = self.config.get("provider", {}).get("matrix", {}) if isinstance(self.config, dict) else {} + matrix_conf = self.config.get("plugin", {}).get("matrix", {}) if isinstance(self.config, dict) else {} homeserver = str(matrix_conf.get("homeserver") or "").strip() room_id = str(matrix_conf.get("room_id") or "").strip() detail = homeserver @@ -439,7 +439,7 @@ class Matrix(TablePluginMixin, Provider): full_metadata={ "room_id": room_id, "room_name": room_name, - "provider": "matrix", + "plugin": "matrix", # Selection metadata for table system and @N expansion "_selection_args": ["-room-id", room_id], }, @@ -450,9 +450,9 @@ class Matrix(TablePluginMixin, Provider): def _get_homeserver_and_token(self) -> Tuple[str, str]: - matrix_conf = self.config.get("provider", - {}).get("matrix", - {}) + matrix_conf = self.config.get("plugin", + {}).get("matrix", + {}) homeserver = matrix_conf.get("homeserver") access_token = matrix_conf.get("access_token") if not homeserver: @@ -681,7 +681,7 @@ class Matrix(TablePluginMixin, Provider): ) def upload(self, file_path: str, **kwargs: Any) -> str: - matrix_conf = self.config.get("provider", + matrix_conf = self.config.get("plugin", {}).get("matrix", {}) room_id = matrix_conf.get("room_id") @@ -877,7 +877,3 @@ try: except Exception: # best-effort registration pass - - -# Backward-compatible alias: tests and callers may import `plugins.matrix.cmdnat`. -from . import commands as cmdnat # noqa: E402 diff --git a/plugins/matrix/commands.py b/plugins/matrix/commands.py index e040614..edf8c64 100644 --- a/plugins/matrix/commands.py +++ b/plugins/matrix/commands.py @@ -58,8 +58,30 @@ def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]: return extract_arg_value(args, flags={"-set-value"}) +def _get_matrix_config_block(config: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(config, dict): + return {} + plugins = config.get("plugin") + if not isinstance(plugins, dict): + return {} + matrix_cfg = plugins.get("matrix") + return matrix_cfg if isinstance(matrix_cfg, dict) else {} + + +def _ensure_matrix_config_block(config: Dict[str, Any]) -> Dict[str, Any]: + plugins = config.setdefault("plugin", {}) + if not isinstance(plugins, dict): + plugins = {} + config["plugin"] = plugins + matrix_cfg = plugins.setdefault("matrix", {}) + if not isinstance(matrix_cfg, dict): + matrix_cfg = {} + plugins["matrix"] = matrix_cfg + return matrix_cfg + + def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool: - """Update the Matrix provider block in the shared config. + """Update the Matrix plugin block in the shared config. This method writes to the unified config store so changes persist between sessions. @@ -71,29 +93,13 @@ def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool: value_str = str(value) current_cfg = load_config() or {} - providers = current_cfg.setdefault("provider", {}) - if not isinstance(providers, dict): - providers = {} - current_cfg["provider"] = providers - - matrix_cfg = providers.setdefault("matrix", {}) - if not isinstance(matrix_cfg, dict): - matrix_cfg = {} - providers["matrix"] = matrix_cfg - + matrix_cfg = _ensure_matrix_config_block(current_cfg) matrix_cfg[key] = value_str save_config(current_cfg) # Keep the supplied config dict in sync for the running CLI - target_providers = config.setdefault("provider", {}) - if not isinstance(target_providers, dict): - target_providers = {} - config["provider"] = target_providers - target_matrix = target_providers.setdefault("matrix", {}) - if not isinstance(target_matrix, dict): - target_matrix = {} - target_providers["matrix"] = target_matrix + target_matrix = _ensure_matrix_config_block(config) target_matrix[key] = value_str return True except Exception as exc: @@ -103,13 +109,8 @@ def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool: def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]: try: - if not isinstance(config, dict): - return [] - providers = config.get("provider") - if not isinstance(providers, dict): - return [] - matrix_conf = providers.get("matrix") - if not isinstance(matrix_conf, dict): + matrix_conf = _get_matrix_config_block(config) + if not matrix_conf: return [] raw = None # Support a few common spellings; `room` is the documented key. @@ -138,16 +139,11 @@ def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]: def _get_matrix_size_limit_bytes(config: Dict[str, Any]) -> Optional[int]: """Return max allowed per-file size in bytes for Matrix uploads. - Config: [provider=Matrix] size_limit=50 # MB + Config: [plugin=matrix] size_limit=50 # MB """ try: - if not isinstance(config, dict): - return None - providers = config.get("provider") - if not isinstance(providers, dict): - return None - matrix_conf = providers.get("matrix") - if not isinstance(matrix_conf, dict): + matrix_conf = _get_matrix_config_block(config) + if not matrix_conf: return None raw = None @@ -236,7 +232,7 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str conf_ids = _parse_config_room_filter_ids(config) if conf_ids: # Attempt to fetch names for the configured IDs - block = config.get("provider", {}).get("matrix", {}) + block = _get_matrix_config_block(config) if block and block.get("homeserver") and block.get("access_token"): try: m = _get_matrix_provider(config) @@ -252,7 +248,7 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str pass # Last resort: attempt to ask the server for matching rooms (if possible) - block = config.get("provider", {}).get("matrix", {}) + block = _get_matrix_config_block(config) if block and block.get("homeserver") and block.get("access_token"): try: m = _get_matrix_provider(config) @@ -631,7 +627,7 @@ def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]: url = _resolve_plugin_url(url, config) try: - from API.HTTP import _download_direct_file + from API.HTTP import download_direct_file base_tmp = None if isinstance(config, dict): @@ -642,7 +638,7 @@ def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]: ) output_dir = output_dir / "matrix" output_dir.mkdir(parents=True, exist_ok=True) - result = _download_direct_file(url, output_dir, quiet=True) + result = download_direct_file(url, output_dir, quiet=True) if (result and hasattr(result, "path") and isinstance(result.path, Path) and result.path.exists()): @@ -691,10 +687,7 @@ def _show_settings_table(config: Dict[str, Any]) -> int: matrix_conf = {} try: - if isinstance(config, dict): - providers = config.get("provider") - if isinstance(providers, dict): - matrix_conf = providers.get("matrix") or {} + matrix_conf = _get_matrix_config_block(config) except Exception: pass diff --git a/plugins/metadata_plugin.py b/plugins/metadata_plugin.py index 8c633fe..2cc6d71 100644 --- a/plugins/metadata_plugin.py +++ b/plugins/metadata_plugin.py @@ -252,7 +252,7 @@ class ITunesMetadataPlugin(MetadataPlugin): "album": r.get("collectionName"), "year": str(r.get("releaseDate", ""))[:4], - "provider": self.name, + "plugin": self.name, "raw": r, } items.append(item) @@ -338,7 +338,7 @@ class OpenLibraryMetadataPlugin(MetadataPlugin): "artist": ", ".join(authors) if authors else "", "album": publisher, "year": str(doc.get("first_publish_year") or ""), - "provider": self.name, + "plugin": self.name, "authors": authors, "publisher": publisher, "identifiers": { @@ -460,7 +460,7 @@ class GoogleBooksMetadataPlugin(MetadataPlugin): "artist": ", ".join(authors) if authors else "", "album": publisher, "year": year, - "provider": self.name, + "plugin": self.name, "authors": authors, "publisher": publisher, "identifiers": identifiers, @@ -643,7 +643,7 @@ class ISBNsearchMetadataPlugin(MetadataPlugin): "artist": ", ".join(authors) if authors else "", "album": publisher or "", "year": year or "", - "provider": self.name, + "plugin": self.name, "authors": authors, "publisher": publisher or "", "language": language or "", @@ -787,7 +787,7 @@ class MusicBrainzMetadataPlugin(MetadataPlugin): "artist": artist, "album": album, "year": year, - "provider": self.name, + "plugin": self.name, "mbid": mbid, "raw": rec, } @@ -871,7 +871,7 @@ class ImdbMetadataPlugin(MetadataPlugin): "artist": "", "album": "", "year": str(year or ""), - "provider": self.name, + "plugin": self.name, "imdb_id": imdb_id, "raw": data, } @@ -908,7 +908,7 @@ class ImdbMetadataPlugin(MetadataPlugin): "artist": "", "album": kind, "year": year, - "provider": self.name, + "plugin": self.name, "imdb_id": imdb_id, "kind": kind, "rating": rating, @@ -1032,7 +1032,7 @@ class YtdlpMetadataPlugin(MetadataPlugin): "artist": str(artist or ""), "album": str(album or ""), "year": str(year or ""), - "provider": self.name, + "plugin": self.name, "url": url, "raw": info, } @@ -1214,7 +1214,7 @@ class YtdlpMetadataPlugin(MetadataPlugin): return None try: - from tool.ytdlp import is_url_supported_by_ytdlp + from plugins.ytdlp.tooling import is_url_supported_by_ytdlp for text in candidates: try: @@ -1322,7 +1322,7 @@ class YtdlpMetadataPlugin(MetadataPlugin): "artist": str(info.get("artist") or info.get("uploader") or info.get("channel") or ""), "album": str(info.get("album") or info.get("playlist_title") or ""), "year": str((str(info.get("release_date") or "") or str(info.get("upload_date") or ""))[:4]), - "provider": self.name, + "plugin": self.name, "url": str(url or "").strip(), "raw": info, } @@ -1927,7 +1927,7 @@ class TidalMetadataPlugin(MetadataPlugin): "year": year, "lyrics": lyrics, "tags": tags, - "provider": self.name, + "plugin": self.name, "path": getattr(result, "path", ""), "track_id": track_id, "full_metadata": metadata, diff --git a/plugins/mpv/LUA/main.lua b/plugins/mpv/LUA/main.lua index 31e7382..0a53b88 100644 --- a/plugins/mpv/LUA/main.lua +++ b/plugins/mpv/LUA/main.lua @@ -2878,7 +2878,7 @@ local function _start_screenshot_store_save(store, out_path, tags) screenshot_url = '' end local cmd = 'file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store) - .. ' -path ' .. quote_pipeline_arg(out_path) + .. ' ' .. quote_pipeline_arg(out_path) if screenshot_url ~= '' then cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url) end @@ -6367,7 +6367,7 @@ local function _start_trim_with_range(range) 'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(selected_store) .. - ' -path ' .. quote_pipeline_arg(output_path) .. + ' ' .. quote_pipeline_arg(output_path) .. ' | add-relationship -store "' .. selected_store .. '"' .. ' -to-hash ' .. quote_pipeline_arg(store_hash.hash) else @@ -6375,7 +6375,7 @@ local function _start_trim_with_range(range) 'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store_hash.store) .. - ' -path ' .. quote_pipeline_arg(output_path) .. + ' ' .. quote_pipeline_arg(output_path) .. ' | add-relationship -store "' .. store_hash.store .. '"' .. ' -to-hash ' .. quote_pipeline_arg(store_hash.hash) end @@ -6386,7 +6386,7 @@ local function _start_trim_with_range(range) _lua_log('trim: building file -add command to selected_store=' .. selected_store) -- Don't add title if empty - the file path will be used as title by default pipeline_cmd = 'file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(selected_store) .. - ' -path ' .. quote_pipeline_arg(output_path) + ' ' .. quote_pipeline_arg(output_path) _lua_log('trim: pipeline_cmd=' .. pipeline_cmd) else mp.osd_message('Trim complete: ' .. output_path, 5) diff --git a/plugins/mpv/pipeline_helper.py b/plugins/mpv/pipeline_helper.py index e094efa..16389e5 100644 --- a/plugins/mpv/pipeline_helper.py +++ b/plugins/mpv/pipeline_helper.py @@ -71,7 +71,7 @@ from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402 from SYS.repl_queue import enqueue_repl_command, repl_state_is_alive # noqa: E402 from SYS.utils import format_bytes # noqa: E402 from PluginCore.registry import get_plugin, get_plugin_class # noqa: E402 -from tool.ytdlp import get_display_format_id, get_selection_format_id # noqa: E402 +from plugins.ytdlp.tooling import get_display_format_id, get_selection_format_id # noqa: E402 REQUEST_PROP = "user-data/medeia-pipeline-request" RESPONSE_PROP = "user-data/medeia-pipeline-response" diff --git a/plugins/openlibrary/__init__.py b/plugins/openlibrary/__init__.py index 42b871e..9f7dedb 100644 --- a/plugins/openlibrary/__init__.py +++ b/plugins/openlibrary/__init__.py @@ -674,7 +674,7 @@ class OpenLibrary(Provider): if not isinstance(config, dict): return _DEFAULT_PREFERRED_LANGUAGE - entry = config.get("provider", {}).get("openlibrary", {}) + entry = config.get("plugin", {}).get("openlibrary", {}) if not isinstance(entry, dict): return _DEFAULT_PREFERRED_LANGUAGE @@ -1118,7 +1118,7 @@ class OpenLibrary(Provider): table = Table(f"OpenLibrary Editions: {title}")._perseverance(True) table.set_table("openlibrary.edition") try: - table.set_table_metadata({"provider": "openlibrary", "view": "borrowable_editions"}) + table.set_table_metadata({"plugin": "openlibrary", "view": "borrowable_editions"}) except Exception: pass table.set_source_command("search-file", ["-plugin", "openlibrary"]) @@ -1274,7 +1274,7 @@ class OpenLibrary(Provider): if not isinstance(config, dict): return None, None - entry = config.get("provider", {}).get("openlibrary", {}) + entry = config.get("plugin", {}).get("openlibrary", {}) if isinstance(entry, dict): email = entry.get("email") password = entry.get("password") @@ -1287,11 +1287,11 @@ class OpenLibrary(Provider): @classmethod def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int: - """Resolve Archive.org book-reader scale from provider config.""" + """Resolve Archive.org book-reader scale from plugin config.""" if not isinstance(config, dict): return _DEFAULT_ARCHIVE_SCALE - entry = config.get("provider", {}).get("openlibrary", {}) + entry = config.get("plugin", {}).get("openlibrary", {}) if not isinstance(entry, dict): return _DEFAULT_ARCHIVE_SCALE diff --git a/plugins/playwright/__init__.py b/plugins/playwright/__init__.py new file mode 100644 index 0000000..e563772 --- /dev/null +++ b/plugins/playwright/__init__.py @@ -0,0 +1,36 @@ +"""Playwright support module under the plugin namespace. + +This package provides shared browser automation defaults/helpers for cmdlets and +plugins. It is intentionally lightweight at import time so plugin discovery can +import `plugins.playwright` even when Playwright itself is not installed. +""" + +from __future__ import annotations + +__all__ = [ + "PlaywrightTimeoutError", + "PlaywrightTool", + "PlaywrightDefaults", + "PlaywrightDownloadResult", + "config_schema", +] + +_MODULE_ATTRS = { + "PlaywrightTimeoutError": ".runtime", + "PlaywrightTool": ".runtime", + "PlaywrightDefaults": ".runtime", + "PlaywrightDownloadResult": ".runtime", + "config_schema": ".runtime", +} + + +def __getattr__(name: str) -> object: + submod = _MODULE_ATTRS.get(name) + if submod is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from importlib import import_module + + mod = import_module(submod, package=__name__) + obj = getattr(mod, name) + globals()[name] = obj + return obj \ No newline at end of file diff --git a/tool/playwright.py b/plugins/playwright/runtime.py similarity index 95% rename from tool/playwright.py rename to plugins/playwright/runtime.py index 95c5bce..77ead33 100644 --- a/tool/playwright.py +++ b/plugins/playwright/runtime.py @@ -13,8 +13,17 @@ from typing import Any, Dict, Iterator, Optional, Union from SYS.config import get_nested_config_value as _get_nested from SYS.logger import debug -from playwright.sync_api import TimeoutError as PlaywrightTimeoutError -from playwright.sync_api import sync_playwright +try: + from playwright.sync_api import TimeoutError as _SyncPlaywrightTimeoutError + from playwright.sync_api import sync_playwright +except Exception: # pragma: no cover - handled at runtime + sync_playwright = None + + class PlaywrightTimeoutError(RuntimeError): + """Fallback timeout type when Playwright is unavailable.""" + +else: + PlaywrightTimeoutError = _SyncPlaywrightTimeoutError # Re-export for consumers (e.g. cmdlets catching navigation timeouts) __all__ = [ @@ -100,16 +109,16 @@ class PlaywrightTool: - user-agent/viewport defaults - ffmpeg path resolution (for video recording) - Config overrides (top-level keys): - - playwright.browser="chromium" - - playwright.headless=true - - playwright.user_agent="..." - - playwright.viewport_width=1280 - - playwright.viewport_height=1200 - - playwright.navigation_timeout_ms=90000 - - playwright.ignore_https_errors=true - - playwright.screenshot_quality=8 - - playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set) + Config overrides (plugin.playwright keys): + - plugin.playwright.browser="chromium" + - plugin.playwright.headless=true + - plugin.playwright.user_agent="..." + - plugin.playwright.viewport_width=1280 + - plugin.playwright.viewport_height=1200 + - plugin.playwright.navigation_timeout_ms=90000 + - plugin.playwright.ignore_https_errors=true + - plugin.playwright.screenshot_quality=8 + - plugin.playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set) FFmpeg resolution (in order): 1. Config key: playwright.ffmpeg_path @@ -127,22 +136,12 @@ class PlaywrightTool: def _load_defaults(self) -> PlaywrightDefaults: cfg = self._config defaults = PlaywrightDefaults() - tool_block = _get_nested(cfg, "tool", "playwright") - if not isinstance(tool_block, dict): - tool_block = {} - pw_block = cfg.get("playwright") if isinstance(cfg.get("playwright"), - dict) else {} + pw_block = _get_nested(cfg, "plugin", "playwright") if not isinstance(pw_block, dict): pw_block = {} def _get(name: str, fallback: Any) -> Any: - val = tool_block.get(name) - if val is None: - val = pw_block.get(name) - if val is None: - val = cfg.get(f"playwright_{name}") - if val is None: - val = _get_nested(cfg, "playwright", name) + val = pw_block.get(name) return fallback if val is None else val browser = str(_get("browser", defaults.browser)).strip().lower() or "chromium" @@ -211,7 +210,7 @@ class PlaywrightTool: if not ffmpeg_path: # Try to find bundled ffmpeg in the project (Windows-only, in MPV/ffmpeg/bin) try: - repo_root = Path(__file__).resolve().parent.parent + repo_root = Path(__file__).resolve().parents[2] bundled_ffmpeg = repo_root / "MPV" / "ffmpeg" / "bin" if bundled_ffmpeg.exists(): ffmpeg_exe = bundled_ffmpeg / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg") diff --git a/plugins/podcastindex/__init__.py b/plugins/podcastindex/__init__.py index 2a1f937..d4210c0 100644 --- a/plugins/podcastindex/__init__.py +++ b/plugins/podcastindex/__init__.py @@ -11,11 +11,11 @@ from SYS.utils import format_bytes def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]: - provider = config.get("provider") - if not isinstance(provider, dict): + plugin_cfg = config.get("plugin") + if not isinstance(plugin_cfg, dict): return "", "" - entry = provider.get("podcastindex") + entry = plugin_cfg.get("podcastindex") if not isinstance(entry, dict): return "", "" @@ -290,7 +290,7 @@ class PodcastIndex(Provider): pass try: - from API.HTTP import _download_direct_file + from API.HTTP import download_direct_file except Exception: return True @@ -308,7 +308,7 @@ class PodcastIndex(Provider): title_hint = str(item.get("title") or md.get("title") or "episode").strip() or "episode" try: - result_obj = _download_direct_file( + result_obj = download_direct_file( enc_url, Path(output_dir), quiet=False, @@ -357,12 +357,12 @@ class PodcastIndex(Provider): "path": str(local_path), "hash": sha256, "title": title_hint, - "action": "provider:podcastindex.selector", + "action": "plugin:podcastindex.selector", "download_mode": "file", "store": "local", "media_kind": "audio", "tag": tags, - "provider": "podcastindex", + "plugin": "podcastindex", "url": enc_url, } if isinstance(md, dict) and md: diff --git a/plugins/scp/__init__.py b/plugins/scp/__init__.py index aa8b847..66824e9 100644 --- a/plugins/scp/__init__.py +++ b/plugins/scp/__init__.py @@ -390,7 +390,7 @@ class SCP(Provider): table.set_table("scp") try: table.set_table_metadata({ - "provider": "scp", + "plugin": "scp", "instance": instance_name or None, "host": settings.get("host"), "path": target_path, @@ -958,7 +958,7 @@ class SCP(Provider): parent = posixpath.dirname(scp_path.rstrip("/")) or "/" instance_name = str(settings.get("instance") or "").strip() metadata = { - "provider": "scp", + "plugin": "scp", "instance": instance_name or None, "host": settings.get("host"), "scp_path": scp_path, diff --git a/plugins/soulseek/__init__.py b/plugins/soulseek/__init__.py index 5c8b469..8e899e4 100644 --- a/plugins/soulseek/__init__.py +++ b/plugins/soulseek/__init__.py @@ -690,7 +690,7 @@ class Soulseek(Provider): "album": item["album"], "track_num": item["track_num"], "ext": item["ext"], - "provider": "soulseek" + "plugin": "soulseek" }, ) ) diff --git a/plugins/telegram/__init__.py b/plugins/telegram/__init__.py index 4d40c7f..237856a 100644 --- a/plugins/telegram/__init__.py +++ b/plugins/telegram/__init__.py @@ -194,7 +194,7 @@ class Telegram(Provider): def __init__(self, config: Optional[Dict[str, Any]] = None): super().__init__(config) telegram_conf = ( - self.config.get("provider", + self.config.get("plugin", {}).get("telegram", {}) if isinstance(self.config, dict) else {} @@ -1280,7 +1280,7 @@ class Telegram(Provider): info: Dict[str, Any] = { - "provider": "telegram", + "plugin": "telegram", "source_url": url, "chat": { "key": chat, diff --git a/plugins/tidal/__init__.py b/plugins/tidal/__init__.py index a9c0aa9..fc2e47b 100644 --- a/plugins/tidal/__init__.py +++ b/plugins/tidal/__init__.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse from plugins.tidal.api import ( - Tidal as TidalApiClient, + Tidal, build_track_tags, coerce_duration_seconds, extract_artists, @@ -208,7 +208,7 @@ class Tidal(Provider): self.api_timeout = float(self.config.get("timeout", 10.0)) except Exception: self.api_timeout = 10.0 - self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls] + self.api_clients = [Tidal(base_url=url, timeout=self.api_timeout) for url in self.api_urls] def resolve_playback_path(self, item: Any, **_kwargs: Any) -> Optional[str]: return resolve_tidal_manifest_path(item) @@ -960,7 +960,7 @@ class Tidal(Provider): try: table.set_table_metadata( { - "provider": "tidal", + "plugin": "tidal", "view": "track", "album_id": album_id, "album_title": album_title, @@ -1670,7 +1670,7 @@ class Tidal(Provider): return downloaded_count - def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]: + def _get_api_client_for_base(self, base_url: str) -> Optional[Tidal]: base = base_url.rstrip("/") for client in self.api_clients: if getattr(client, "base_url", "").rstrip("/") == base: @@ -2206,7 +2206,7 @@ class Tidal(Provider): table = Table(f"Tidal Albums: {artist_name}")._perseverance(False) table.set_table("tidal.album") try: - table.set_table_metadata({"provider": "tidal", "view": "album", "artist_id": artist_id, "artist_name": artist_name}) + table.set_table_metadata({"plugin": "tidal", "view": "album", "artist_id": artist_id, "artist_name": artist_name}) except Exception: pass @@ -2268,7 +2268,7 @@ class Tidal(Provider): try: table.set_table_metadata( { - "provider": "tidal", + "plugin": "tidal", "view": "track", "album_id": album_id, "album_title": album_title, @@ -2338,7 +2338,7 @@ class Tidal(Provider): table = Table("Tidal Track")._perseverance(True) table.set_table("tidal.track") try: - table.set_table_metadata({"provider": "tidal", "view": "track", "resolved_manifest": True}) + table.set_table_metadata({"plugin": "tidal", "view": "track", "resolved_manifest": True}) except Exception: pass results_payload: List[Dict[str, Any]] = [] diff --git a/plugins/tidal/api/__init__.py b/plugins/tidal/api/__init__.py index ef64322..15db8dc 100644 --- a/plugins/tidal/api/__init__.py +++ b/plugins/tidal/api/__init__.py @@ -320,8 +320,3 @@ class Tidal(API): border_style="cyan", ) return res - - -# Legacy alias for TidalApiClient -TidalApiClient = Tidal -HifiApiClient = Tidal diff --git a/plugins/vimm/__init__.py b/plugins/vimm/__init__.py index 948111e..00df0b5 100644 --- a/plugins/vimm/__init__.py +++ b/plugins/vimm/__init__.py @@ -20,7 +20,7 @@ from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments from PluginCore.inline_utils import resolve_filter from SYS.logger import debug, debug_panel from SYS.plugin_helpers import TablePluginMixin -from tool.playwright import PlaywrightTool +from plugins.playwright import PlaywrightTool class Vimm(TablePluginMixin, Provider): diff --git a/plugins/ytdlp/__init__.py b/plugins/ytdlp/__init__.py index f0fecaf..ef48b63 100644 --- a/plugins/ytdlp/__init__.py +++ b/plugins/ytdlp/__init__.py @@ -23,12 +23,13 @@ from SYS.result_table import Table from SYS.rich_display import stderr_console as get_stderr_console from SYS import pipeline as pipeline_context from SYS.utils import sha256_file -from tool.ytdlp import ( +from .tooling import ( YtDlpTool, _best_subtitle_sidecar, _SUBTITLE_EXTS, _download_with_timeout, _format_chapters_note, + config_schema as _ytdlp_config_schema, _read_text_file, collapse_picker_formats, format_for_table_selection, @@ -508,6 +509,10 @@ class ytdlp(TablePluginMixin, Provider): PLUGIN_ALIASES = ("youtube",) SEARCH_QUERY_KEYS = ("search", "q") + @staticmethod + def config_schema() -> List[Dict[str, Any]]: + return _ytdlp_config_schema() + @classmethod def url_patterns(cls) -> Tuple[str, ...]: try: diff --git a/tool/ytdlp.py b/plugins/ytdlp/tooling.py similarity index 98% rename from tool/ytdlp.py rename to plugins/ytdlp/tooling.py index d685ca4..0e58cac 100644 --- a/tool/ytdlp.py +++ b/plugins/ytdlp/tooling.py @@ -997,60 +997,43 @@ class YtDlpTool: # default string value. Use an instance for fallback defaults. _fallback_defaults = YtDlpDefaults() - tool_block = _get_nested(cfg, "tool", "ytdlp") - if not isinstance(tool_block, dict): - tool_block = {} - - ytdlp_block = cfg.get("ytdlp") if isinstance(cfg.get("ytdlp"), - dict) else {} + ytdlp_block = _get_nested(cfg, "plugin", "ytdlp") if not isinstance(ytdlp_block, dict): ytdlp_block = {} - # Accept both nested and flat styles. video_format = ( - tool_block.get("video_format") or tool_block.get("format") - or ytdlp_block.get("video_format") or ytdlp_block.get("video") - or ytdlp_block.get("format_video") or cfg.get("ytdlp_video_format") + ytdlp_block.get("video_format") or ytdlp_block.get("video") + or ytdlp_block.get("format_video") ) audio_format = ( - tool_block.get("audio_format") or ytdlp_block.get("audio_format") - or ytdlp_block.get("audio") or ytdlp_block.get("format_audio") - or cfg.get("ytdlp_audio_format") + ytdlp_block.get("audio_format") or ytdlp_block.get("audio") + or ytdlp_block.get("format_audio") ) - # Also accept dotted keys written as nested dicts: ytdlp.format.video, ytdlp.format.audio - nested_video = _get_nested(cfg, "ytdlp", "format", "video") - nested_audio = _get_nested(cfg, "ytdlp", "format", "audio") + nested_video = _get_nested(cfg, "plugin", "ytdlp", "format", "video") + nested_audio = _get_nested(cfg, "plugin", "ytdlp", "format", "audio") fmt_sort_val = ( - tool_block.get("format_sort") or ytdlp_block.get("format_sort") - or ytdlp_block.get("formatSort") or cfg.get("ytdlp_format_sort") + ytdlp_block.get("format_sort") + or ytdlp_block.get("formatSort") or _get_nested(cfg, + "plugin", "ytdlp", "format", "sort") ) fmt_sort = _parse_csv_list(fmt_sort_val) - # Cookie source preference: allow forcing a browser DB or 'auto'/'none' cookies_pref = ( - tool_block.get("cookies_from_browser") - or tool_block.get("cookiesfrombrowser") - or ytdlp_block.get("cookies_from_browser") + ytdlp_block.get("cookies_from_browser") or ytdlp_block.get("cookiesfrombrowser") - or cfg.get("ytdlp_cookies_from_browser") - or _get_nested(cfg, "ytdlp", "cookies_from_browser") + or _get_nested(cfg, "plugin", "ytdlp", "cookies_from_browser") ) - # Unified format preference: prefer explicit 'format' key but accept legacy keys format_pref = ( - tool_block.get("format") - or tool_block.get("video_format") - or ytdlp_block.get("format") + ytdlp_block.get("format") or ytdlp_block.get("video_format") - or cfg.get("ytdlp_format") - or cfg.get("ytdlp_video_format") - or _get_nested(cfg, "ytdlp", "format") + or _get_nested(cfg, "plugin", "ytdlp", "format") ) defaults = YtDlpDefaults( @@ -1121,7 +1104,7 @@ class YtDlpTool: ) try: - repo_root = Path(__file__).resolve().parents[1] + repo_root = Path(__file__).resolve().parents[2] bundled_ffmpeg_dir = repo_root / "MPV" / "ffmpeg" / "bin" if bundled_ffmpeg_dir.exists(): base_options.setdefault("ffmpeg_location", str(bundled_ffmpeg_dir)) diff --git a/scripts/run_client.py b/scripts/run_client.py index d1dfa55..efa0ef0 100644 --- a/scripts/run_client.py +++ b/scripts/run_client.py @@ -978,7 +978,7 @@ def main(argv: Optional[List[str]] = None) -> int: p.add_argument( "--pull", action="store_true", - help="Force a repository update before starting the client (legacy alias; startup update is enabled by default)", + help="Force a repository update before starting the client", ) p.add_argument( "--no-update", diff --git a/tool/__init__.py b/tool/__init__.py deleted file mode 100644 index bbd0336..0000000 --- a/tool/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tool helpers. - -This package contains wrappers around external tools (e.g. yt-dlp) so cmdlets can share -common defaults (cookies, timeouts, format selectors) and users can override them via -`config.conf`. -""" -from __future__ import annotations - -# Lazy-loaded to avoid pulling in yt_dlp, playwright, and their heavy transitive -# dependencies (~1–2 s) at package import time. Each submodule is loaded only when -# a name from it is first accessed through this package namespace. - -__all__ = [ - "YtDlpTool", - "YtDlpDefaults", - "PlaywrightTool", - "PlaywrightDefaults", - "FlorenceVisionTool", - "FlorenceVisionDefaults", -] - -_MODULE_ATTRS = { - "YtDlpTool": ".ytdlp", - "YtDlpDefaults": ".ytdlp", - "PlaywrightTool": ".playwright", - "PlaywrightDefaults": ".playwright", - "FlorenceVisionTool": ".florencevision", - "FlorenceVisionDefaults": ".florencevision", -} - - -def __getattr__(name: str) -> object: - submod = _MODULE_ATTRS.get(name) - if submod is None: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - from importlib import import_module - mod = import_module(submod, package=__name__) - obj = getattr(mod, name) - # Cache on this module so subsequent accesses bypass __getattr__. - globals()[name] = obj - return obj -