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__) logger = logging.getLogger(__name__)
from API.ssl_certs import resolve_verify_value as _resolve_verify_value 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 from API.httpx_shared import get_shared_httpx_client
# Default configuration # Default configuration
@@ -924,11 +923,6 @@ def download_direct_file(
) )
raise DownloadError(f"Error downloading file: {exc}") from exc raise DownloadError(f"Error downloading file: {exc}") from exc
# Back-compat alias
_download_direct_file = download_direct_file
class AsyncHTTPClient: class AsyncHTTPClient:
"""Unified async HTTP client with asyncio support.""" """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") logger.exception("Failed to probe certifi for trust bundle")
return True 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 ( from cmdnat._status_shared import (
add_startup_check as _shared_add_startup_check, add_startup_check as _shared_add_startup_check,
collect_plugin_startup_checks as _collect_plugin_startup_checks, 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]: def store_choices(config: Dict[str, Any], force: bool = False) -> List[str]:
try: try:
# Use the cached startup check from SharedArgs # 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) return SharedArgs.get_store_choices(config, force=force)
except Exception: except Exception:
return [] return []
@@ -1361,7 +1361,7 @@ class CmdletCompleter(Completer):
) )
if choices: if choices:
choice_list = 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() current_lower = current_token.lower()
filtered = [c for c in choices if c.lower().startswith(current_lower)] filtered = [c for c in choices if c.lower().startswith(current_lower)]
if filtered: if filtered:
@@ -1665,20 +1665,10 @@ class CmdletExecutor:
mod = import_cmd_module(cmd_name, reload_loaded=True) mod = import_cmd_module(cmd_name, reload_loaded=True)
data = getattr(mod, "CMDLET", None) if mod else None data = getattr(mod, "CMDLET", None) if mod else None
if data and hasattr(data, "exec") and callable(getattr(data, "exec")): 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") run_fn = getattr(data, "exec")
registered_names = set() for registered_name in collect_registered_cmdlet_names(data, fallback_name=cmd_name):
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:
REGISTRY[registered_name] = run_fn REGISTRY[registered_name] = run_fn
cmd_fn = run_fn cmd_fn = run_fn
except Exception: except Exception:
@@ -2336,7 +2326,7 @@ class CLI:
# Initialize the store choices cache at startup (filters disabled stores) # Initialize the store choices cache at startup (filters disabled stores)
try: try:
from cmdlet._shared import SharedArgs from SYS.cmdlet_spec import SharedArgs
config = self._config_loader.load() config = self._config_loader.load()
SharedArgs._refresh_store_choices_cache(config) SharedArgs._refresh_store_choices_cache(config)
except Exception: 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"), files=check.get("files"),
) )
# Tool checks (configured via [tool=...]) # Plugin support checks (configured via [plugin=...])
if _has_tool(config, "florencevision"): if _has_provider(config, "florencevision"):
try: try:
tool_cfg = config.get("tool") plugin_cfg = config.get("plugin")
fv_cfg = tool_cfg.get("florencevision") if isinstance(tool_cfg, dict) else None 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 enabled = bool(fv_cfg.get("enabled")) if isinstance(fv_cfg, dict) else False
if not enabled: if not enabled:
_add_startup_check( _add_startup_check(
"DISABLED", "DISABLED",
"FlorenceVision", "FlorenceVision",
provider="tool", provider="plugin",
detail="Not enabled", detail="Not enabled",
) )
else: else:
@@ -2643,21 +2633,21 @@ Come to love it when others take what you share, as there is no greater joy
_add_startup_check( _add_startup_check(
"DISABLED", "DISABLED",
"FlorenceVision", "FlorenceVision",
provider="tool", provider="plugin",
detail="Missing: " + ", ".join(missing), detail="Missing: " + ", ".join(missing),
) )
else: else:
_add_startup_check( _add_startup_check(
"ENABLED", "ENABLED",
"FlorenceVision", "FlorenceVision",
provider="tool", provider="plugin",
detail="Ready", detail="Ready",
) )
except Exception as exc: except Exception as exc:
_add_startup_check( _add_startup_check(
"DISABLED", "DISABLED",
"FlorenceVision", "FlorenceVision",
provider="tool", provider="plugin",
detail=str(exc), detail=str(exc),
) )
except Exception as 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. # Backends that failed to initialize earlier in the current process.
# Keyed by (backend_type, instance_key) where instance_key is the configured name # 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] = {} _FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {}
@@ -186,12 +186,12 @@ class BackendRegistry:
self._load_backends() self._load_backends()
def _load_backends(self) -> None: def _load_backends(self) -> None:
store_cfg = self._config.get("store") plugin_cfg = self._config.get("plugin")
if not isinstance(store_cfg, dict): if not isinstance(plugin_cfg, dict):
store_cfg = {} plugin_cfg = {}
self._backend_types = {} 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): if not isinstance(instances, dict):
continue 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.""" """Return configured backend instance names without instantiating backends."""
try: try:
names: list[str] = [] names: list[str] = []
for section_name in ("store", "plugin", "provider"): for section_name in ("plugin",):
section_cfg = (config or {}).get(section_name) or {} section_cfg = (config or {}).get(section_name) or {}
if not isinstance(section_cfg, dict): if not isinstance(section_cfg, dict):
continue continue
@@ -403,7 +403,7 @@ def get_backend_instance(
return None return None
desired = str(backend_name or "").strip().lower() 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 {} section_cfg = (config or {}).get(section_name) or {}
if not isinstance(section_cfg, dict): if not isinstance(section_cfg, dict):
continue continue
+5 -14
View File
@@ -323,20 +323,11 @@ class Provider(ABC):
def plugin_config_root(self) -> Dict[str, Any]: def plugin_config_root(self) -> Dict[str, Any]:
if not isinstance(self.config, dict): if not isinstance(self.config, dict):
return {} return {}
# Check plugin/provider section first (preferred new format) section_cfg = self.config.get("plugin")
for section in ("plugin", "provider"): if isinstance(section_cfg, dict):
section_cfg = self.config.get(section) entry = section_cfg.get(self.plugin_config_key())
if isinstance(section_cfg, dict): if isinstance(entry, dict):
entry = section_cfg.get(self.plugin_config_key()) return dict(entry)
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)
return {} return {}
def plugin_instance_configs(self) -> Dict[str, Dict[str, Any]]: def plugin_instance_configs(self) -> Dict[str, Dict[str, Any]]:
+17 -9
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import importlib.util
from importlib import import_module from importlib import import_module
from pathlib import Path import pkgutil
from typing import Any, Callable, Dict, Iterable, Sequence 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]: def iter_plugin_command_module_names() -> list[str]:
try: try:
repo_root = Path(__file__).resolve().parent.parent package = import_module("plugins")
except Exception: except Exception:
return [] return []
plugins_dir = repo_root / "plugins" package_path = getattr(package, "__path__", None)
if not plugins_dir.is_dir(): if not package_path:
return [] return []
module_names: list[str] = [] module_names: list[str] = []
for entry in sorted(plugins_dir.iterdir(), key=lambda path: path.name.lower()): seen: set[str] = set()
if not entry.is_dir() or entry.name.startswith("."): for _, module_name, is_package in pkgutil.iter_modules(package_path):
if not is_package or module_name.startswith("_"):
continue 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 continue
if (entry / "commands.py").is_file() or (entry / "commands" / "__init__.py").is_file(): if commands_module in seen:
module_names.append(f"plugins.{entry.name}.commands") continue
seen.add(commands_module)
module_names.append(commands_module)
return module_names 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 __future__ import annotations
from typing import Any, Dict, List, Optional 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. """Collect normalized inline/query argument choice entries from a provider.
Supports QUERY_ARG_CHOICES, INLINE_QUERY_FIELD_CHOICES, and the 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}. `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: if normalized:
mapping[target_key] = normalized mapping[target_key] = normalized
base = getattr(provider, "QUERY_ARG_CHOICES", None) def _merge_mapping(source: Any) -> None:
if isinstance(base, dict): if not isinstance(source, dict):
for k, v in base.items(): return
for k, v in source.items():
key_norm = str(k).strip().lower() key_norm = str(k).strip().lower()
if not key_norm: if not key_norm:
continue continue
_ingest(v, key_norm) _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: try:
fn = getattr(provider, "inline_query_field_choices", None) fn = getattr(provider, "inline_query_field_choices", None)
if callable(fn): if callable(fn):
extra = fn() _merge_mapping(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)
except Exception: except Exception:
pass pass
+33 -111
View File
@@ -23,6 +23,7 @@ from urllib.parse import urlparse
from SYS.logger import log, debug from SYS.logger import log, debug
from PluginCore.base import Provider, SearchResult 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") _EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
@@ -150,6 +151,14 @@ class PluginInfo:
exposed = True exposed = True
return exposed and _class_supports_method(self.plugin_class, "upload", Provider.upload) 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 @property
def is_multi_instance(self) -> bool: def is_multi_instance(self) -> bool:
"""True if the plugin declares MULTI_INSTANCE = True.""" """True if the plugin declares MULTI_INSTANCE = True."""
@@ -542,6 +551,7 @@ def get_plugin_capabilities(
"supported_cmdlets": [], "supported_cmdlets": [],
"supports_search": False, "supports_search": False,
"supports_upload": False, "supports_upload": False,
"supports_download": False,
"supports_pipe_download": False, "supports_pipe_download": False,
"supports_delete_file": False, "supports_delete_file": False,
"supports_url_association": False, "supports_url_association": False,
@@ -582,6 +592,7 @@ def get_plugin_capabilities(
"supported_cmdlets": supported_cmdlets, "supported_cmdlets": supported_cmdlets,
"supports_search": bool(info.supports_search), "supports_search": bool(info.supports_search),
"supports_upload": bool(info.supports_upload), "supports_upload": bool(info.supports_upload),
"supports_download": bool(info.supports_download),
"supports_pipe_download": bool(supports_pipe_download), "supports_pipe_download": bool(supports_pipe_download),
"supports_delete_file": bool(supports_delete_file), "supports_delete_file": bool(supports_delete_file),
"supports_url_association": bool(supports_url_association), "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) 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: def _supports_pipe_result_download(provider: Provider) -> bool:
return _class_supports_method( return _class_supports_method(
provider.__class__, provider.__class__,
@@ -647,6 +666,8 @@ def _supports_capability(provider: Provider, capability: str) -> bool:
return _supports_search(provider) return _supports_search(provider)
if capability_key in {"upload", "file", "file-provider"}: if capability_key in {"upload", "file", "file-provider"}:
return _supports_upload(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"}: if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
return _supports_pipe_result_download(provider) return _supports_pipe_result_download(provider)
if capability_key in {"delete-file", "delete_file", "delete"}: 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) return bool(info.supports_search)
if capability_key in {"upload", "file", "file-provider"}: if capability_key in {"upload", "file", "file-provider"}:
return bool(info.supports_upload) 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"}: if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
return _class_supports_method( return _class_supports_method(
info.plugin_class, info.plugin_class,
@@ -697,62 +720,6 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
return False 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]: def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
info = REGISTRY.get(name) info = REGISTRY.get(name)
if info is None: 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. """Return plugin names that support `capability` AND have configuration present.
For MULTI_INSTANCE plugins (e.g. hydrusnetwork, ftp) the plugin must have at 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 least one configured instance. For single-instance plugins the plugin's section
must exist under config["plugin"] or config["provider"]. must exist under config["plugin"].
""" """
cfg = config or {} cfg = config or {}
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment] 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] = [] result: List[str] = []
for info in REGISTRY.iter_plugins(): for info in REGISTRY.iter_plugins():
@@ -862,7 +828,7 @@ def list_configured_plugin_names_with_capability(
pass pass
else: else:
pname = name.lower() 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) result.append(name)
return sorted(result) return sorted(result)
@@ -890,7 +856,7 @@ def list_plugin_names_for_cmdlet(
fallback_capability = { fallback_capability = {
"search-file": "search", "search-file": "search",
"add-file": "upload", "add-file": "upload",
"download-file": "search", "download-file": "download",
"delete-file": "delete-file", "delete-file": "delete-file",
}.get(cmd) }.get(cmd)
@@ -905,12 +871,11 @@ def list_plugin_names_for_cmdlet(
if fallback_capability: if fallback_capability:
configured.update(list_configured_plugin_names_with_capability(fallback_capability, cfg)) 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] 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: for name in supported:
key = str(name or "").strip().lower() 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) configured.add(name)
return sorted(configured) return sorted(configured)
@@ -995,13 +960,13 @@ def plugin_inline_query_choices(
mapping: Dict[str, List[Dict[str, Any]]] = {} mapping: Dict[str, List[Dict[str, Any]]] = {}
info = REGISTRY.get(pname) info = REGISTRY.get(pname)
if info is not None: if info is not None:
mapping = _collect_inline_choice_mapping(info.plugin_class) mapping = collect_choice(info.plugin_class)
if not mapping: if not mapping:
plugin = get_plugin(pname, config) plugin = get_plugin(pname, config)
if plugin is None: if plugin is None:
return [] return []
mapping = _collect_inline_choice_mapping(plugin) mapping = collect_choice(plugin)
if not mapping: if not mapping:
return [] return []
@@ -1065,52 +1030,9 @@ def resolve_inline_filters(
*, *,
field_transforms: Optional[Dict[str, Any]] = None, field_transforms: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]: ) -> 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. return resolve_filter(provider, inline_args, field_transforms=field_transforms)
- 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
def clear_plugin_cache() -> None: def clear_plugin_cache() -> None:
+3 -3
View File
@@ -410,10 +410,10 @@ def get_cmdlet_arg_choices(
matrix_conf = {} matrix_conf = {}
try: try:
providers = config.get("provider") or {} plugins = config.get("plugin") or {}
matrix_conf = providers.get("matrix") or {} matrix_conf = plugins.get("matrix") or {}
except Exception as exc: 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 = {} matrix_conf = {}
raw = None 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: class SharedArgs:
"""Registry of shared CmdletArg definitions used across multiple cmdlet.""" """Registry of shared CmdletArg definitions used across multiple cmdlet."""
@@ -100,6 +144,13 @@ class SharedArgs:
description="selects plugin", description="selects plugin",
) )
INSTANCE = CmdletArg(
name="instance",
type="string",
description="Selects a plugin instance",
query_key="instance",
)
@staticmethod @staticmethod
def get_store_choices(config: Optional[Dict[str, Any]] = None, force: bool = False) -> List[str]: def get_store_choices(config: Optional[Dict[str, Any]] = None, force: bool = False) -> List[str]:
if not force and hasattr(SharedArgs, "_cached_available_stores"): 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: def _log_config_load_summary(config: Dict[str, Any]) -> None:
try: try:
plugin_block = config.get("plugin") plugin_block = config.get("plugin")
if not isinstance(plugin_block, dict):
plugin_block = config.get("provider")
if isinstance(plugin_block, dict): if isinstance(plugin_block, dict):
# Count distinct plugin names; note multi-instance plugins appear once per name # Count distinct plugin names; note multi-instance plugins appear once per name
plugin_names = list(plugin_block.keys()) plugin_names = list(plugin_block.keys())
@@ -265,7 +263,9 @@ def set_nested_config_value(
def get_hydrus_instance( def get_hydrus_instance(
config: Dict[str, Any], instance_name: str = "home" config: Dict[str, Any], instance_name: str = "home"
) -> Optional[Dict[str, Any]]: ) -> 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]]: def _lookup_in(source: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not isinstance(source, dict) or not source: if not isinstance(source, dict) or not source:
return None return None
@@ -286,16 +286,13 @@ def get_hydrus_instance(
candidate = source.get(first_key) if first_key else None candidate = source.get(first_key) if first_key else None
return candidate if isinstance(candidate, dict) else None return candidate if isinstance(candidate, dict) else None
# New format: config["plugin"]["hydrusnetwork"] or config["provider"]["hydrusnetwork"] plugin_cfg = config.get("plugin")
# (both point to the same dict after normalization) if isinstance(plugin_cfg, dict):
for section in ("plugin", "provider"): hydrus_cfg = plugin_cfg.get("hydrusnetwork")
section_cfg = config.get(section) if isinstance(hydrus_cfg, dict):
if isinstance(section_cfg, dict): result = _lookup_in(hydrus_cfg)
hydrus_cfg = section_cfg.get("hydrusnetwork") if result is not None:
if isinstance(hydrus_cfg, dict): return result
result = _lookup_in(hydrus_cfg)
if result is not None:
return result
return None 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 return str(url).strip() if url else None
def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]: def get_plugin_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
_normalize_plugin_config_aliases(config) _canonicalize_plugin_config(config)
provider_cfg = config.get("provider") plugin_cfg = config.get("plugin")
if not isinstance(provider_cfg, dict): if not isinstance(plugin_cfg, dict):
return {} return {}
normalized = _normalize_provider_name(name) normalized = _normalize_provider_name(name)
if normalized: if normalized:
block = provider_cfg.get(normalized) block = plugin_cfg.get(normalized)
if isinstance(block, dict): if isinstance(block, dict):
return block return block
for key, block in provider_cfg.items(): for key, block in plugin_cfg.items():
if not isinstance(block, dict): if not isinstance(block, dict):
continue continue
if _normalize_provider_name(key) == normalized: 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]: 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") val = block.get("username") or block.get("USERNAME")
return str(val).strip() if val else None return str(val).strip() if val else None
def get_soulseek_password(config: Dict[str, Any]) -> Optional[str]: 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") val = block.get("password") or block.get("PASSWORD")
return str(val).strip() if val else None 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]: 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: This helper is intentionally narrow: it reports a real local library/export
- Old: config["storage"]["local"]["path"] root only when the canonical `plugin.local` config defines one. Callers that
- Old: config["Local"]["path"] want a staging/output directory should use `resolve_output_dir(...)` instead.
Args:
config: Configuration dict
Returns:
Path object if found, None otherwise
""" """
# Fall back to storage.local.path format local_block = get_plugin_block(config, "local")
storage = config.get("storage", {}) if not isinstance(local_block, dict) or not local_block:
if isinstance(storage, dict): return None
local_config = storage.get("local", {})
if isinstance(local_config, dict):
path_str = local_config.get("path")
if path_str:
return expand_path(path_str)
# Fall back to old Local format if _is_multi_instance_plugin_config(local_block):
local_config = config.get("Local", {}) if "default" in local_block and isinstance(local_block.get("default"), dict):
if isinstance(local_config, dict): local_config = local_block.get("default")
path_str = local_config.get("path") else:
if path_str: local_config = next(
return expand_path(path_str) (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 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]: def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> Optional[str]:
"""Get Debrid API key from config. """Get Debrid API key from config.
Checks the plugin/provider block first (canonical format). Checks the plugin block first (canonical format).
Args: Args:
config: Configuration dict config: Configuration dict
@@ -458,37 +455,23 @@ def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> O
Returns: Returns:
API key string if found, None otherwise API key string if found, None otherwise
""" """
# 1) Canonical plugin/provider block: config["plugin"]["alldebrid"]["api_key"] _canonicalize_plugin_config(config)
provider_block = config.get("provider") or config.get("plugin")
if isinstance(provider_block, dict): # 1) Canonical plugin block: config["plugin"]["alldebrid"]["api_key"]
alldebrid_entry = provider_block.get("alldebrid") plugin_block = config.get("plugin")
if isinstance(plugin_block, dict):
alldebrid_entry = plugin_block.get("alldebrid")
if isinstance(alldebrid_entry, dict): if isinstance(alldebrid_entry, dict):
for k in ("api_key", "API_KEY", "apikey", "APIKEY"): for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
val = alldebrid_entry.get(k) val = alldebrid_entry.get(k)
if isinstance(val, str) and val.strip(): if isinstance(val, str) and val.strip():
return 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 return None
def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]: def get_plugin_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]:
"""Get provider credentials (email/password) from config. """Get plugin credentials (email/password) from config.
Supports both formats:
- New: config["provider"][provider] = {"email": "...", "password": "..."}
- Old: config[provider.capitalize()] = {"email": "...", "password": "..."}
Args: Args:
config: Configuration dict config: Configuration dict
@@ -497,22 +480,11 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[
Returns: Returns:
Dict with credentials if found, None otherwise Dict with credentials if found, None otherwise
""" """
# Try new format first _canonicalize_plugin_config(config)
provider_config = config.get("provider", {})
if isinstance(provider_config, dict):
creds = provider_config.get(provider.lower(), {})
if isinstance(creds, dict) and creds:
return creds
# Fall back to old format (capitalized key) plugin_config = config.get("plugin", {})
old_key_map = { if isinstance(plugin_config, dict):
"openlibrary": "OpenLibrary", creds = plugin_config.get(provider.lower(), {})
"archive": "Archive",
"soulseek": "Soulseek",
}
old_key = old_key_map.get(provider.lower())
if old_key:
creds = config.get(old_key, {})
if isinstance(creds, dict) and creds: if isinstance(creds, dict) and creds:
return creds return creds
@@ -522,19 +494,19 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[
def resolve_cookies_path( def resolve_cookies_path(
config: Dict[str, Any], script_dir: Optional[Path] = None config: Dict[str, Any], script_dir: Optional[Path] = None
) -> Optional[Path]: ) -> Optional[Path]:
# Only support modular config style: # Only support plugin config style:
# [tool=ytdlp] # [plugin=ytdlp]
# cookies="C:\\path\\cookies.txt" # cookies="C:\\path\\cookies.txt"
values: list[Any] = [] values: list[Any] = []
try: try:
tool = config.get("tool") plugin = config.get("plugin")
if isinstance(tool, dict): if isinstance(plugin, dict):
ytdlp = tool.get("ytdlp") ytdlp = plugin.get("ytdlp")
if isinstance(ytdlp, dict): if isinstance(ytdlp, dict):
values.append(ytdlp.get("cookies")) values.append(ytdlp.get("cookies"))
values.append(ytdlp.get("cookiefile")) values.append(ytdlp.get("cookiefile"))
except Exception as exc: 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) base_dir = _resolve_app_root(script_dir)
for value in values: for value in values:
@@ -627,54 +599,26 @@ def resolve_plugin_asset_path(
return None 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): if not isinstance(config, dict):
return return
config.pop("provider", None)
config.pop("store", None)
plugin_block = config.get("plugin") plugin_block = config.get("plugin")
provider_block = config.get("provider")
normalized_provider: Dict[str, Any] = {} normalized_plugin: 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
if isinstance(plugin_block, dict): if isinstance(plugin_block, dict):
for key, value in plugin_block.items(): for key, value in plugin_block.items():
normalized_key = _normalize_provider_name(key) normalized_key = _normalize_provider_name(key)
if normalized_key and normalized_key not in normalized_provider: if normalized_key:
normalized_provider[normalized_key] = value normalized_plugin[normalized_key] = value
# Fold legacy config["store"] entries into the plugin namespace. if normalized_plugin or isinstance(plugin_block, dict):
# store format: {type: {instance_name: {key: val}}} — multi-instance. config["plugin"] = normalized_plugin
# 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
else: else:
if isinstance(provider_block, dict): config.pop("plugin", None)
config["plugin"] = provider_block
elif isinstance(plugin_block, dict):
config["provider"] = plugin_block
def _extract_api_key(value: Any) -> Optional[str]: def _extract_api_key(value: Any) -> Optional[str]:
if isinstance(value, dict): if isinstance(value, dict):
@@ -698,40 +642,24 @@ def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None:
if not isinstance(config, dict): if not isinstance(config, dict):
return return
_normalize_plugin_config_aliases(config) _canonicalize_plugin_config(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)
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: def _is_multi_instance_plugin_config(value: Any) -> bool:
"""Return True if `value` looks like a multi-instance plugin config (dict-of-dicts). """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]: def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]:
entries: 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(): for key, value in config.items():
if key == 'plugin': if key == 'plugin' and isinstance(value, dict):
# plugin == provider after normalization; skip duplicate
continue
if key == 'provider' and isinstance(value, dict):
for subtype, plugin_cfg in value.items(): for subtype, plugin_cfg in value.items():
if not isinstance(plugin_cfg, dict): if not isinstance(plugin_cfg, dict):
continue continue
@@ -773,21 +698,13 @@ def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str,
else: else:
# Single-instance: {key: val} # Single-instance: {key: val}
for k, v in plugin_cfg.items(): for k, v in plugin_cfg.items():
entries[('provider', subtype, 'default', k)] = v entries[('plugin', subtype, 'default', k)] = v
elif key in ('store', 'tool') and isinstance(value, dict): elif key == 'tool' and isinstance(value, dict):
for subtype, instances in value.items(): for subtype, instances in value.items():
if not isinstance(instances, dict): if not isinstance(instances, dict):
continue continue
if key == 'store': for k, v in instances.items():
# Legacy store: migrate to plugin category entries[('tool', subtype, 'default', k)] = v
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
elif not key.startswith('_') and value is not None: elif not key.startswith('_') and value is not None:
entries[('global', 'none', 'none', key)] = value entries[('global', 'none', 'none', key)] = value
return entries return entries
@@ -817,14 +734,6 @@ def _config_from_flattened_entries(
config[key] = value config[key] = value
continue 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": if category == "plugin":
plugin_block = config.setdefault("plugin", {}) plugin_block = config.setdefault("plugin", {})
subtype_block = plugin_block.setdefault(subtype, {}) subtype_block = plugin_block.setdefault(subtype, {})
@@ -835,7 +744,7 @@ def _config_from_flattened_entries(
item_block[key] = value item_block[key] = value
continue continue
if category in {"provider", "tool"}: if category == "tool":
category_block = config.setdefault(category, {}) category_block = config.setdefault(category, {})
subtype_block = category_block.setdefault(subtype, {}) subtype_block = category_block.setdefault(subtype, {})
subtype_block[key] = value subtype_block[key] = value
@@ -849,7 +758,7 @@ def _config_from_flattened_entries(
if isinstance(item_block, dict): if isinstance(item_block, dict):
item_block[key] = value item_block[key] = value
_normalize_plugin_config_aliases(config) _canonicalize_plugin_config(config)
_sync_alldebrid_api_key(config) _sync_alldebrid_api_key(config)
return config return config
@@ -880,9 +789,9 @@ def _merge_non_conflicting_config_changes(
def _extract_expected_alldebrid_key(config: Dict[str, Any]) -> Optional[str]: def _extract_expected_alldebrid_key(config: Dict[str, Any]) -> Optional[str]:
expected_key = None expected_key = None
try: try:
providers = config.get("provider", {}) if isinstance(config, dict) else {} plugins = config.get("plugin", {}) if isinstance(config, dict) else {}
if isinstance(providers, dict): if isinstance(plugins, dict):
entry = providers.get("alldebrid") entry = plugins.get("alldebrid")
if entry is not None: if entry is not None:
if isinstance(entry, dict): if isinstance(entry, dict):
for k in ("api_key", "API_KEY", "apikey", "APIKEY"): 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 _CONFIG_SUMMARY_PENDING = False
return _CONFIG_CACHE 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 # Load strictly from database
db_config = get_config_all() db_config = get_config_all()
if db_config: if db_config:
_normalize_plugin_config_aliases(db_config) _canonicalize_plugin_config(db_config)
_sync_alldebrid_api_key(db_config) _sync_alldebrid_api_key(db_config)
_CONFIG_CACHE = db_config _CONFIG_CACHE = db_config
_LAST_SAVED_CONFIG = deepcopy(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: def save_config(config: Dict[str, Any]) -> int:
global _CONFIG_CACHE, _LAST_SAVED_CONFIG global _CONFIG_CACHE, _LAST_SAVED_CONFIG
_normalize_plugin_config_aliases(config) _canonicalize_plugin_config(config)
_sync_alldebrid_api_key(config) _sync_alldebrid_api_key(config)
# Acquire cross-process save lock to avoid concurrent saves from different # 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 # Proceed with writing when no conflicting external changes detected
conn.execute("DELETE FROM config") conn.execute("DELETE FROM config")
for key, value in config_to_write.items(): 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(): for subtype, instances in value.items():
if not isinstance(instances, dict): if not isinstance(instances, dict):
continue continue
if key == 'store': if key == 'plugin':
for name, settings in instances.items(): normalized_subtype = _normalize_provider_name(subtype)
if isinstance(settings, dict): 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(): for k, v in settings.items():
val_str = json.dumps(v) if not isinstance(v, str) else v val_str = json.dumps(v) if not isinstance(v, str) else v
conn.execute( conn.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", "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 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: else:
normalized_subtype = subtype
if key == 'provider':
normalized_subtype = _normalize_provider_name(subtype)
if not normalized_subtype:
continue
for k, v in instances.items(): for k, v in instances.items():
val_str = json.dumps(v) if not isinstance(v, str) else v val_str = json.dumps(v) if not isinstance(v, str) else v
conn.execute( conn.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", "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 count += 1
else: 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. # Nothing special to verify; return success.
return saved 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() clear_config_cache()
reloaded = load_config() 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: try:
store_key = get_debrid_api_key(reloaded, service="All-debrid") reloaded_key = _extract_expected_alldebrid_key(reloaded)
except Exception: except Exception:
store_key = None reloaded_key = None
if prov_key == expected_key or store_key == expected_key: if reloaded_key == expected_key:
try: try:
# Log a short, masked fingerprint to aid debugging without exposing the key itself # Log a short, masked fingerprint to aid debugging without exposing the key itself
import hashlib import hashlib
+13 -51
View File
@@ -504,67 +504,29 @@ def rows_to_config(rows) -> Dict[str, Any]:
if cat == 'global': if cat == 'global':
config[key] = parsed_val config[key] = parsed_val
else: else:
# Modular structure: config[cat][sub][name][key] # Modular structure: config[category][subtype][item_name?][key]
if cat in ('provider', 'tool'): 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, {}) cat_dict = config.setdefault(cat, {})
sub_dict = cat_dict.setdefault(sub, {}) sub_dict = cat_dict.setdefault(sub, {})
sub_dict[key] = parsed_val 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: else:
config.setdefault(cat, {})[key] = parsed_val config.setdefault(cat, {})[key] = parsed_val
return config 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]: 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") rows = db.fetchall("SELECT category, subtype, item_name, key, value FROM config")
return rows_to_config(rows) 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 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. # use their own mapping when they need full SearchResult objects.
from PluginCore.base import SearchResult # local import to avoid circular issues 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] = [] out: List[SearchResult] = []
for rec in records: for rec in records:
title = rec.get("title") or rec.get("name") or "" title = rec.get("title") or rec.get("name") or ""
+3 -3
View File
@@ -49,7 +49,7 @@ class PipeObject:
hash: str hash: str
store: str store: str
provider: Optional[str] = None plugin: Optional[str] = None
tag: List[str] = field(default_factory=list) tag: List[str] = field(default_factory=list)
title: Optional[str] = None title: Optional[str] = None
url: Optional[str] = None url: Optional[str] = None
@@ -144,8 +144,8 @@ class PipeObject:
"store": self.store, "store": self.store,
} }
if self.provider: if self.plugin:
data["provider"] = self.provider data["plugin"] = self.plugin
if self.tag: if self.tag:
data["tag"] = 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]]: def _provider_missing_modules(config: Dict[str, Any]) -> Dict[str, List[str]]:
missing: Dict[str, List[str]] = {} missing: Dict[str, List[str]] = {}
provider_cfg = (config or {}).get("provider") plugin_cfg = (config or {}).get("plugin")
if not isinstance(provider_cfg, dict): if not isinstance(plugin_cfg, dict):
return missing return missing
for provider_name, requirements in _PROVIDER_DEPENDENCIES.items(): 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: if not isinstance(block, dict) or not block:
continue continue
missing_for_provider = [ missing_for_provider = [
+4 -4
View File
@@ -191,11 +191,11 @@ def coerce_to_pipe_object(
pipe_obj = models.PipeObject( pipe_obj = models.PipeObject(
hash=hash_val, hash=hash_val,
store=store_val, store=store_val,
provider=str( plugin=str(
value.get("provider") value.get("plugin")
or value.get("prov") or value.get("prov")
or value.get("source") or value.get("source")
or extra.get("provider") or extra.get("plugin")
or extra.get("source") or extra.get("source")
or "" or ""
).strip() ).strip()
@@ -253,7 +253,7 @@ def coerce_to_pipe_object(
pipe_obj = models.PipeObject( pipe_obj = models.PipeObject(
hash=hash_val, hash=hash_val,
store=store_val, store=store_val,
provider=None, plugin=None,
path=str(path_val) if path_val and path_val != "unknown" else None, path=str(path_val) if path_val and path_val != "unknown" else None,
title=title_val, title=title_val,
url=url_val, url=url_val,
+3 -19
View File
@@ -1545,19 +1545,16 @@ class PipelineExecutor:
table_meta = meta if isinstance(meta, dict) else None table_meta = meta if isinstance(meta, dict) else None
if isinstance(meta, dict): if isinstance(meta, dict):
_add(meta.get("plugin")) _add(meta.get("plugin"))
_add(meta.get("provider"))
except Exception: except Exception:
logger.exception("Failed to inspect current_table/table metadata in _maybe_run_class_selector") logger.exception("Failed to inspect current_table/table metadata in _maybe_run_class_selector")
for item in selected_items or []: for item in selected_items or []:
if isinstance(item, dict): if isinstance(item, dict):
_add(item.get("plugin")) _add(item.get("plugin"))
_add(item.get("provider"))
_add(item.get("store")) _add(item.get("store"))
_add(item.get("table")) _add(item.get("table"))
else: else:
_add(getattr(item, "plugin", None)) _add(getattr(item, "plugin", None))
_add(getattr(item, "provider", None))
_add(getattr(item, "store", None)) _add(getattr(item, "store", None))
_add(getattr(item, "table", None)) _add(getattr(item, "table", None))
@@ -1664,17 +1661,14 @@ class PipelineExecutor:
meta = None meta = None
if isinstance(meta, dict): if isinstance(meta, dict):
_add(meta.get("plugin")) _add(meta.get("plugin"))
_add(meta.get("provider"))
for item in selected_items or []: for item in selected_items or []:
if isinstance(item, dict): if isinstance(item, dict):
_add(item.get("plugin")) _add(item.get("plugin"))
_add(item.get("provider"))
_add(item.get("table")) _add(item.get("table"))
_add(item.get("source")) _add(item.get("source"))
else: else:
_add(getattr(item, "plugin", None)) _add(getattr(item, "plugin", None))
_add(getattr(item, "provider", None))
_add(getattr(item, "table", None)) _add(getattr(item, "table", None))
_add(getattr(item, "source", None)) _add(getattr(item, "source", None))
@@ -3129,20 +3123,10 @@ class PipelineExecutor:
mod = import_cmd_module(cmd_name, reload_loaded=True) mod = import_cmd_module(cmd_name, reload_loaded=True)
data = getattr(mod, "CMDLET", None) if mod else None data = getattr(mod, "CMDLET", None) if mod else None
if data and hasattr(data, "exec") and callable(getattr(data, "exec")): 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") run_fn = getattr(data, "exec")
registered_names = set() for registered_name in collect_registered_cmdlet_names(data, fallback_name=cmd_name):
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:
REGISTRY[registered_name] = run_fn REGISTRY[registered_name] = run_fn
cmd_fn = run_fn cmd_fn = run_fn
except Exception: except Exception:
+51 -51
View File
@@ -14,6 +14,36 @@ logger = logging.getLogger(__name__)
ConfigField = Dict[str, Any] 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]: def _normalize_schema(fields: Optional[Iterable[Any]]) -> List[ConfigField]:
normalized: List[ConfigField] = [] normalized: List[ConfigField] = []
seen: set[str] = set() 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]: def get_store_schema(store_type: str) -> List[ConfigField]:
"""Return config schema for a store type. """Return config schema for a store type.
After the storeplugin migration, store types are plugins. We look up the Store types are now plugins. We look up the plugin schema by name; if not
plugin schema by name; if not found we return an empty list. found we return an empty list.
""" """
normalized = str(store_type or "").strip() return get_plugin_schema(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)
def get_plugin_schema(plugin_name: str) -> List[ConfigField]: def get_plugin_schema(plugin_name: str) -> List[ConfigField]:
plugin_class = get_plugin_class(str(plugin_name or "").strip()) normalized_name = str(plugin_name or "").strip()
if plugin_class is None: if not normalized_name:
return [] 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]: module = _import_plugin_support_module(normalized_name)
tool_name = str(tool_name or "").strip() if module is None:
if not tool_name:
return [] return []
try: return _call_schema(module, f"plugin support '{normalized_name}'")
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}'")
def get_item_schema(item_type: str, item_name: str) -> List[ConfigField]: def get_item_schema(item_type: str, item_name: str) -> List[ConfigField]:
normalized_type = str(item_type or "").strip() normalized_type = str(item_type or "").strip()
normalized_name = str(item_name 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-"): if normalized_type.startswith("plugin-"):
# Multi-instance plugin: plugin-{ptype}; item_name is the instance name # Multi-instance plugin: plugin-{ptype}; item_name is the instance name
ptype = normalized_type[len("plugin-"):] ptype = normalized_type[len("plugin-"):]
return get_plugin_schema(ptype) return get_plugin_schema(ptype)
if normalized_type in {"provider", "plugin"}: if normalized_type == "plugin":
return get_plugin_schema(normalized_name) return get_plugin_schema(normalized_name)
if normalized_type == "tool":
return get_tool_schema(normalized_name)
return [] return []
@@ -143,13 +163,6 @@ def build_default_plugin_config(plugin_name: str) -> Dict[str, Any]:
return config 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]: def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
normalized_type = str(item_type or "").strip() normalized_type = str(item_type or "").strip()
normalized_name = str(item_name 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"): if field.get("required"):
_add_key(field.get("key")) _add_key(field.get("key"))
if normalized_type.startswith("plugin-") or normalized_type.startswith("store-"): if normalized_type.startswith("plugin-"):
# Multi-instance plugin (plugin-{ptype}) or legacy store-{type}: look up by plugin name # Multi-instance plugin (plugin-{ptype}): look up by plugin name.
ptype = normalized_type.replace("plugin-", "", 1).replace("store-", "", 1) ptype = normalized_type.replace("plugin-", "", 1)
plugin_class = get_plugin_class(ptype) plugin_class = get_plugin_class(ptype)
if plugin_class is not None: if plugin_class is not None:
try: try:
@@ -180,7 +193,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
_add_key(required_key) _add_key(required_key)
except Exception: except Exception:
logger.exception("Failed to load required config keys for plugin '%s'", ptype) 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) plugin_class = get_plugin_class(normalized_name)
if plugin_class is not None: if plugin_class is not None:
try: try:
@@ -211,20 +224,7 @@ def get_configurable_plugin_types() -> List[str]:
plugin_cls = info.plugin_class plugin_cls = info.plugin_class
if get_plugin_schema(info.canonical_name) or getattr(plugin_cls, 'MULTI_INSTANCE', False): if get_plugin_schema(info.canonical_name) or getattr(plugin_cls, 'MULTI_INSTANCE', False):
options.append(info.canonical_name) options.append(info.canonical_name)
return sorted(set(options)) for module_name in _iter_plugin_module_names():
if get_plugin_schema(module_name):
options.append(module_name)
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")
return sorted(set(options)) return sorted(set(options))
+1 -1
View File
@@ -51,7 +51,7 @@ class Plugin:
raise RuntimeError(f"plugin '{self.name}' adapter failed") from exc raise RuntimeError(f"plugin '{self.name}' adapter failed") from exc
cols = self.get_columns(rows) 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]: def serialize_row(self, row: ResultModel) -> Dict[str, Any]:
r = ensure_result_model(row) r = ensure_result_model(row)
+5 -5
View File
@@ -35,21 +35,21 @@ class ResultModel:
@dataclass(frozen=True) @dataclass(frozen=True)
class ResultTable: 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 This is intentionally minimal: it only stores rows, column specs, and
optional metadata used by renderers. It does not auto-normalize legacy optional metadata used by renderers. It does not auto-normalize legacy
objects or infer columns. objects or infer columns.
""" """
provider: str plugin: str
rows: List[ResultModel] rows: List[ResultModel]
columns: List[ColumnSpec] columns: List[ColumnSpec]
meta: Dict[str, Any] = field(default_factory=dict) meta: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not str(self.provider or "").strip(): if not str(self.plugin or "").strip():
raise ValueError("provider required for ResultTable") raise ValueError("plugin required for ResultTable")
object.__setattr__(self, "rows", [ensure_result_model(r) for r in self.rows]) object.__setattr__(self, "rows", [ensure_result_model(r) for r in self.rows])
if not self.columns: if not self.columns:
raise ValueError("columns are required for ResultTable") raise ValueError("columns are required for ResultTable")
@@ -70,7 +70,7 @@ class ResultTable:
"ext": r.ext, "ext": r.ext,
"size_bytes": r.size_bytes, "size_bytes": r.size_bytes,
"metadata": r.metadata or {}, "metadata": r.metadata or {},
"source": r.source or self.provider, "source": r.source or self.plugin,
"_selection_args": list(selection or []), "_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: 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: Args:
config: Configuration dictionary config: Configuration dictionary
service: Service name for logging 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: Returns:
API key if found and not empty, None otherwise API key if found and not empty, None otherwise
+2 -11
View File
@@ -81,7 +81,7 @@ class Worker:
""" """
try: try:
if self.manager: if self.manager:
self.manager.append_worker_stdout(self.id, text) self.manager.append_stdout(self.id, text)
else: else:
self._stdout_buffer.append(text) self._stdout_buffer.append(text)
except Exception as e: except Exception as e:
@@ -232,7 +232,7 @@ class WorkerLoggingHandler(logging.StreamHandler):
log_text = "\n".join(self.buffer) log_text = "\n".join(self.buffer)
try: try:
if self.manager: if self.manager:
self.manager.append_worker_stdout( self.manager.append_stdout(
self.worker_id, self.worker_id,
log_text, log_text,
channel="log" channel="log"
@@ -872,15 +872,6 @@ class WorkerManager:
logger.error(f"[WorkerManager] Error getting stdout: {e}", exc_info=True) logger.error(f"[WorkerManager] Error getting stdout: {e}", exc_info=True)
return "" 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: def clear_stdout(self, worker_id: str) -> bool:
"""Clear stdout logs for a worker. """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) # Initialize the store choices cache at startup (filters disabled stores)
try: try:
from cmdlet._shared import SharedArgs from SYS.cmdlet_spec import SharedArgs
config = load_config() config = load_config()
SharedArgs._refresh_store_choices_cache(config) SharedArgs._refresh_store_choices_cache(config)
except Exception: except Exception:
@@ -617,13 +617,9 @@ class PipelineHubApp(App):
try: try:
cfg = load_config() or {} cfg = load_config() or {}
plugin_block = cfg.get("plugin") 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 [] 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 "") 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)'}), db={db.db_path.name}")
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}")
except Exception: except Exception:
logger.exception("Failed to produce startup config summary") 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.""" """Call when the config modal is dismissed to reload session data."""
try: try:
from SYS.config import load_config, clear_config_cache 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 # Force a fresh load from disk
clear_config_cache() clear_config_cache()
cfg = load_config() 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 SYS.logger import log, debug, debug_panel
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
from dataclasses import dataclass, field
from SYS import models from SYS import models
from SYS import pipeline as pipeline_context from SYS import pipeline as pipeline_context
from SYS.item_accessors import get_field as _item_accessor_get_field 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_publication import publish_result_table
from SYS.result_table import Table from SYS.result_table import Table
from SYS.rich_display import stderr_console as get_stderr_console 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 rich.prompt import Confirm
from contextlib import AbstractContextManager, nullcontext 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 (loaded from JSON config file)
_TAG_GROUPS_CACHE: Optional[Dict[str, List[str]]] = None _TAG_GROUPS_CACHE: Optional[Dict[str, List[str]]] = None
_TAG_GROUPS_MTIME: Optional[float] = None _TAG_GROUPS_MTIME: Optional[float] = None
@@ -566,240 +43,6 @@ def set_tag_groups_path(path: Path) -> None:
TAG_GROUPS_PATH = path 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( def resolve_target_dir(
parsed: Dict[str, Any], parsed: Dict[str, Any],
config: Dict[str, Any], config: Dict[str, Any],
@@ -3011,20 +2254,6 @@ def collapse_namespace_tags(
kept_ns = True kept_ns = True
result.append(text) result.append(text)
return result 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]: def extract_tag_from_result(result: Any) -> list[str]:
"""Extract all tags from a result dict or PipeObject. """Extract all tags from a result dict or PipeObject.
@@ -3395,11 +2624,11 @@ def coerce_to_pipe_object(
pipe_obj = models.PipeObject( pipe_obj = models.PipeObject(
hash=hash_val, hash=hash_val,
store=store_val, store=store_val,
provider=str( plugin=str(
value.get("provider") value.get("plugin")
or value.get("prov") or value.get("prov")
or value.get("source") or value.get("source")
or extra.get("provider") or extra.get("plugin")
or extra.get("source") or extra.get("source")
or "" or ""
).strip() or None, ).strip() or None,
@@ -3456,7 +2685,7 @@ def coerce_to_pipe_object(
pipe_obj = models.PipeObject( pipe_obj = models.PipeObject(
hash=hash_val, hash=hash_val,
store=store_val, store=store_val,
provider=None, plugin=None,
path=str(path_val) if path_val and path_val != "unknown" else None, path=str(path_val) if path_val and path_val != "unknown" else None,
title=title_val, title=title_val,
url=url_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.rich_display import show_available_plugins_panel, show_plugin_config_panel
from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS
from PluginCore.backend_registry import BackendRegistry 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 from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
@@ -31,7 +31,7 @@ merge_sequences = sh.merge_sequences
extract_relationships = sh.extract_relationships extract_relationships = sh.extract_relationships
extract_duration = sh.extract_duration extract_duration = sh.extract_duration
coerce_to_pipe_object = sh.coerce_to_pipe_object 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_target_dir = sh.resolve_target_dir
resolve_media_kind_by_extension = sh.resolve_media_kind_by_extension resolve_media_kind_by_extension = sh.resolve_media_kind_by_extension
coerce_to_path = sh.coerce_to_path coerce_to_path = sh.coerce_to_path
@@ -102,10 +102,10 @@ def _maybe_apply_florencevision_tags(
config: Dict[str, Any], config: Dict[str, Any],
pipe_obj: Optional[models.PipeObject] = None, pipe_obj: Optional[models.PipeObject] = None,
) -> List[str]: ) -> List[str]:
"""Optionally auto-tag images using the FlorenceVision tool. """Optionally auto-tag images using the FlorenceVision plugin helper.
Controlled via config: Controlled via config:
[tool=florencevision] [plugin=florencevision]
enabled=true enabled=true
strict=false strict=false
@@ -114,8 +114,8 @@ def _maybe_apply_florencevision_tags(
""" """
strict = False strict = False
try: try:
tool_block = (config or {}).get("tool") plugin_block = (config or {}).get("plugin")
fv_block = tool_block.get("florencevision") if isinstance(tool_block, dict) else None fv_block = plugin_block.get("florencevision") if isinstance(plugin_block, dict) else None
enabled = False enabled = False
if isinstance(fv_block, dict): if isinstance(fv_block, dict):
enabled = bool(fv_block.get("enabled")) enabled = bool(fv_block.get("enabled"))
@@ -123,7 +123,7 @@ def _maybe_apply_florencevision_tags(
if not enabled: if not enabled:
return tags return tags
from tool.florencevision import FlorenceVisionTool from plugins.florencevision import FlorenceVisionTool
# Special-case: if this file was produced by the `screen-shot` cmdlet, # Special-case: if this file was produced by the `screen-shot` cmdlet,
# OCR is more useful than caption/detection for tagging screenshots. # OCR is more useful than caption/detection for tagging screenshots.
@@ -134,12 +134,12 @@ def _maybe_apply_florencevision_tags(
if action.lower().startswith("cmdlet:"): if action.lower().startswith("cmdlet:"):
cmdlet_name = action.split(":", 1)[1].strip().lower() cmdlet_name = action.split(":", 1)[1].strip().lower()
if cmdlet_name in {"screen-shot", "screen_shot", "screenshot"}: if cmdlet_name in {"screen-shot", "screen_shot", "screenshot"}:
tool_block2 = dict((config or {}).get("tool") or {}) plugin_block2 = dict((config or {}).get("plugin") or {})
fv_block2 = dict(tool_block2.get("florencevision") or {}) fv_block2 = dict(plugin_block2.get("florencevision") or {})
fv_block2["task"] = "ocr" fv_block2["task"] = "ocr"
tool_block2["florencevision"] = fv_block2 plugin_block2["florencevision"] = fv_block2
cfg_for_tool = dict(config or {}) cfg_for_tool = dict(config or {})
cfg_for_tool["tool"] = tool_block2 cfg_for_tool["plugin"] = plugin_block2
except Exception: except Exception:
cfg_for_tool = config cfg_for_tool = config
@@ -1237,7 +1237,7 @@ class Add_File(Cmdlet):
except Exception: except Exception:
pass pass
downloaded = _download_direct_file( downloaded = download_direct_file(
url_text, url_text,
download_root, download_root,
quiet=False, quiet=False,
@@ -1693,9 +1693,8 @@ class Add_File(Cmdlet):
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]: ) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
plugin_key = None plugin_key = None
for source in ( for source in (
pipe_obj.provider, pipe_obj.plugin,
get_field(result, "plugin"), get_field(result, "plugin"),
get_field(result, "provider"),
get_field(result, "table"), get_field(result, "table"),
): ):
candidate = Add_File._normalize_provider_key(source) candidate = Add_File._normalize_provider_key(source)
@@ -1760,7 +1759,7 @@ class Add_File(Cmdlet):
str(r_hash), str(r_hash),
source_url, source_url,
) )
downloaded = _download_direct_file( downloaded = download_direct_file(
source_url, source_url,
download_dir, download_dir,
quiet=True, quiet=True,
@@ -2028,7 +2027,7 @@ class Add_File(Cmdlet):
*, *,
hash_value: str, hash_value: str,
store: str, store: str,
provider: Optional[str] = None, plugin: Optional[str] = None,
path: Optional[str], path: Optional[str],
tag: List[str], tag: List[str],
title: Optional[str], title: Optional[str],
@@ -2037,7 +2036,7 @@ class Add_File(Cmdlet):
) -> None: ) -> None:
pipe_obj.hash = hash_value pipe_obj.hash = hash_value
pipe_obj.store = store pipe_obj.store = store
pipe_obj.provider = provider pipe_obj.plugin = plugin
pipe_obj.is_temp = False pipe_obj.is_temp = False
pipe_obj.path = path pipe_obj.path = path
pipe_obj.tag = tag pipe_obj.tag = tag
@@ -2260,7 +2259,7 @@ class Add_File(Cmdlet):
t for t in tags_from_result t for t in tags_from_result
if not str(t).strip().lower().startswith("title:") 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], [normalize_title_tag(t) for t in sidecar_tags],
"title", "title",
prefer="last" prefer="last"
@@ -2449,15 +2448,15 @@ class Add_File(Cmdlet):
or "unknown" or "unknown"
).strip() or "unknown" ).strip() or "unknown"
store_value = str(payload.get("store") or "").strip() store_value = str(payload.get("store") or "").strip()
provider_value = payload.get("provider") plugin_value = payload.get("plugin")
if provider_value is None and plugin_name: if plugin_value is None and plugin_name:
provider_value = plugin_name plugin_value = plugin_name
Add_File._update_pipe_object_destination( Add_File._update_pipe_object_destination(
pipe_obj, pipe_obj,
hash_value=hash_value, hash_value=hash_value,
store=store_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, path=path_value,
tag=tag_values, tag=tag_values,
title=title_value, title=title_value,
@@ -2584,7 +2583,7 @@ class Add_File(Cmdlet):
pipe_obj, pipe_obj,
hash_value=f_hash or "unknown", hash_value=f_hash or "unknown",
store="", store="",
provider=plugin_name or None, plugin=plugin_name or None,
path=file_path, path=file_path,
tag=pipe_obj.tag, tag=pipe_obj.tag,
title=pipe_obj.title or (media_path.name if media_path else None), 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]: def _hydrus_instance_names(config: Dict[str, Any]) -> Set[str]:
instances: Set[str] = set() instances: Set[str] = set()
try: try:
store_cfg = config.get("store") if isinstance(config, dict) else None plugin_cfg = config.get("plugin") if isinstance(config, dict) else None
if isinstance(store_cfg, dict): if isinstance(plugin_cfg, dict):
hydrus_cfg = store_cfg.get("hydrusnetwork") hydrus_cfg = plugin_cfg.get("hydrusnetwork")
if isinstance(hydrus_cfg, dict): if isinstance(hydrus_cfg, dict):
instances = { instances = {
str(k).strip().lower() str(k).strip().lower()
+3 -3
View File
@@ -133,13 +133,13 @@ class Delete_File(sh.Cmdlet):
provider_name = None provider_name = None
full_metadata: Dict[str, Any] = {} full_metadata: Dict[str, Any] = {}
if isinstance(item, dict): 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") raw_meta = item.get("full_metadata") or item.get("metadata")
if isinstance(raw_meta, dict): if isinstance(raw_meta, dict):
full_metadata = raw_meta full_metadata = raw_meta
else: else:
try: 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: except Exception:
pass pass
try: try:
@@ -542,4 +542,4 @@ class Delete_File(sh.Cmdlet):
# Instantiate and register the cmdlet # Instantiate and register the cmdlet
Delete_File() CMDLET = Delete_File()
+9 -10
View File
@@ -19,7 +19,7 @@ import shutil
import webbrowser 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.models import DownloadError, DownloadOptions, DownloadMediaResult
from SYS.logger import log, debug_panel, is_debug_enabled from SYS.logger import log, debug_panel, is_debug_enabled
from SYS.payload_builders import build_file_result_payload, build_table_result_payload from SYS.payload_builders import build_file_result_payload, build_table_result_payload
@@ -235,7 +235,7 @@ class Download_File(Cmdlet):
action = str( action = str(
result.get("action") result.get("action")
or result.get("provider_action") or result.get("plugin_action")
or "" or ""
).strip().lower() ).strip().lower()
@@ -338,12 +338,12 @@ class Download_File(Cmdlet):
path_value: Optional[Any] = path path_value: Optional[Any] = path
if isinstance(path, dict): if isinstance(path, dict):
provider_action = str( plugin_action = str(
path.get("action") path.get("action")
or path.get("provider_action") or path.get("plugin_action")
or "" or ""
).strip().lower() ).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 {} request_metadata = path.get("metadata") or path.get("full_metadata") or {}
if not isinstance(request_metadata, dict): if not isinstance(request_metadata, dict):
request_metadata = {} request_metadata = {}
@@ -522,7 +522,7 @@ class Download_File(Cmdlet):
# Direct Download Fallback # Direct Download Fallback
attempted_download = True attempted_download = True
result_obj = _download_direct_file( result_obj = download_direct_file(
str(url), str(url),
final_output_dir, final_output_dir,
quiet=quiet_mode, quiet=quiet_mode,
@@ -569,7 +569,7 @@ class Download_File(Cmdlet):
key = self._normalize_provider_key(table_hint) key = self._normalize_provider_key(table_hint)
if key: if key:
return key return key
provider_hint = get_field(item, "provider") provider_hint = get_field(item, "plugin")
key = self._normalize_provider_key(provider_hint) key = self._normalize_provider_key(provider_hint)
if key: if key:
return key return key
@@ -743,7 +743,7 @@ class Download_File(Cmdlet):
and isinstance(target, str) and target.startswith("http")): and isinstance(target, str) and target.startswith("http")):
suggested_name = str(title).strip() if title is not None else None suggested_name = str(title).strip() if title is not None else None
result_obj = _download_direct_file( result_obj = download_direct_file(
target, target,
final_output_dir, final_output_dir,
quiet=quiet_mode, quiet=quiet_mode,
@@ -926,7 +926,6 @@ class Download_File(Cmdlet):
} }
if provider_hint: if provider_hint:
payload["plugin"] = str(provider_hint) payload["plugin"] = str(provider_hint)
payload["provider"] = str(provider_hint)
if full_metadata: if full_metadata:
payload["metadata"] = full_metadata payload["metadata"] = full_metadata
if notes: if notes:
@@ -1125,7 +1124,7 @@ class Download_File(Cmdlet):
filename += ext_text filename += ext_text
if download_url: if download_url:
result_obj = _download_direct_file( result_obj = download_direct_file(
download_url, download_url,
final_output_dir, final_output_dir,
quiet=True, quiet=True,
+16 -16
View File
@@ -43,7 +43,7 @@ from SYS import pipeline as pipeline_context
# Playwright & Screenshot Dependencies # Playwright & Screenshot Dependencies
# ============================================================================ # ============================================================================
from tool.playwright import PlaywrightTimeoutError, PlaywrightTool from plugins.playwright import PlaywrightTimeoutError, PlaywrightTool
try: try:
from SYS.config import resolve_output_dir from SYS.config import resolve_output_dir
@@ -1525,22 +1525,22 @@ def _capture(
{}) or {}) {}) or {})
except Exception: except Exception:
base_cfg = {} base_cfg = {}
tool_block = dict(base_cfg.get("tool") or {} plugin_block = dict(base_cfg.get("plugin") or {}
) if isinstance(base_cfg, ) if isinstance(base_cfg,
dict) else {} dict) else {}
pw_block = ( pw_block = (
dict(tool_block.get("playwright") or {}) dict(plugin_block.get("playwright") or {})
if isinstance(tool_block, if isinstance(plugin_block,
dict) else {} dict) else {}
) )
pw_block["browser"] = "chromium" pw_block["browser"] = "chromium"
tool_block["playwright"] = pw_block plugin_block["playwright"] = pw_block
if isinstance(base_cfg, dict): if isinstance(base_cfg, dict):
base_cfg["tool"] = tool_block base_cfg["plugin"] = plugin_block
tool = PlaywrightTool(base_cfg) tool = PlaywrightTool(base_cfg)
except Exception: except Exception:
tool = PlaywrightTool({ tool = PlaywrightTool({
"tool": { "plugin": {
"playwright": { "playwright": {
"browser": "chromium" "browser": "chromium"
} }
@@ -1888,8 +1888,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
quality_value: Optional[int] = None quality_value: Optional[int] = None
if not format_value: if not format_value:
try: try:
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {} plugin_cfg = config.get("plugin", {}) if isinstance(config, dict) else {}
pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None pw_cfg = plugin_cfg.get("playwright") if isinstance(plugin_cfg, dict) else None
if isinstance(pw_cfg, dict): if isinstance(pw_cfg, dict):
format_value = pw_cfg.get("format") format_value = pw_cfg.get("format")
except Exception: 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) quality_value = _normalize_quality(raw_quality_value)
else: else:
try: try:
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {} plugin_cfg = config.get("plugin", {}) if isinstance(config, dict) else {}
pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None 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, ""): if isinstance(pw_cfg, dict) and pw_cfg.get("screenshot_quality") not in (None, ""):
quality_value = _normalize_quality(pw_cfg.get("screenshot_quality")) quality_value = _normalize_quality(pw_cfg.get("screenshot_quality"))
except Exception: except Exception:
@@ -1994,18 +1994,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
shared_playwright_tool: Optional[PlaywrightTool] = None shared_playwright_tool: Optional[PlaywrightTool] = None
try: try:
if isinstance(config, dict): if isinstance(config, dict):
tool_block = dict(config.get("tool") or {}) plugin_block = dict(config.get("plugin") or {})
pw_block = dict(tool_block.get("playwright") or {}) pw_block = dict(plugin_block.get("playwright") or {})
pw_block["browser"] = "chromium" pw_block["browser"] = "chromium"
pw_block["user_agent"] = "native" pw_block["user_agent"] = "native"
pw_block["viewport_width"] = int(DEFAULT_VIEWPORT.get("width", 1920)) pw_block["viewport_width"] = int(DEFAULT_VIEWPORT.get("width", 1920))
pw_block["viewport_height"] = int(DEFAULT_VIEWPORT.get("height", 1080)) 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 = dict(config)
pw_local_cfg["tool"] = tool_block pw_local_cfg["plugin"] = plugin_block
else: else:
pw_local_cfg = { pw_local_cfg = {
"tool": { "plugin": {
"playwright": { "playwright": {
"browser": "chromium", "browser": "chromium",
"user_agent": "native", "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 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: def __init__(self) -> None:
super().__init__( super().__init__(
@@ -187,9 +187,9 @@ class search_file(Cmdlet):
), ),
], ],
detail=[ 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 -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)", "URL search: url:* (any URL) or url:<value> (URL substring)",
"Extension search: ext:<value> (e.g., ext:png)", "Extension search: ext:<value> (e.g., ext:png)",
"Hydrus-style extension: system:filetype = png", "Hydrus-style extension: system:filetype = png",
@@ -1216,7 +1216,7 @@ class search_file(Cmdlet):
try: try:
table.set_table_metadata( table.set_table_metadata(
{ {
"provider": "web", "plugin": "web",
"site": site_host, "site": site_host,
"query": search_query, "query": search_query,
"filetype": requested_type, "filetype": requested_type,
@@ -1490,7 +1490,7 @@ class search_file(Cmdlet):
return 1 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: if not limit_set:
limit = 50 limit = 50
@@ -1632,7 +1632,7 @@ class search_file(Cmdlet):
if "table" not in item_dict: if "table" not in item_dict:
item_dict["table"] = table_type 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: if "source" not in item_dict:
item_dict["source"] = plugin_name item_dict["source"] = plugin_name
-4
View File
@@ -125,10 +125,6 @@ class File(Cmdlet):
if callable(exec_fn): if callable(exec_fn):
return int(exec_fn(result, args, config)) 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) log(f"file: cannot dispatch action '{action}' via module '{module_name}'", file=sys.stderr)
return 1 return 1
+7 -7
View File
@@ -640,8 +640,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception: except Exception:
hydrus_client = None hydrus_client = None
# Sidecar/tag import fallback DB root (legacy): if a folder store is selected, use it; # Use the selected store root when available; otherwise use the configured
# otherwise fall back to configured local storage path. # local plugin root for sidecar/tag import lookup.
from SYS.config import get_local_storage_path from SYS.config import get_local_storage_path
local_storage_root: Optional[Path] = None local_storage_root: Optional[Path] = None
@@ -852,8 +852,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
"path", "path",
None) None)
# Legacy LOCAL STORAGE MODE: Handle relationships for local files # Handle relationships for local-file results using the configured
# (kept as stub - folder store removed) # local plugin root when available.
from SYS.config import get_local_storage_path from SYS.config import get_local_storage_path
local_storage_path = get_local_storage_path(config) if config else None 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: try:
file_path_obj = Path(str(file_path_from_result)) file_path_obj = Path(str(file_path_from_result))
except Exception as exc: except Exception as exc:
log(f"Local storage error: {exc}", file=sys.stderr) log(f"Local library error: {exc}", file=sys.stderr)
return 1 return 1
if not file_path_obj.exists(): 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: if file_path_obj is not None:
try: try:
if local_storage_root is None: if local_storage_root is None:
log("Local storage path unavailable", file=sys.stderr) log("Local plugin path unavailable", file=sys.stderr)
return 1 return 1
with LocalLibrarySearchOptimizer(local_storage_root) as opt: with LocalLibrarySearchOptimizer(local_storage_root) as opt:
if opt.db is None: if opt.db is None:
log("Local storage DB unavailable", file=sys.stderr) log("Local library DB unavailable", file=sys.stderr)
return 1 return 1
if king_hash: if king_hash:
+7 -7
View File
@@ -25,7 +25,7 @@ expand_tag_groups = sh.expand_tag_groups
merge_sequences = sh.merge_sequences merge_sequences = sh.merge_sequences
render_tag_value_templates = sh.render_tag_value_templates render_tag_value_templates = sh.render_tag_value_templates
parse_cmdlet_args = sh.parse_cmdlet_args 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 should_show_help = sh.should_show_help
get_field = sh.get_field get_field = sh.get_field
@@ -800,7 +800,7 @@ class Add_Tag(Cmdlet):
file=sys.stderr, file=sys.stderr,
) )
item_tag_to_add = collapse_namespace_tag( item_tag_to_add = collapse_namespace_tags(
item_tag_to_add, item_tag_to_add,
"title", "title",
prefer="last" prefer="last"
@@ -843,7 +843,7 @@ class Add_Tag(Cmdlet):
) )
unresolved_template_count += len(unresolved_templates) unresolved_template_count += len(unresolved_templates)
item_tag_to_add = collapse_namespace_tag( item_tag_to_add = collapse_namespace_tags(
item_tag_to_add, item_tag_to_add,
"title", "title",
prefer="last" prefer="last"
@@ -877,7 +877,7 @@ class Add_Tag(Cmdlet):
] ]
updated_tag_list.extend(actual_tag_to_add) updated_tag_list.extend(actual_tag_to_add)
updated_tag_list = collapse_namespace_tag( updated_tag_list = collapse_namespace_tags(
updated_tag_list, updated_tag_list,
"title", "title",
prefer="last" prefer="last"
@@ -977,7 +977,7 @@ class Add_Tag(Cmdlet):
file=sys.stderr, file=sys.stderr,
) )
item_tag_to_add = collapse_namespace_tag( item_tag_to_add = collapse_namespace_tags(
item_tag_to_add, item_tag_to_add,
"title", "title",
prefer="last" prefer="last"
@@ -1016,7 +1016,7 @@ class Add_Tag(Cmdlet):
) )
unresolved_template_count += len(unresolved_templates) unresolved_template_count += len(unresolved_templates)
item_tag_to_add = collapse_namespace_tag( item_tag_to_add = collapse_namespace_tags(
item_tag_to_add, item_tag_to_add,
"title", "title",
prefer="last" prefer="last"
@@ -1032,7 +1032,7 @@ class Add_Tag(Cmdlet):
] ]
if len(existing_title_tags) > 1: if len(existing_title_tags) > 1:
item_tag_to_add.append(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, item_tag_to_add,
"title", "title",
prefer="last" 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), item_title=str(items[0].get("title") or provider.name),
path=None, path=None,
subject={ subject={
"provider": provider.name, "plugin": provider.name,
"url": str(query_hint) "url": str(query_hint)
}, },
quiet=emit_mode, quiet=emit_mode,
@@ -692,7 +692,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
) )
payload = { payload = {
"tag": tags, "tag": tags,
"provider": provider.name, "plugin": provider.name,
"title": item.get("title"), "title": item.get("title"),
"artist": item.get("artist"), "artist": item.get("artist"),
"album": item.get("album"), "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, "path": path_for_payload,
"extra": { "extra": {
"tag": tags, "tag": tags,
"provider": provider.name, "plugin": provider.name,
}, },
} }
selection_payload.append(payload) 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 # 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. # 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) result_tags = get_field(result, "tag", None)
if result_provider and isinstance(result_tags, list) and result_tags: 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 importlib import import_module
from typing import Any, Callable, Dict, Sequence 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] 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): if not callable(run_fn):
return return
if hasattr(cmdlet_obj, "name") and cmdlet_obj.name: for registered_name in collect_registered_cmdlet_names(cmdlet_obj):
registry[cmdlet_obj.name.replace("_", "-").lower()] = run_fn registry[registered_name] = 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
def _iter_legacy_native_module_names() -> list[str]: 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 {} return {}
provider_cfg = config.get("plugin") 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 {} 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)) 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: def has_provider(cfg: dict, name: str) -> bool:
provider_cfg = cfg.get("plugin") provider_cfg = cfg.get("plugin")
if not isinstance(provider_cfg, dict):
provider_cfg = cfg.get("provider")
if not isinstance(provider_cfg, dict): if not isinstance(provider_cfg, dict):
return False return False
block = provider_cfg.get(str(name).strip().lower()) block = provider_cfg.get(str(name).strip().lower())
return isinstance(block, dict) and bool(block) 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]: def ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]:
try: try:
from API.HTTP import HTTPClient 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.logger import log
from SYS.plugin_config import ( from SYS.plugin_config import (
build_default_plugin_config, build_default_plugin_config,
build_default_tool_config,
get_configurable_plugin_types, get_configurable_plugin_types,
get_configurable_store_types, get_configurable_store_types,
get_configurable_tool_types,
) )
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.result_table import Table from SYS.result_table import Table
@@ -32,19 +30,15 @@ from cmdnat._parsing import (
_PREFERENCES_BROWSE_PATH = "__preferences__" _PREFERENCES_BROWSE_PATH = "__preferences__"
_PLUGINS_BROWSE_PATH = "__plugins__" _PLUGINS_BROWSE_PATH = "__plugins__"
_PLUGIN_CATEGORY_KEYS = ("plugin", "provider", "tool") _PLUGIN_CATEGORY_KEYS = ("plugin",)
_CREATE_INSTANCE_FLAG = "-create-instance" _CREATE_INSTANCE_FLAG = "-create-instance"
_KNOWN_SECTION_LABELS = { _KNOWN_SECTION_LABELS = {
"plugin": "Plugins", "plugin": "Plugins",
"provider": "Plugins",
"tool": "Plugins",
} }
_KNOWN_SECTION_DESCRIPTIONS = { _KNOWN_SECTION_DESCRIPTIONS = {
_PREFERENCES_BROWSE_PATH: "Global preferences and simple values", _PREFERENCES_BROWSE_PATH: "Global preferences and simple values",
_PLUGINS_BROWSE_PATH: "All configured plugins and plugin instances", _PLUGINS_BROWSE_PATH: "All configured plugins and plugin instances",
"provider": "Plugin configuration",
"plugin": "Plugin configuration", "plugin": "Plugin configuration",
"tool": "Plugin configuration",
} }
_SENSITIVE_CONFIG_KEYS = { _SENSITIVE_CONFIG_KEYS = {
"access_key", "access_key",
@@ -300,19 +294,6 @@ def _get_configurable_plugin_names() -> List[str]:
] ]
except Exception: except Exception:
return [] 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]: def _get_multi_instance_plugin_names() -> set[str]:
try: try:
return { return {
@@ -336,7 +317,7 @@ def _is_multi_instance_plugin_root_path(browse_path: Optional[str]) -> bool:
parts = _split_config_path(browse_path) parts = _split_config_path(browse_path)
return ( return (
len(parts) == 2 len(parts) == 2
and parts[0] in {"plugin", "provider"} and parts[0] == "plugin"
and _is_multi_instance_plugin_name(parts[1]) 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: if not normalized_name:
return None 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) branch = build_default_plugin_config(normalized_name)
if not isinstance(branch, dict): if not isinstance(branch, dict):
return None return None
@@ -441,10 +418,7 @@ def _resolve_plugin_branch(
if not normalized_name: if not normalized_name:
return None return None
if normalized_category == "tool": if normalized_name not in _get_configurable_plugin_names():
if normalized_name not in _get_configurable_tool_names():
return None
elif normalized_name not in _get_configurable_plugin_names():
return None return None
synthetic = _build_synthetic_plugin_branch(normalized_category, normalized_name) synthetic = _build_synthetic_plugin_branch(normalized_category, normalized_name)
@@ -557,7 +531,7 @@ def _resolve_config_branch(
if resolved is None: if resolved is None:
return None return None
_, current, _ = resolved _, 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) current = _normalize_multi_instance_branch(parts[1], current)
for part in parts[2:]: for part in parts[2:]:
if not isinstance(current, dict): if not isinstance(current, dict):
@@ -620,7 +594,7 @@ def _create_or_get_plugin_instance(
instance_name: str, instance_name: str,
) -> tuple[str, bool]: ) -> tuple[str, bool]:
parts = _split_config_path(instance_target) 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}'") raise ValueError(f"Unsupported instance target '{instance_target}'")
category, plugin_name = parts 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) parts = _split_config_path(selection_key)
if ( if (
len(parts) >= 4 len(parts) >= 4
and parts[0] in {"plugin", "provider"} and parts[0] == "plugin"
and parts[2].lower() == "default" and parts[2].lower() == "default"
and _is_multi_instance_plugin_name(parts[1]) 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) parts = _split_config_path(text)
if ( if (
len(parts) == 3 len(parts) == 3
and parts[0] in {"plugin", "provider"} and parts[0] == "plugin"
and _is_multi_instance_plugin_name(parts[1]) and _is_multi_instance_plugin_name(parts[1])
): ):
return [ return [
@@ -951,17 +925,13 @@ def _resolve_direct_browse_path(
lowered = text.lower() lowered = text.lower()
if lowered in {"preferences", "prefs"}: if lowered in {"preferences", "prefs"}:
return _PREFERENCES_BROWSE_PATH return _PREFERENCES_BROWSE_PATH
if lowered in {"plugins", "plugin", "providers", "provider", "tools", "tool"}: if lowered in {"plugins", "plugin"}:
return _PLUGINS_BROWSE_PATH return _PLUGINS_BROWSE_PATH
plugin_branch = _resolve_plugin_branch(config_data, "plugin", lowered) plugin_branch = _resolve_plugin_branch(config_data, "plugin", lowered)
if plugin_branch is not None: if plugin_branch is not None:
return f"plugin.{plugin_branch[0]}" 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) branch = _resolve_config_branch(config_data, text)
if isinstance(branch, dict): if isinstance(branch, dict):
return text return text
+7 -21
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, Sequence, List, Optional, Tuple
import shlex import shlex
import sys 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 cmdlet import REGISTRY as CMDLET_REGISTRY, ensure_cmdlet_modules_loaded
from SYS.logger import log from SYS.logger import log
from SYS.result_table import Table 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]: def _cmdlet_aliases(cmdlet_obj: Cmdlet) -> List[str]:
aliases: List[str] = [] canonical_name = _normalize_cmdlet_key(getattr(cmdlet_obj, "name", None))
for attr in ("alias", "aliases"): return [
raw_aliases = getattr(cmdlet_obj, attr, None) registered_name
if isinstance(raw_aliases, (list, tuple, set)): for registered_name in collect_registered_cmdlet_names(cmdlet_obj)
candidates = raw_aliases if registered_name != canonical_name
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
def _cmdlet_arg_to_dict(arg: CmdletArg) -> Dict[str, Any]: 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 = ResultTable("Plugin: X results").set_preserve_order(True)
table.set_table("plugin_name") 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"]) table.set_source_command("search-file", ["-plugin", "plugin_name", "query"])
for result in results: for result in results:
@@ -114,8 +114,8 @@ SearchResult(
media_kind="folder", media_kind="folder",
full_metadata={ full_metadata={
"magnet_id": 123, "magnet_id": 123,
"provider": "alldebrid", "plugin": "alldebrid",
"provider_view": "folders", "plugin_view": "folders",
}, },
) )
``` ```
@@ -130,8 +130,8 @@ SearchResult(
media_kind="file", media_kind="file",
full_metadata={ full_metadata={
"magnet_id": 123, "magnet_id": 123,
"provider": "alldebrid", "plugin": "alldebrid",
"provider_view": "files", "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 typing import Any, Dict, Iterable, List, Optional, Callable, Tuple
from urllib.parse import urlparse 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 plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file
from PluginCore.base import Provider, SearchResult from PluginCore.base import Provider, SearchResult
from SYS.plugin_helpers import TablePluginMixin 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]: def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]:
"""Read AllDebrid API key from config. """Read the canonical 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
try: try:
from SYS.config import get_debrid_api_key from SYS.config import get_debrid_api_key
key = get_debrid_api_key(config, service="All-debrid") key = get_debrid_api_key(config, service="All-debrid")
return key.strip() if key else None return key.strip() if key else None
except Exception: except Exception:
pass return None
# 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
def _consume_bencoded_value(data: bytes, pos: int) -> int: def _consume_bencoded_value(data: bytes, pos: int) -> int:
@@ -399,8 +366,8 @@ def _build_queued_magnet_item(
metadata: Dict[str, Any] = { metadata: Dict[str, Any] = {
"magnet_id": magnet_id, "magnet_id": magnet_id,
"provider": "alldebrid", "plugin": "alldebrid",
"provider_view": "files", "plugin_view": "files",
"magnet_spec": magnet_spec, "magnet_spec": magnet_spec,
"source_url": magnet_spec, "source_url": magnet_spec,
"status": status_label, "status": status_label,
@@ -412,7 +379,6 @@ def _build_queued_magnet_item(
return { return {
"table": "alldebrid", "table": "alldebrid",
"provider": "alldebrid",
"plugin": "alldebrid", "plugin": "alldebrid",
"path": f"{_ALD_MAGNET_PREFIX}{magnet_id}", "path": f"{_ALD_MAGNET_PREFIX}{magnet_id}",
"title": title, "title": title,
@@ -539,7 +505,7 @@ def download_magnet(
output_dir = target_path output_dir = target_path
try: try:
result_obj = _download_direct_file( result_obj = download_direct_file(
file_url, file_url,
output_dir, output_dir,
quiet=quiet_mode, quiet=quiet_mode,
@@ -800,8 +766,8 @@ class AllDebrid(TablePluginMixin, Provider):
"title": f"magnet-{magnet_id}", "title": f"magnet-{magnet_id}",
"metadata": { "metadata": {
"magnet_id": magnet_id, "magnet_id": magnet_id,
"provider": "alldebrid", "plugin": "alldebrid",
"provider_view": "files", "plugin_view": "files",
}, },
} }
@@ -952,7 +918,7 @@ class AllDebrid(TablePluginMixin, Provider):
pipe_progress = None pipe_progress = None
try: try:
dl_res = _download_direct_file( dl_res = download_direct_file(
unlocked_url, unlocked_url,
Path(output_dir), Path(output_dir),
quiet=quiet, quiet=quiet,
@@ -965,7 +931,7 @@ class AllDebrid(TablePluginMixin, Provider):
downloaded_path = Path(str(downloaded_path)) downloaded_path = Path(str(downloaded_path))
except DownloadError as exc: except DownloadError as exc:
log( 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 return None
@@ -1360,7 +1326,7 @@ class AllDebrid(TablePluginMixin, Provider):
suggested_name = rel_path_obj.name or file_name or f"file-{file_idx}" suggested_name = rel_path_obj.name or file_name or f"file-{file_idx}"
try: try:
result_obj = _download_direct_file( result_obj = download_direct_file(
file_url, file_url,
target_path, target_path,
quiet=quiet_mode, quiet=quiet_mode,
@@ -1482,8 +1448,8 @@ class AllDebrid(TablePluginMixin, Provider):
full_metadata={ full_metadata={
"magnet": magnet_status, "magnet": magnet_status,
"magnet_id": magnet_id, "magnet_id": magnet_id,
"provider": "alldebrid", "plugin": "alldebrid",
"provider_view": "files", "plugin_view": "files",
"magnet_name": magnet_name, "magnet_name": magnet_name,
}, },
) )
@@ -1535,8 +1501,8 @@ class AllDebrid(TablePluginMixin, Provider):
"magnet_name": magnet_name, "magnet_name": magnet_name,
"relpath": relpath, "relpath": relpath,
"file": file_node, "file": file_node,
"provider": "alldebrid", "plugin": "alldebrid",
"provider_view": "files", "plugin_view": "files",
# Selection metadata for table system # Selection metadata for table system
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], "_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
"_selection_action": ["download-file", "-plugin", "alldebrid", "-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={ full_metadata={
"magnet": magnet, "magnet": magnet,
"magnet_id": magnet_id, "magnet_id": magnet_id,
"provider": "alldebrid", "plugin": "alldebrid",
"provider_view": "folders", "plugin_view": "folders",
"magnet_name": magnet_name, "magnet_name": magnet_name,
# Selection metadata: allow @N expansion to drive downloads directly # Selection metadata: allow @N expansion to drive downloads directly
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], "_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 = Table(f"AllDebrid Files: {title}")._perseverance(True)
table.set_table("alldebrid") table.set_table("alldebrid")
try: 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: except Exception:
pass pass
table.set_source_command("download-file", ["-plugin", "alldebrid"]) table.set_source_command("download-file", ["-plugin", "alldebrid"])
@@ -1844,7 +1810,7 @@ class AllDebrid(TablePluginMixin, Provider):
"Magnet": magnet_name or None, "Magnet": magnet_name or None,
"Magnet ID": magnet_id, "Magnet ID": magnet_id,
"Relative Path": relpath or None, "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, "Direct Url": direct_url or None,
"Selection Url": selection_url or None, "Selection Url": selection_url or None,
}, },
@@ -1942,7 +1908,7 @@ try:
if table_name: if table_name:
metadata.setdefault("table", table_name) metadata.setdefault("table", table_name)
metadata.setdefault("source", table_name) metadata.setdefault("source", table_name)
metadata.setdefault("provider", table_name) metadata.setdefault("plugin", table_name)
ext = payload.get("ext") ext = payload.get("ext")
if not ext and isinstance(path_val, str): if not ext and isinstance(path_val, str):
@@ -2003,8 +1969,8 @@ try:
cols.append(metadata_column("ready", "Ready")) cols.append(metadata_column("ready", "Ready"))
if _has_metadata(rows, "relpath"): if _has_metadata(rows, "relpath"):
cols.append(metadata_column("relpath", "File Path")) cols.append(metadata_column("relpath", "File Path"))
if _has_metadata(rows, "provider_view"): if _has_metadata(rows, "plugin_view"):
cols.append(metadata_column("provider_view", "View")) cols.append(metadata_column("plugin_view", "View"))
if _has_metadata(rows, "size"): if _has_metadata(rows, "size"):
cols.append(metadata_column("size", "Size")) cols.append(metadata_column("size", "Size"))
return cols return cols
@@ -2016,7 +1982,7 @@ try:
Selection precedence: Selection precedence:
1. Explicit _selection_action (full command args) 1. Explicit _selection_action (full command args)
2. Explicit _selection_args (URL-specific 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>) 4. Magnet ID routing for folder-type rows (via alldebrid:magnet:<id>)
5. Direct URL for file rows 5. Direct URL for file rows
@@ -2035,7 +2001,7 @@ try:
return [str(x) for x in args if x is not None] return [str(x) for x in args if x is not None]
# Magic routing by view type # 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": if view == "files":
# File rows: pass direct URL for immediate download # File rows: pass direct URL for immediate download
if row.path: 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 unlock-link # Uses URL from pipeline result
Requires: Requires:
- AllDebrid API key in config under Debrid.All-debrid - AllDebrid API key in config under plugin.alldebrid.api_key
Args: Args:
result: Pipeline result object result: Pipeline result object
@@ -1054,28 +1054,14 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
return None return None
def _get_alldebrid_api_key_from_config(cfg: Dict[str, Any]) -> Optional[str]: def _get_alldebrid_api_key_from_config(cfg: Dict[str, Any]) -> Optional[str]:
# Current config format
try: try:
provider_cfg = cfg.get("provider") if isinstance(cfg, dict) else None from SYS.config import get_debrid_api_key
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
# Legacy config format fallback (best-effort) api_key = get_debrid_api_key(cfg, service="All-debrid")
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")
if isinstance(api_key, str) and api_key.strip(): if isinstance(api_key, str) and api_key.strip():
return api_key.strip() return api_key.strip()
except Exception: except Exception:
pass return None
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 PluginCore.base import Provider, SearchResult
from SYS.logger import log, debug, debug_panel from SYS.logger import log, debug, debug_panel
from tool.playwright import PlaywrightTool from plugins.playwright import PlaywrightTool
class Bandcamp(Provider): 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]: def _pick_provider_config(config: Any) -> Dict[str, Any]:
if not isinstance(config, dict): if not isinstance(config, dict):
return {} return {}
provider = config.get("provider") plugin_cfg = config.get("plugin")
if not isinstance(provider, dict): if not isinstance(plugin_cfg, dict):
return {} return {}
entry = provider.get("file.io") entry = plugin_cfg.get("file.io")
if isinstance(entry, dict): if isinstance(entry, dict):
return entry return entry
return {} 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. Designed to be dependency-light at import time; heavy deps are imported lazily.
Config: Config:
[tool=florencevision] [plugin=florencevision]
enabled=true enabled=true
strict=false strict=false
model="microsoft/Florence-2-large" model="microsoft/Florence-2-large"
@@ -316,21 +316,21 @@ class FlorenceVisionTool:
def _load_defaults(self) -> FlorenceVisionDefaults: def _load_defaults(self) -> FlorenceVisionDefaults:
cfg = self._config cfg = self._config
tool_block = _get_nested(cfg, "tool", "florencevision") plugin_block = _get_nested(cfg, "plugin", "florencevision")
if not isinstance(tool_block, dict): if not isinstance(plugin_block, dict):
tool_block = {} plugin_block = {}
base = FlorenceVisionDefaults() base = FlorenceVisionDefaults()
defaults = FlorenceVisionDefaults( defaults = FlorenceVisionDefaults(
enabled=_as_bool(tool_block.get("enabled"), False), enabled=_as_bool(plugin_block.get("enabled"), False),
strict=_as_bool(tool_block.get("strict"), False), strict=_as_bool(plugin_block.get("strict"), False),
model=str(tool_block.get("model") or base.model), model=str(plugin_block.get("model") or base.model),
device=str(tool_block.get("device") or base.device), device=str(plugin_block.get("device") or base.device),
dtype=(str(tool_block.get("dtype")).strip() if tool_block.get("dtype") else None), dtype=(str(plugin_block.get("dtype")).strip() if plugin_block.get("dtype") else None),
max_tags=_as_int(tool_block.get("max_tags"), base.max_tags), max_tags=_as_int(plugin_block.get("max_tags"), base.max_tags),
namespace=str(tool_block.get("namespace") or base.namespace), namespace=str(plugin_block.get("namespace") or base.namespace),
task=str(tool_block.get("task") or base.task), task=str(plugin_block.get("task") or base.task),
) )
return defaults return defaults
@@ -1022,4 +1022,58 @@ class FlorenceVisionTool:
return self.tags_for_image(media_path) 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") table.set_table("ftp")
try: try:
table.set_table_metadata({ table.set_table_metadata({
"provider": "ftp", "plugin": "ftp",
"instance": instance_name or None, "instance": instance_name or None,
"host": settings.get("host"), "host": settings.get("host"),
"path": target_path, "path": target_path,
@@ -792,7 +792,7 @@ class FTP(Provider):
parent = posixpath.dirname(ftp_path.rstrip("/")) or "/" parent = posixpath.dirname(ftp_path.rstrip("/")) or "/"
instance_name = str(settings.get("instance") or "").strip() instance_name = str(settings.get("instance") or "").strip()
metadata = { metadata = {
"provider": "ftp", "plugin": "ftp",
"instance": instance_name or None, "instance": instance_name or None,
"host": settings.get("host"), "host": settings.get("host"),
"ftp_path": ftp_path, "ftp_path": ftp_path,
+1 -1
View File
@@ -153,7 +153,7 @@ class HelloProvider(Provider):
table = Table(f"Hello Details: {title}")._perseverance(True) table = Table(f"Hello Details: {title}")._perseverance(True)
table.set_table("hello") table.set_table("hello")
try: 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: except Exception:
pass pass
+7 -7
View File
@@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from plugins.tidal.api import ( from plugins.tidal.api import (
Tidal as TidalApiClient, Tidal,
build_track_tags, build_track_tags,
coerce_duration_seconds, coerce_duration_seconds,
extract_artists, extract_artists,
@@ -97,7 +97,7 @@ class HIFI(Provider):
self.api_timeout = float(self.config.get("timeout", 10.0)) self.api_timeout = float(self.config.get("timeout", 10.0))
except Exception: except Exception:
self.api_timeout = 10.0 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]]: def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
normalized, parsed = parse_inline_query_arguments(query) normalized, parsed = parse_inline_query_arguments(query)
@@ -744,7 +744,7 @@ class HIFI(Provider):
try: try:
table.set_table_metadata( table.set_table_metadata(
{ {
"provider": "hifi", "plugin": "hifi",
"view": "track", "view": "track",
"album_id": album_id, "album_id": album_id,
"album_title": album_title, "album_title": album_title,
@@ -1376,7 +1376,7 @@ class HIFI(Provider):
return False, None 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("/") base = base_url.rstrip("/")
for client in self.api_clients: for client in self.api_clients:
if getattr(client, "base_url", "").rstrip("/") == base: if getattr(client, "base_url", "").rstrip("/") == base:
@@ -1935,7 +1935,7 @@ class HIFI(Provider):
table = Table(f"HIFI Albums: {artist_name}")._perseverance(False) table = Table(f"HIFI Albums: {artist_name}")._perseverance(False)
table.set_table("hifi.album") table.set_table("hifi.album")
try: 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: except Exception:
pass pass
@@ -1997,7 +1997,7 @@ class HIFI(Provider):
try: try:
table.set_table_metadata( table.set_table_metadata(
{ {
"provider": "hifi", "plugin": "hifi",
"view": "track", "view": "track",
"album_id": album_id, "album_id": album_id,
"album_title": album_title, "album_title": album_title,
@@ -2061,7 +2061,7 @@ class HIFI(Provider):
table = Table("HIFI Track")._perseverance(True) table = Table("HIFI Track")._perseverance(True)
table.set_table("hifi.track") table.set_table("hifi.track")
try: 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: except Exception:
pass pass
results_payload: List[Dict[str, Any]] = [] 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 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 PluginCore.base import Provider, SearchResult
from SYS.utils import sanitize_filename, unique_path from SYS.utils import sanitize_filename, unique_path
from SYS.logger import log 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. # Helper for download-file: render selectable formats for a details URL.
def maybe_show_formats_table( def maybe_show_formats_table(
@@ -177,26 +177,20 @@ def _ia() -> Any:
def _pick_provider_config(config: Any) -> Dict[str, Any]: def _pick_provider_config(config: Any) -> Dict[str, Any]:
if not isinstance(config, dict): if not isinstance(config, dict):
return {} return {}
provider = config.get("provider") return get_plugin_block(config, "internetarchive")
if not isinstance(provider, dict):
return {}
entry = provider.get("internetarchive")
if isinstance(entry, dict):
return entry
return {}
def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]]: def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]]:
"""Resolve Archive.org credentials. """Resolve Archive.org credentials.
Preference order: Preference order:
1) provider.internetarchive (email/username + password) 1) plugin.internetarchive (email/username + password)
2) provider.openlibrary (email + password) 2) plugin.openlibrary (email + password)
""" """
if not isinstance(config, dict): if not isinstance(config, dict):
return None, None return None, None
ia_block = get_provider_block(config, "internetarchive") ia_block = get_plugin_block(config, "internetarchive")
if isinstance(ia_block, dict): if isinstance(ia_block, dict):
email = ( email = (
ia_block.get("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: if email_text and password_text:
return email_text, 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): if isinstance(ol_block, dict):
email = ol_block.get("email") email = ol_block.get("email")
password = ol_block.get("password") password = ol_block.get("password")
@@ -744,7 +738,7 @@ class InternetArchive(Provider):
if tags: if tags:
normalized["tags"] = tags normalized["tags"] = tags
normalized["media_kind"] = "book" normalized["media_kind"] = "book"
normalized["provider_action"] = "borrow" normalized["plugin_action"] = "borrow"
return normalized return normalized
def validate(self) -> bool: def validate(self) -> bool:
@@ -993,7 +987,7 @@ class InternetArchive(Provider):
pipeline_progress = None pipeline_progress = None
try: try:
direct_result = _download_direct_file( direct_result = download_direct_file(
raw_path, raw_path,
output_dir, output_dir,
quiet=quiet_mode, quiet=quiet_mode,
+1 -1
View File
@@ -279,7 +279,7 @@ class Local(Provider):
return { return {
"hash": hash_value or "unknown", "hash": hash_value or "unknown",
"store": "local", "store": "local",
"provider": self.name, "plugin": self.name,
"path": str(target_path), "path": str(target_path),
"tag": tags, "tag": tags,
"title": title or target_path.name, "title": title or target_path.name,
+13 -17
View File
@@ -327,10 +327,10 @@ class Matrix(TablePluginMixin, Provider):
self._init_reason: Optional[str] = None self._init_reason: Optional[str] = None
matrix_conf = ( matrix_conf = (
self.config.get("provider", self.config.get("plugin",
{}).get("matrix", {}).get("matrix",
{}) if isinstance(self.config, {}) if isinstance(self.config,
dict) else {} dict) else {}
) )
homeserver = matrix_conf.get("homeserver") homeserver = matrix_conf.get("homeserver")
access_token = matrix_conf.get("access_token") access_token = matrix_conf.get("access_token")
@@ -362,16 +362,16 @@ class Matrix(TablePluginMixin, Provider):
return False return False
if self._init_ok is False: if self._init_ok is False:
return False return False
matrix_conf = self.config.get("provider", matrix_conf = self.config.get("plugin",
{}).get("matrix", {}).get("matrix",
{}) {})
return bool( return bool(
matrix_conf.get("homeserver") matrix_conf.get("homeserver")
and matrix_conf.get("access_token") and matrix_conf.get("access_token")
) )
def status_summary(self) -> Dict[str, Any]: 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() homeserver = str(matrix_conf.get("homeserver") or "").strip()
room_id = str(matrix_conf.get("room_id") or "").strip() room_id = str(matrix_conf.get("room_id") or "").strip()
detail = homeserver detail = homeserver
@@ -439,7 +439,7 @@ class Matrix(TablePluginMixin, Provider):
full_metadata={ full_metadata={
"room_id": room_id, "room_id": room_id,
"room_name": room_name, "room_name": room_name,
"provider": "matrix", "plugin": "matrix",
# Selection metadata for table system and @N expansion # Selection metadata for table system and @N expansion
"_selection_args": ["-room-id", room_id], "_selection_args": ["-room-id", room_id],
}, },
@@ -450,9 +450,9 @@ class Matrix(TablePluginMixin, Provider):
def _get_homeserver_and_token(self) -> Tuple[str, str]: def _get_homeserver_and_token(self) -> Tuple[str, str]:
matrix_conf = self.config.get("provider", matrix_conf = self.config.get("plugin",
{}).get("matrix", {}).get("matrix",
{}) {})
homeserver = matrix_conf.get("homeserver") homeserver = matrix_conf.get("homeserver")
access_token = matrix_conf.get("access_token") access_token = matrix_conf.get("access_token")
if not homeserver: if not homeserver:
@@ -681,7 +681,7 @@ class Matrix(TablePluginMixin, Provider):
) )
def upload(self, file_path: str, **kwargs: Any) -> str: def upload(self, file_path: str, **kwargs: Any) -> str:
matrix_conf = self.config.get("provider", matrix_conf = self.config.get("plugin",
{}).get("matrix", {}).get("matrix",
{}) {})
room_id = matrix_conf.get("room_id") room_id = matrix_conf.get("room_id")
@@ -877,7 +877,3 @@ try:
except Exception: except Exception:
# best-effort registration # best-effort registration
pass 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"}) 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: 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 This method writes to the unified config store so changes persist between
sessions. sessions.
@@ -71,29 +93,13 @@ def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
value_str = str(value) value_str = str(value)
current_cfg = load_config() or {} current_cfg = load_config() or {}
providers = current_cfg.setdefault("provider", {}) matrix_cfg = _ensure_matrix_config_block(current_cfg)
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[key] = value_str matrix_cfg[key] = value_str
save_config(current_cfg) save_config(current_cfg)
# Keep the supplied config dict in sync for the running CLI # Keep the supplied config dict in sync for the running CLI
target_providers = config.setdefault("provider", {}) target_matrix = _ensure_matrix_config_block(config)
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[key] = value_str target_matrix[key] = value_str
return True return True
except Exception as exc: 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]: def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]:
try: try:
if not isinstance(config, dict): matrix_conf = _get_matrix_config_block(config)
return [] if not matrix_conf:
providers = config.get("provider")
if not isinstance(providers, dict):
return []
matrix_conf = providers.get("matrix")
if not isinstance(matrix_conf, dict):
return [] return []
raw = None raw = None
# Support a few common spellings; `room` is the documented key. # 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]: def _get_matrix_size_limit_bytes(config: Dict[str, Any]) -> Optional[int]:
"""Return max allowed per-file size in bytes for Matrix uploads. """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: try:
if not isinstance(config, dict): matrix_conf = _get_matrix_config_block(config)
return None if not matrix_conf:
providers = config.get("provider")
if not isinstance(providers, dict):
return None
matrix_conf = providers.get("matrix")
if not isinstance(matrix_conf, dict):
return None return None
raw = 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) conf_ids = _parse_config_room_filter_ids(config)
if conf_ids: if conf_ids:
# Attempt to fetch names for the configured 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"): if block and block.get("homeserver") and block.get("access_token"):
try: try:
m = _get_matrix_provider(config) m = _get_matrix_provider(config)
@@ -252,7 +248,7 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str
pass pass
# Last resort: attempt to ask the server for matching rooms (if possible) # 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"): if block and block.get("homeserver") and block.get("access_token"):
try: try:
m = _get_matrix_provider(config) 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) url = _resolve_plugin_url(url, config)
try: try:
from API.HTTP import _download_direct_file from API.HTTP import download_direct_file
base_tmp = None base_tmp = None
if isinstance(config, dict): 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 = output_dir / "matrix"
output_dir.mkdir(parents=True, exist_ok=True) 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, if (result and hasattr(result,
"path") and isinstance(result.path, "path") and isinstance(result.path,
Path) and result.path.exists()): Path) and result.path.exists()):
@@ -691,10 +687,7 @@ def _show_settings_table(config: Dict[str, Any]) -> int:
matrix_conf = {} matrix_conf = {}
try: try:
if isinstance(config, dict): matrix_conf = _get_matrix_config_block(config)
providers = config.get("provider")
if isinstance(providers, dict):
matrix_conf = providers.get("matrix") or {}
except Exception: except Exception:
pass pass
+11 -11
View File
@@ -252,7 +252,7 @@ class ITunesMetadataPlugin(MetadataPlugin):
"album": r.get("collectionName"), "album": r.get("collectionName"),
"year": str(r.get("releaseDate", "year": str(r.get("releaseDate",
""))[:4], ""))[:4],
"provider": self.name, "plugin": self.name,
"raw": r, "raw": r,
} }
items.append(item) items.append(item)
@@ -338,7 +338,7 @@ class OpenLibraryMetadataPlugin(MetadataPlugin):
"artist": ", ".join(authors) if authors else "", "artist": ", ".join(authors) if authors else "",
"album": publisher, "album": publisher,
"year": str(doc.get("first_publish_year") or ""), "year": str(doc.get("first_publish_year") or ""),
"provider": self.name, "plugin": self.name,
"authors": authors, "authors": authors,
"publisher": publisher, "publisher": publisher,
"identifiers": { "identifiers": {
@@ -460,7 +460,7 @@ class GoogleBooksMetadataPlugin(MetadataPlugin):
"artist": ", ".join(authors) if authors else "", "artist": ", ".join(authors) if authors else "",
"album": publisher, "album": publisher,
"year": year, "year": year,
"provider": self.name, "plugin": self.name,
"authors": authors, "authors": authors,
"publisher": publisher, "publisher": publisher,
"identifiers": identifiers, "identifiers": identifiers,
@@ -643,7 +643,7 @@ class ISBNsearchMetadataPlugin(MetadataPlugin):
"artist": ", ".join(authors) if authors else "", "artist": ", ".join(authors) if authors else "",
"album": publisher or "", "album": publisher or "",
"year": year or "", "year": year or "",
"provider": self.name, "plugin": self.name,
"authors": authors, "authors": authors,
"publisher": publisher or "", "publisher": publisher or "",
"language": language or "", "language": language or "",
@@ -787,7 +787,7 @@ class MusicBrainzMetadataPlugin(MetadataPlugin):
"artist": artist, "artist": artist,
"album": album, "album": album,
"year": year, "year": year,
"provider": self.name, "plugin": self.name,
"mbid": mbid, "mbid": mbid,
"raw": rec, "raw": rec,
} }
@@ -871,7 +871,7 @@ class ImdbMetadataPlugin(MetadataPlugin):
"artist": "", "artist": "",
"album": "", "album": "",
"year": str(year or ""), "year": str(year or ""),
"provider": self.name, "plugin": self.name,
"imdb_id": imdb_id, "imdb_id": imdb_id,
"raw": data, "raw": data,
} }
@@ -908,7 +908,7 @@ class ImdbMetadataPlugin(MetadataPlugin):
"artist": "", "artist": "",
"album": kind, "album": kind,
"year": year, "year": year,
"provider": self.name, "plugin": self.name,
"imdb_id": imdb_id, "imdb_id": imdb_id,
"kind": kind, "kind": kind,
"rating": rating, "rating": rating,
@@ -1032,7 +1032,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
"artist": str(artist or ""), "artist": str(artist or ""),
"album": str(album or ""), "album": str(album or ""),
"year": str(year or ""), "year": str(year or ""),
"provider": self.name, "plugin": self.name,
"url": url, "url": url,
"raw": info, "raw": info,
} }
@@ -1214,7 +1214,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
return None return None
try: try:
from tool.ytdlp import is_url_supported_by_ytdlp from plugins.ytdlp.tooling import is_url_supported_by_ytdlp
for text in candidates: for text in candidates:
try: try:
@@ -1322,7 +1322,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
"artist": str(info.get("artist") or info.get("uploader") or info.get("channel") or ""), "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 ""), "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]), "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(), "url": str(url or "").strip(),
"raw": info, "raw": info,
} }
@@ -1927,7 +1927,7 @@ class TidalMetadataPlugin(MetadataPlugin):
"year": year, "year": year,
"lyrics": lyrics, "lyrics": lyrics,
"tags": tags, "tags": tags,
"provider": self.name, "plugin": self.name,
"path": getattr(result, "path", ""), "path": getattr(result, "path", ""),
"track_id": track_id, "track_id": track_id,
"full_metadata": metadata, "full_metadata": metadata,
+4 -4
View File
@@ -2878,7 +2878,7 @@ local function _start_screenshot_store_save(store, out_path, tags)
screenshot_url = '' screenshot_url = ''
end end
local cmd = 'file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store) 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 if screenshot_url ~= '' then
cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url) cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url)
end end
@@ -6367,7 +6367,7 @@ local function _start_trim_with_range(range)
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) .. 'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(selected_store) .. ' | 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 .. '"' .. ' | add-relationship -store "' .. selected_store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash) ' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
else else
@@ -6375,7 +6375,7 @@ local function _start_trim_with_range(range)
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) .. 'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store_hash.store) .. ' | 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 .. '"' .. ' | add-relationship -store "' .. store_hash.store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash) ' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
end end
@@ -6386,7 +6386,7 @@ local function _start_trim_with_range(range)
_lua_log('trim: building file -add command to selected_store=' .. selected_store) _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 -- 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) .. 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) _lua_log('trim: pipeline_cmd=' .. pipeline_cmd)
else else
mp.osd_message('Trim complete: ' .. output_path, 5) 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.repl_queue import enqueue_repl_command, repl_state_is_alive # noqa: E402
from SYS.utils import format_bytes # noqa: E402 from SYS.utils import format_bytes # noqa: E402
from PluginCore.registry import get_plugin, get_plugin_class # 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" REQUEST_PROP = "user-data/medeia-pipeline-request"
RESPONSE_PROP = "user-data/medeia-pipeline-response" RESPONSE_PROP = "user-data/medeia-pipeline-response"
+5 -5
View File
@@ -674,7 +674,7 @@ class OpenLibrary(Provider):
if not isinstance(config, dict): if not isinstance(config, dict):
return _DEFAULT_PREFERRED_LANGUAGE return _DEFAULT_PREFERRED_LANGUAGE
entry = config.get("provider", {}).get("openlibrary", {}) entry = config.get("plugin", {}).get("openlibrary", {})
if not isinstance(entry, dict): if not isinstance(entry, dict):
return _DEFAULT_PREFERRED_LANGUAGE return _DEFAULT_PREFERRED_LANGUAGE
@@ -1118,7 +1118,7 @@ class OpenLibrary(Provider):
table = Table(f"OpenLibrary Editions: {title}")._perseverance(True) table = Table(f"OpenLibrary Editions: {title}")._perseverance(True)
table.set_table("openlibrary.edition") table.set_table("openlibrary.edition")
try: try:
table.set_table_metadata({"provider": "openlibrary", "view": "borrowable_editions"}) table.set_table_metadata({"plugin": "openlibrary", "view": "borrowable_editions"})
except Exception: except Exception:
pass pass
table.set_source_command("search-file", ["-plugin", "openlibrary"]) table.set_source_command("search-file", ["-plugin", "openlibrary"])
@@ -1274,7 +1274,7 @@ class OpenLibrary(Provider):
if not isinstance(config, dict): if not isinstance(config, dict):
return None, None return None, None
entry = config.get("provider", {}).get("openlibrary", {}) entry = config.get("plugin", {}).get("openlibrary", {})
if isinstance(entry, dict): if isinstance(entry, dict):
email = entry.get("email") email = entry.get("email")
password = entry.get("password") password = entry.get("password")
@@ -1287,11 +1287,11 @@ class OpenLibrary(Provider):
@classmethod @classmethod
def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int: 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): if not isinstance(config, dict):
return _DEFAULT_ARCHIVE_SCALE return _DEFAULT_ARCHIVE_SCALE
entry = config.get("provider", {}).get("openlibrary", {}) entry = config.get("plugin", {}).get("openlibrary", {})
if not isinstance(entry, dict): if not isinstance(entry, dict):
return _DEFAULT_ARCHIVE_SCALE 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.config import get_nested_config_value as _get_nested
from SYS.logger import debug from SYS.logger import debug
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError try:
from playwright.sync_api import sync_playwright 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) # Re-export for consumers (e.g. cmdlets catching navigation timeouts)
__all__ = [ __all__ = [
@@ -100,16 +109,16 @@ class PlaywrightTool:
- user-agent/viewport defaults - user-agent/viewport defaults
- ffmpeg path resolution (for video recording) - ffmpeg path resolution (for video recording)
Config overrides (top-level keys): Config overrides (plugin.playwright keys):
- playwright.browser="chromium" - plugin.playwright.browser="chromium"
- playwright.headless=true - plugin.playwright.headless=true
- playwright.user_agent="..." - plugin.playwright.user_agent="..."
- playwright.viewport_width=1280 - plugin.playwright.viewport_width=1280
- playwright.viewport_height=1200 - plugin.playwright.viewport_height=1200
- playwright.navigation_timeout_ms=90000 - plugin.playwright.navigation_timeout_ms=90000
- playwright.ignore_https_errors=true - plugin.playwright.ignore_https_errors=true
- playwright.screenshot_quality=8 - plugin.playwright.screenshot_quality=8
- playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set) - plugin.playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
FFmpeg resolution (in order): FFmpeg resolution (in order):
1. Config key: playwright.ffmpeg_path 1. Config key: playwright.ffmpeg_path
@@ -127,22 +136,12 @@ class PlaywrightTool:
def _load_defaults(self) -> PlaywrightDefaults: def _load_defaults(self) -> PlaywrightDefaults:
cfg = self._config cfg = self._config
defaults = PlaywrightDefaults() defaults = PlaywrightDefaults()
tool_block = _get_nested(cfg, "tool", "playwright") pw_block = _get_nested(cfg, "plugin", "playwright")
if not isinstance(tool_block, dict):
tool_block = {}
pw_block = cfg.get("playwright") if isinstance(cfg.get("playwright"),
dict) else {}
if not isinstance(pw_block, dict): if not isinstance(pw_block, dict):
pw_block = {} pw_block = {}
def _get(name: str, fallback: Any) -> Any: def _get(name: str, fallback: Any) -> Any:
val = tool_block.get(name) val = pw_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)
return fallback if val is None else val return fallback if val is None else val
browser = str(_get("browser", defaults.browser)).strip().lower() or "chromium" browser = str(_get("browser", defaults.browser)).strip().lower() or "chromium"
@@ -211,7 +210,7 @@ class PlaywrightTool:
if not ffmpeg_path: if not ffmpeg_path:
# Try to find bundled ffmpeg in the project (Windows-only, in MPV/ffmpeg/bin) # Try to find bundled ffmpeg in the project (Windows-only, in MPV/ffmpeg/bin)
try: try:
repo_root = Path(__file__).resolve().parent.parent repo_root = Path(__file__).resolve().parents[2]
bundled_ffmpeg = repo_root / "MPV" / "ffmpeg" / "bin" bundled_ffmpeg = repo_root / "MPV" / "ffmpeg" / "bin"
if bundled_ffmpeg.exists(): if bundled_ffmpeg.exists():
ffmpeg_exe = bundled_ffmpeg / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg") 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]: def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]:
provider = config.get("provider") plugin_cfg = config.get("plugin")
if not isinstance(provider, dict): if not isinstance(plugin_cfg, dict):
return "", "" return "", ""
entry = provider.get("podcastindex") entry = plugin_cfg.get("podcastindex")
if not isinstance(entry, dict): if not isinstance(entry, dict):
return "", "" return "", ""
@@ -290,7 +290,7 @@ class PodcastIndex(Provider):
pass pass
try: try:
from API.HTTP import _download_direct_file from API.HTTP import download_direct_file
except Exception: except Exception:
return True return True
@@ -308,7 +308,7 @@ class PodcastIndex(Provider):
title_hint = str(item.get("title") or md.get("title") or "episode").strip() or "episode" title_hint = str(item.get("title") or md.get("title") or "episode").strip() or "episode"
try: try:
result_obj = _download_direct_file( result_obj = download_direct_file(
enc_url, enc_url,
Path(output_dir), Path(output_dir),
quiet=False, quiet=False,
@@ -357,12 +357,12 @@ class PodcastIndex(Provider):
"path": str(local_path), "path": str(local_path),
"hash": sha256, "hash": sha256,
"title": title_hint, "title": title_hint,
"action": "provider:podcastindex.selector", "action": "plugin:podcastindex.selector",
"download_mode": "file", "download_mode": "file",
"store": "local", "store": "local",
"media_kind": "audio", "media_kind": "audio",
"tag": tags, "tag": tags,
"provider": "podcastindex", "plugin": "podcastindex",
"url": enc_url, "url": enc_url,
} }
if isinstance(md, dict) and md: if isinstance(md, dict) and md:
+2 -2
View File
@@ -390,7 +390,7 @@ class SCP(Provider):
table.set_table("scp") table.set_table("scp")
try: try:
table.set_table_metadata({ table.set_table_metadata({
"provider": "scp", "plugin": "scp",
"instance": instance_name or None, "instance": instance_name or None,
"host": settings.get("host"), "host": settings.get("host"),
"path": target_path, "path": target_path,
@@ -958,7 +958,7 @@ class SCP(Provider):
parent = posixpath.dirname(scp_path.rstrip("/")) or "/" parent = posixpath.dirname(scp_path.rstrip("/")) or "/"
instance_name = str(settings.get("instance") or "").strip() instance_name = str(settings.get("instance") or "").strip()
metadata = { metadata = {
"provider": "scp", "plugin": "scp",
"instance": instance_name or None, "instance": instance_name or None,
"host": settings.get("host"), "host": settings.get("host"),
"scp_path": scp_path, "scp_path": scp_path,
+1 -1
View File
@@ -690,7 +690,7 @@ class Soulseek(Provider):
"album": item["album"], "album": item["album"],
"track_num": item["track_num"], "track_num": item["track_num"],
"ext": item["ext"], "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): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
telegram_conf = ( telegram_conf = (
self.config.get("provider", self.config.get("plugin",
{}).get("telegram", {}).get("telegram",
{}) if isinstance(self.config, {}) if isinstance(self.config,
dict) else {} dict) else {}
@@ -1280,7 +1280,7 @@ class Telegram(Provider):
info: Dict[str, info: Dict[str,
Any] = { Any] = {
"provider": "telegram", "plugin": "telegram",
"source_url": url, "source_url": url,
"chat": { "chat": {
"key": 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 urllib.parse import urlparse
from plugins.tidal.api import ( from plugins.tidal.api import (
Tidal as TidalApiClient, Tidal,
build_track_tags, build_track_tags,
coerce_duration_seconds, coerce_duration_seconds,
extract_artists, extract_artists,
@@ -208,7 +208,7 @@ class Tidal(Provider):
self.api_timeout = float(self.config.get("timeout", 10.0)) self.api_timeout = float(self.config.get("timeout", 10.0))
except Exception: except Exception:
self.api_timeout = 10.0 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]: def resolve_playback_path(self, item: Any, **_kwargs: Any) -> Optional[str]:
return resolve_tidal_manifest_path(item) return resolve_tidal_manifest_path(item)
@@ -960,7 +960,7 @@ class Tidal(Provider):
try: try:
table.set_table_metadata( table.set_table_metadata(
{ {
"provider": "tidal", "plugin": "tidal",
"view": "track", "view": "track",
"album_id": album_id, "album_id": album_id,
"album_title": album_title, "album_title": album_title,
@@ -1670,7 +1670,7 @@ class Tidal(Provider):
return downloaded_count 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("/") base = base_url.rstrip("/")
for client in self.api_clients: for client in self.api_clients:
if getattr(client, "base_url", "").rstrip("/") == base: if getattr(client, "base_url", "").rstrip("/") == base:
@@ -2206,7 +2206,7 @@ class Tidal(Provider):
table = Table(f"Tidal Albums: {artist_name}")._perseverance(False) table = Table(f"Tidal Albums: {artist_name}")._perseverance(False)
table.set_table("tidal.album") table.set_table("tidal.album")
try: 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: except Exception:
pass pass
@@ -2268,7 +2268,7 @@ class Tidal(Provider):
try: try:
table.set_table_metadata( table.set_table_metadata(
{ {
"provider": "tidal", "plugin": "tidal",
"view": "track", "view": "track",
"album_id": album_id, "album_id": album_id,
"album_title": album_title, "album_title": album_title,
@@ -2338,7 +2338,7 @@ class Tidal(Provider):
table = Table("Tidal Track")._perseverance(True) table = Table("Tidal Track")._perseverance(True)
table.set_table("tidal.track") table.set_table("tidal.track")
try: 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: except Exception:
pass pass
results_payload: List[Dict[str, Any]] = [] results_payload: List[Dict[str, Any]] = []
-5
View File
@@ -320,8 +320,3 @@ class Tidal(API):
border_style="cyan", border_style="cyan",
) )
return res 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 PluginCore.inline_utils import resolve_filter
from SYS.logger import debug, debug_panel from SYS.logger import debug, debug_panel
from SYS.plugin_helpers import TablePluginMixin from SYS.plugin_helpers import TablePluginMixin
from tool.playwright import PlaywrightTool from plugins.playwright import PlaywrightTool
class Vimm(TablePluginMixin, Provider): 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.rich_display import stderr_console as get_stderr_console
from SYS import pipeline as pipeline_context from SYS import pipeline as pipeline_context
from SYS.utils import sha256_file from SYS.utils import sha256_file
from tool.ytdlp import ( from .tooling import (
YtDlpTool, YtDlpTool,
_best_subtitle_sidecar, _best_subtitle_sidecar,
_SUBTITLE_EXTS, _SUBTITLE_EXTS,
_download_with_timeout, _download_with_timeout,
_format_chapters_note, _format_chapters_note,
config_schema as _ytdlp_config_schema,
_read_text_file, _read_text_file,
collapse_picker_formats, collapse_picker_formats,
format_for_table_selection, format_for_table_selection,
@@ -508,6 +509,10 @@ class ytdlp(TablePluginMixin, Provider):
PLUGIN_ALIASES = ("youtube",) PLUGIN_ALIASES = ("youtube",)
SEARCH_QUERY_KEYS = ("search", "q") SEARCH_QUERY_KEYS = ("search", "q")
@staticmethod
def config_schema() -> List[Dict[str, Any]]:
return _ytdlp_config_schema()
@classmethod @classmethod
def url_patterns(cls) -> Tuple[str, ...]: def url_patterns(cls) -> Tuple[str, ...]:
try: try:
+15 -32
View File
@@ -997,60 +997,43 @@ class YtDlpTool:
# default string value. Use an instance for fallback defaults. # default string value. Use an instance for fallback defaults.
_fallback_defaults = YtDlpDefaults() _fallback_defaults = YtDlpDefaults()
tool_block = _get_nested(cfg, "tool", "ytdlp") ytdlp_block = _get_nested(cfg, "plugin", "ytdlp")
if not isinstance(tool_block, dict):
tool_block = {}
ytdlp_block = cfg.get("ytdlp") if isinstance(cfg.get("ytdlp"),
dict) else {}
if not isinstance(ytdlp_block, dict): if not isinstance(ytdlp_block, dict):
ytdlp_block = {} ytdlp_block = {}
# Accept both nested and flat styles.
video_format = ( video_format = (
tool_block.get("video_format") or tool_block.get("format") ytdlp_block.get("video_format") or ytdlp_block.get("video")
or ytdlp_block.get("video_format") or ytdlp_block.get("video") or ytdlp_block.get("format_video")
or ytdlp_block.get("format_video") or cfg.get("ytdlp_video_format")
) )
audio_format = ( audio_format = (
tool_block.get("audio_format") or ytdlp_block.get("audio_format") ytdlp_block.get("audio_format") or ytdlp_block.get("audio")
or ytdlp_block.get("audio") or ytdlp_block.get("format_audio") or ytdlp_block.get("format_audio")
or cfg.get("ytdlp_audio_format")
) )
# Also accept dotted keys written as nested dicts: ytdlp.format.video, ytdlp.format.audio nested_video = _get_nested(cfg, "plugin", "ytdlp", "format", "video")
nested_video = _get_nested(cfg, "ytdlp", "format", "video") nested_audio = _get_nested(cfg, "plugin", "ytdlp", "format", "audio")
nested_audio = _get_nested(cfg, "ytdlp", "format", "audio")
fmt_sort_val = ( fmt_sort_val = (
tool_block.get("format_sort") or ytdlp_block.get("format_sort") ytdlp_block.get("format_sort")
or ytdlp_block.get("formatSort") or cfg.get("ytdlp_format_sort") or ytdlp_block.get("formatSort")
or _get_nested(cfg, or _get_nested(cfg,
"plugin",
"ytdlp", "ytdlp",
"format", "format",
"sort") "sort")
) )
fmt_sort = _parse_csv_list(fmt_sort_val) fmt_sort = _parse_csv_list(fmt_sort_val)
# Cookie source preference: allow forcing a browser DB or 'auto'/'none'
cookies_pref = ( cookies_pref = (
tool_block.get("cookies_from_browser") ytdlp_block.get("cookies_from_browser")
or tool_block.get("cookiesfrombrowser")
or ytdlp_block.get("cookies_from_browser")
or ytdlp_block.get("cookiesfrombrowser") or ytdlp_block.get("cookiesfrombrowser")
or cfg.get("ytdlp_cookies_from_browser") or _get_nested(cfg, "plugin", "ytdlp", "cookies_from_browser")
or _get_nested(cfg, "ytdlp", "cookies_from_browser")
) )
# Unified format preference: prefer explicit 'format' key but accept legacy keys
format_pref = ( format_pref = (
tool_block.get("format") ytdlp_block.get("format")
or tool_block.get("video_format")
or ytdlp_block.get("format")
or ytdlp_block.get("video_format") or ytdlp_block.get("video_format")
or cfg.get("ytdlp_format") or _get_nested(cfg, "plugin", "ytdlp", "format")
or cfg.get("ytdlp_video_format")
or _get_nested(cfg, "ytdlp", "format")
) )
defaults = YtDlpDefaults( defaults = YtDlpDefaults(
@@ -1121,7 +1104,7 @@ class YtDlpTool:
) )
try: try:
repo_root = Path(__file__).resolve().parents[1] repo_root = Path(__file__).resolve().parents[2]
bundled_ffmpeg_dir = repo_root / "MPV" / "ffmpeg" / "bin" bundled_ffmpeg_dir = repo_root / "MPV" / "ffmpeg" / "bin"
if bundled_ffmpeg_dir.exists(): if bundled_ffmpeg_dir.exists():
base_options.setdefault("ffmpeg_location", str(bundled_ffmpeg_dir)) 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( p.add_argument(
"--pull", "--pull",
action="store_true", 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( p.add_argument(
"--no-update", "--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