update and cleanup repo

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