update and cleanup repo
This commit is contained in:
@@ -33,7 +33,6 @@ except Exception: # pragma: no cover - optional dependency
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from API.ssl_certs import resolve_verify_value as _resolve_verify_value
|
||||
from API.ssl_certs import get_requests_verify_value
|
||||
from API.httpx_shared import get_shared_httpx_client
|
||||
|
||||
# Default configuration
|
||||
@@ -924,11 +923,6 @@ def download_direct_file(
|
||||
)
|
||||
raise DownloadError(f"Error downloading file: {exc}") from exc
|
||||
|
||||
|
||||
# Back-compat alias
|
||||
_download_direct_file = download_direct_file
|
||||
|
||||
|
||||
class AsyncHTTPClient:
|
||||
"""Unified async HTTP client with asyncio support."""
|
||||
|
||||
|
||||
@@ -101,9 +101,3 @@ def resolve_verify_value(verify_ssl: bool) -> Union[bool, str]:
|
||||
logger.exception("Failed to probe certifi for trust bundle")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_requests_verify_value(verify_ssl: bool = True) -> Union[bool, str]:
|
||||
"""Backwards-friendly alias for call sites that only care about requests."""
|
||||
|
||||
return resolve_verify_value(verify_ssl)
|
||||
|
||||
@@ -60,7 +60,7 @@ from SYS.rich_display import (
|
||||
from cmdnat._status_shared import (
|
||||
add_startup_check as _shared_add_startup_check,
|
||||
collect_plugin_startup_checks as _collect_plugin_startup_checks,
|
||||
has_tool as _has_tool,
|
||||
has_provider as _has_provider,
|
||||
)
|
||||
|
||||
|
||||
@@ -479,7 +479,7 @@ class CmdletIntrospection:
|
||||
def store_choices(config: Dict[str, Any], force: bool = False) -> List[str]:
|
||||
try:
|
||||
# Use the cached startup check from SharedArgs
|
||||
from cmdlet._shared import SharedArgs
|
||||
from SYS.cmdlet_spec import SharedArgs
|
||||
return SharedArgs.get_store_choices(config, force=force)
|
||||
except Exception:
|
||||
return []
|
||||
@@ -1361,7 +1361,7 @@ class CmdletCompleter(Completer):
|
||||
)
|
||||
if choices:
|
||||
choice_list = choices
|
||||
if normalized_prev in {"plugin", "provider"} and current_token:
|
||||
if normalized_prev == "plugin" and current_token:
|
||||
current_lower = current_token.lower()
|
||||
filtered = [c for c in choices if c.lower().startswith(current_lower)]
|
||||
if filtered:
|
||||
@@ -1665,20 +1665,10 @@ class CmdletExecutor:
|
||||
mod = import_cmd_module(cmd_name, reload_loaded=True)
|
||||
data = getattr(mod, "CMDLET", None) if mod else None
|
||||
if data and hasattr(data, "exec") and callable(getattr(data, "exec")):
|
||||
from SYS.cmdlet_spec import collect_registered_cmdlet_names
|
||||
|
||||
run_fn = getattr(data, "exec")
|
||||
registered_names = set()
|
||||
raw_name = getattr(data, "name", None)
|
||||
if raw_name:
|
||||
registered_names.add(str(raw_name).replace("_", "-").lower())
|
||||
registered_names.add(str(cmd_name).replace("_", "-").lower())
|
||||
for alias_attr in ("alias", "aliases"):
|
||||
alias_values = getattr(data, alias_attr, None)
|
||||
if alias_values:
|
||||
for alias in alias_values:
|
||||
alias_text = str(alias or "").replace("_", "-").lower().strip()
|
||||
if alias_text:
|
||||
registered_names.add(alias_text)
|
||||
for registered_name in registered_names:
|
||||
for registered_name in collect_registered_cmdlet_names(data, fallback_name=cmd_name):
|
||||
REGISTRY[registered_name] = run_fn
|
||||
cmd_fn = run_fn
|
||||
except Exception:
|
||||
@@ -2336,7 +2326,7 @@ class CLI:
|
||||
|
||||
# Initialize the store choices cache at startup (filters disabled stores)
|
||||
try:
|
||||
from cmdlet._shared import SharedArgs
|
||||
from SYS.cmdlet_spec import SharedArgs
|
||||
config = self._config_loader.load()
|
||||
SharedArgs._refresh_store_choices_cache(config)
|
||||
except Exception:
|
||||
@@ -2622,17 +2612,17 @@ Come to love it when others take what you share, as there is no greater joy
|
||||
files=check.get("files"),
|
||||
)
|
||||
|
||||
# Tool checks (configured via [tool=...])
|
||||
if _has_tool(config, "florencevision"):
|
||||
# Plugin support checks (configured via [plugin=...])
|
||||
if _has_provider(config, "florencevision"):
|
||||
try:
|
||||
tool_cfg = config.get("tool")
|
||||
fv_cfg = tool_cfg.get("florencevision") if isinstance(tool_cfg, dict) else None
|
||||
plugin_cfg = config.get("plugin")
|
||||
fv_cfg = plugin_cfg.get("florencevision") if isinstance(plugin_cfg, dict) else None
|
||||
enabled = bool(fv_cfg.get("enabled")) if isinstance(fv_cfg, dict) else False
|
||||
if not enabled:
|
||||
_add_startup_check(
|
||||
"DISABLED",
|
||||
"FlorenceVision",
|
||||
provider="tool",
|
||||
provider="plugin",
|
||||
detail="Not enabled",
|
||||
)
|
||||
else:
|
||||
@@ -2643,21 +2633,21 @@ Come to love it when others take what you share, as there is no greater joy
|
||||
_add_startup_check(
|
||||
"DISABLED",
|
||||
"FlorenceVision",
|
||||
provider="tool",
|
||||
provider="plugin",
|
||||
detail="Missing: " + ", ".join(missing),
|
||||
)
|
||||
else:
|
||||
_add_startup_check(
|
||||
"ENABLED",
|
||||
"FlorenceVision",
|
||||
provider="tool",
|
||||
provider="plugin",
|
||||
detail="Ready",
|
||||
)
|
||||
except Exception as exc:
|
||||
_add_startup_check(
|
||||
"DISABLED",
|
||||
"FlorenceVision",
|
||||
provider="tool",
|
||||
provider="plugin",
|
||||
detail=str(exc),
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -21,7 +21,7 @@ _PLUGIN_DISCOVERED_CLASSES_CACHE: Dict[str, Optional[Type[BackendBase]]] = {}
|
||||
|
||||
# Backends that failed to initialize earlier in the current process.
|
||||
# Keyed by (backend_type, instance_key) where instance_key is the configured name
|
||||
# under config.store.<type>.<instance_key>.
|
||||
# under config.plugin.<type>.<instance_key>.
|
||||
_FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {}
|
||||
|
||||
|
||||
@@ -186,12 +186,12 @@ class BackendRegistry:
|
||||
self._load_backends()
|
||||
|
||||
def _load_backends(self) -> None:
|
||||
store_cfg = self._config.get("store")
|
||||
if not isinstance(store_cfg, dict):
|
||||
store_cfg = {}
|
||||
plugin_cfg = self._config.get("plugin")
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
plugin_cfg = {}
|
||||
|
||||
self._backend_types = {}
|
||||
for raw_backend_type, instances in store_cfg.items():
|
||||
for raw_backend_type, instances in plugin_cfg.items():
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
|
||||
@@ -357,7 +357,7 @@ def list_configured_backend_names(config: Optional[Dict[str, Any]]) -> list[str]
|
||||
"""Return configured backend instance names without instantiating backends."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
for section_name in ("store", "plugin", "provider"):
|
||||
for section_name in ("plugin",):
|
||||
section_cfg = (config or {}).get(section_name) or {}
|
||||
if not isinstance(section_cfg, dict):
|
||||
continue
|
||||
@@ -403,7 +403,7 @@ def get_backend_instance(
|
||||
return None
|
||||
desired = str(backend_name or "").strip().lower()
|
||||
|
||||
for section_name in ("store", "plugin", "provider"):
|
||||
for section_name in ("plugin",):
|
||||
section_cfg = (config or {}).get(section_name) or {}
|
||||
if not isinstance(section_cfg, dict):
|
||||
continue
|
||||
|
||||
+5
-14
@@ -323,20 +323,11 @@ class Provider(ABC):
|
||||
def plugin_config_root(self) -> Dict[str, Any]:
|
||||
if not isinstance(self.config, dict):
|
||||
return {}
|
||||
# Check plugin/provider section first (preferred new format)
|
||||
for section in ("plugin", "provider"):
|
||||
section_cfg = self.config.get(section)
|
||||
if isinstance(section_cfg, dict):
|
||||
entry = section_cfg.get(self.plugin_config_key())
|
||||
if isinstance(entry, dict):
|
||||
return dict(entry)
|
||||
# Backward compat: fall back to store section.
|
||||
# store config uses {type: {instance: {key: val}}} — one level deeper.
|
||||
store_cfg = self.config.get("store")
|
||||
if isinstance(store_cfg, dict):
|
||||
store_entries = store_cfg.get(self.plugin_config_key())
|
||||
if isinstance(store_entries, dict):
|
||||
return dict(store_entries)
|
||||
section_cfg = self.config.get("plugin")
|
||||
if isinstance(section_cfg, dict):
|
||||
entry = section_cfg.get(self.plugin_config_key())
|
||||
if isinstance(entry, dict):
|
||||
return dict(entry)
|
||||
return {}
|
||||
|
||||
def plugin_instance_configs(self) -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
+17
-9
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
import pkgutil
|
||||
from typing import Any, Callable, Dict, Iterable, Sequence
|
||||
|
||||
|
||||
@@ -64,22 +65,29 @@ def _register_command_object(cmdlet_obj: Any, registry: Dict[str, CmdletFn]) ->
|
||||
|
||||
def iter_plugin_command_module_names() -> list[str]:
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
package = import_module("plugins")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
plugins_dir = repo_root / "plugins"
|
||||
if not plugins_dir.is_dir():
|
||||
package_path = getattr(package, "__path__", None)
|
||||
if not package_path:
|
||||
return []
|
||||
|
||||
module_names: list[str] = []
|
||||
for entry in sorted(plugins_dir.iterdir(), key=lambda path: path.name.lower()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
seen: set[str] = set()
|
||||
for _, module_name, is_package in pkgutil.iter_modules(package_path):
|
||||
if not is_package or module_name.startswith("_"):
|
||||
continue
|
||||
if not (entry / "__init__.py").is_file():
|
||||
commands_module = f"plugins.{module_name}.commands"
|
||||
try:
|
||||
if importlib.util.find_spec(commands_module) is None:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if (entry / "commands.py").is_file() or (entry / "commands" / "__init__.py").is_file():
|
||||
module_names.append(f"plugins.{entry.name}.commands")
|
||||
if commands_module in seen:
|
||||
continue
|
||||
seen.add(commands_module)
|
||||
module_names.append(commands_module)
|
||||
return module_names
|
||||
|
||||
|
||||
|
||||
+19
-12
@@ -1,4 +1,4 @@
|
||||
"""Inline query helpers for providers (choice normalization and filter resolution)."""
|
||||
"""Inline query helpers for plugins (choice normalization and filter resolution)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -24,7 +24,7 @@ def collect_choice(provider: Any) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Collect normalized inline/query argument choice entries from a provider.
|
||||
|
||||
Supports QUERY_ARG_CHOICES, INLINE_QUERY_FIELD_CHOICES, and the
|
||||
helper methods valued by Providers (`query_field_choices` /
|
||||
helper methods exposed by plugins (`query_field_choices` /
|
||||
`inline_query_field_choices`). Each choice is normalized to {value,text,aliases}.
|
||||
"""
|
||||
|
||||
@@ -48,24 +48,31 @@ def collect_choice(provider: Any) -> Dict[str, List[Dict[str, Any]]]:
|
||||
if normalized:
|
||||
mapping[target_key] = normalized
|
||||
|
||||
base = getattr(provider, "QUERY_ARG_CHOICES", None)
|
||||
if isinstance(base, dict):
|
||||
for k, v in base.items():
|
||||
def _merge_mapping(source: Any) -> None:
|
||||
if not isinstance(source, dict):
|
||||
return
|
||||
for k, v in source.items():
|
||||
key_norm = str(k).strip().lower()
|
||||
if not key_norm:
|
||||
continue
|
||||
_ingest(v, key_norm)
|
||||
|
||||
base = getattr(provider, "QUERY_ARG_CHOICES", None)
|
||||
if not isinstance(base, dict):
|
||||
base = getattr(provider, "INLINE_QUERY_FIELD_CHOICES", None)
|
||||
_merge_mapping(base)
|
||||
|
||||
try:
|
||||
fn = getattr(provider, "query_field_choices", None)
|
||||
if callable(fn):
|
||||
_merge_mapping(fn())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
fn = getattr(provider, "inline_query_field_choices", None)
|
||||
if callable(fn):
|
||||
extra = fn()
|
||||
if isinstance(extra, dict):
|
||||
for k, v in extra.items():
|
||||
key_norm = str(k).strip().lower()
|
||||
if not key_norm:
|
||||
continue
|
||||
_ingest(v, key_norm)
|
||||
_merge_mapping(fn())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+33
-111
@@ -23,6 +23,7 @@ from urllib.parse import urlparse
|
||||
from SYS.logger import log, debug
|
||||
|
||||
from PluginCore.base import Provider, SearchResult
|
||||
from PluginCore.inline_utils import collect_choice, resolve_filter
|
||||
|
||||
|
||||
_EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
|
||||
@@ -150,6 +151,14 @@ class PluginInfo:
|
||||
exposed = True
|
||||
return exposed and _class_supports_method(self.plugin_class, "upload", Provider.upload)
|
||||
|
||||
@property
|
||||
def supports_download(self) -> bool:
|
||||
return (
|
||||
_class_supports_method(self.plugin_class, "handle_url", Provider.handle_url)
|
||||
or _class_supports_method(self.plugin_class, "download_url", Provider.download_url)
|
||||
or _class_supports_method(self.plugin_class, "download", Provider.download)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_multi_instance(self) -> bool:
|
||||
"""True if the plugin declares MULTI_INSTANCE = True."""
|
||||
@@ -542,6 +551,7 @@ def get_plugin_capabilities(
|
||||
"supported_cmdlets": [],
|
||||
"supports_search": False,
|
||||
"supports_upload": False,
|
||||
"supports_download": False,
|
||||
"supports_pipe_download": False,
|
||||
"supports_delete_file": False,
|
||||
"supports_url_association": False,
|
||||
@@ -582,6 +592,7 @@ def get_plugin_capabilities(
|
||||
"supported_cmdlets": supported_cmdlets,
|
||||
"supports_search": bool(info.supports_search),
|
||||
"supports_upload": bool(info.supports_upload),
|
||||
"supports_download": bool(info.supports_download),
|
||||
"supports_pipe_download": bool(supports_pipe_download),
|
||||
"supports_delete_file": bool(supports_delete_file),
|
||||
"supports_url_association": bool(supports_url_association),
|
||||
@@ -627,6 +638,14 @@ def _supports_upload(provider: Provider) -> bool:
|
||||
return exposed and _class_supports_method(provider.__class__, "upload", Provider.upload)
|
||||
|
||||
|
||||
def _supports_download(provider: Provider) -> bool:
|
||||
return (
|
||||
_class_supports_method(provider.__class__, "handle_url", Provider.handle_url)
|
||||
or _class_supports_method(provider.__class__, "download_url", Provider.download_url)
|
||||
or _class_supports_method(provider.__class__, "download", Provider.download)
|
||||
)
|
||||
|
||||
|
||||
def _supports_pipe_result_download(provider: Provider) -> bool:
|
||||
return _class_supports_method(
|
||||
provider.__class__,
|
||||
@@ -647,6 +666,8 @@ def _supports_capability(provider: Provider, capability: str) -> bool:
|
||||
return _supports_search(provider)
|
||||
if capability_key in {"upload", "file", "file-provider"}:
|
||||
return _supports_upload(provider)
|
||||
if capability_key in {"download", "download-file", "download_file"}:
|
||||
return _supports_download(provider)
|
||||
if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
|
||||
return _supports_pipe_result_download(provider)
|
||||
if capability_key in {"delete-file", "delete_file", "delete"}:
|
||||
@@ -672,6 +693,8 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
|
||||
return bool(info.supports_search)
|
||||
if capability_key in {"upload", "file", "file-provider"}:
|
||||
return bool(info.supports_upload)
|
||||
if capability_key in {"download", "download-file", "download_file"}:
|
||||
return bool(info.supports_download)
|
||||
if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
|
||||
return _class_supports_method(
|
||||
info.plugin_class,
|
||||
@@ -697,62 +720,6 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]:
|
||||
if entry is None:
|
||||
return None
|
||||
if isinstance(entry, dict):
|
||||
value = entry.get("value")
|
||||
text = entry.get("text") or entry.get("label") or value
|
||||
aliases = entry.get("alias") or entry.get("aliases") or []
|
||||
value_str = str(value) if value is not None else (str(text) if text is not None else None)
|
||||
text_str = str(text) if text is not None else value_str
|
||||
if not value_str or not text_str:
|
||||
return None
|
||||
alias_list = [str(a) for a in aliases if a is not None]
|
||||
return {"value": value_str, "text": text_str, "aliases": alias_list}
|
||||
return {"value": str(entry), "text": str(entry), "aliases": []}
|
||||
|
||||
|
||||
def _collect_inline_choice_mapping(provider: Provider) -> Dict[str, List[Dict[str, Any]]]:
|
||||
mapping: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
base = getattr(provider, "QUERY_ARG_CHOICES", None)
|
||||
if not isinstance(base, dict):
|
||||
base = getattr(provider, "INLINE_QUERY_FIELD_CHOICES", None)
|
||||
|
||||
def _merge_from(obj: Any) -> None:
|
||||
if not isinstance(obj, dict):
|
||||
return
|
||||
for key, value in obj.items():
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
seq = value
|
||||
try:
|
||||
if callable(seq):
|
||||
seq = seq()
|
||||
except Exception:
|
||||
seq = value
|
||||
if isinstance(seq, dict):
|
||||
seq = seq.get("choices") or seq.get("values") or seq
|
||||
if isinstance(seq, (list, tuple, set)):
|
||||
for entry in seq:
|
||||
n = _normalize_choice_entry(entry)
|
||||
if n:
|
||||
normalized.append(n)
|
||||
if normalized:
|
||||
mapping[str(key).strip().lower()] = normalized
|
||||
|
||||
_merge_from(base)
|
||||
|
||||
try:
|
||||
fn = getattr(provider, "inline_query_field_choices", None)
|
||||
if callable(fn):
|
||||
_merge_from(fn())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
|
||||
info = REGISTRY.get(name)
|
||||
if info is None:
|
||||
@@ -838,12 +805,11 @@ def list_configured_plugin_names_with_capability(
|
||||
"""Return plugin names that support `capability` AND have configuration present.
|
||||
|
||||
For MULTI_INSTANCE plugins (e.g. hydrusnetwork, ftp) the plugin must have at
|
||||
least one configured instance. For single-instance plugins the plugin's section
|
||||
must exist under config["plugin"] or config["provider"].
|
||||
least one configured instance. For single-instance plugins the plugin's section
|
||||
must exist under config["plugin"].
|
||||
"""
|
||||
cfg = config or {}
|
||||
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment]
|
||||
provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment]
|
||||
|
||||
result: List[str] = []
|
||||
for info in REGISTRY.iter_plugins():
|
||||
@@ -862,7 +828,7 @@ def list_configured_plugin_names_with_capability(
|
||||
pass
|
||||
else:
|
||||
pname = name.lower()
|
||||
if isinstance(plugin_section.get(pname), dict) or isinstance(provider_section.get(pname), dict):
|
||||
if isinstance(plugin_section.get(pname), dict):
|
||||
result.append(name)
|
||||
return sorted(result)
|
||||
|
||||
@@ -890,7 +856,7 @@ def list_plugin_names_for_cmdlet(
|
||||
fallback_capability = {
|
||||
"search-file": "search",
|
||||
"add-file": "upload",
|
||||
"download-file": "search",
|
||||
"download-file": "download",
|
||||
"delete-file": "delete-file",
|
||||
}.get(cmd)
|
||||
|
||||
@@ -905,12 +871,11 @@ def list_plugin_names_for_cmdlet(
|
||||
if fallback_capability:
|
||||
configured.update(list_configured_plugin_names_with_capability(fallback_capability, cfg))
|
||||
|
||||
# Keep cmdlet-declared plugins if they appear configured in plugin/provider sections.
|
||||
# Keep cmdlet-declared plugins if they appear configured in the plugin section.
|
||||
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment]
|
||||
provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment]
|
||||
for name in supported:
|
||||
key = str(name or "").strip().lower()
|
||||
if isinstance(plugin_section.get(key), dict) or isinstance(provider_section.get(key), dict):
|
||||
if isinstance(plugin_section.get(key), dict):
|
||||
configured.add(name)
|
||||
|
||||
return sorted(configured)
|
||||
@@ -995,13 +960,13 @@ def plugin_inline_query_choices(
|
||||
mapping: Dict[str, List[Dict[str, Any]]] = {}
|
||||
info = REGISTRY.get(pname)
|
||||
if info is not None:
|
||||
mapping = _collect_inline_choice_mapping(info.plugin_class)
|
||||
mapping = collect_choice(info.plugin_class)
|
||||
|
||||
if not mapping:
|
||||
plugin = get_plugin(pname, config)
|
||||
if plugin is None:
|
||||
return []
|
||||
mapping = _collect_inline_choice_mapping(plugin)
|
||||
mapping = collect_choice(plugin)
|
||||
|
||||
if not mapping:
|
||||
return []
|
||||
@@ -1065,52 +1030,9 @@ def resolve_inline_filters(
|
||||
*,
|
||||
field_transforms: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Map inline query args to provider filter values using declared choices.
|
||||
"""Map inline query args to plugin filter values using the canonical helper."""
|
||||
|
||||
- Uses provider's inline choice mapping (value/text/aliases) to resolve user text.
|
||||
- Applies optional per-field transforms (e.g., str.upper).
|
||||
- Returns normalized filters suitable for provider.search.
|
||||
"""
|
||||
|
||||
filters: Dict[str, str] = {}
|
||||
if not inline_args:
|
||||
return filters
|
||||
|
||||
mapping = _collect_inline_choice_mapping(provider)
|
||||
transforms = field_transforms or {}
|
||||
|
||||
for raw_key, raw_val in inline_args.items():
|
||||
if raw_val is None:
|
||||
continue
|
||||
key = str(raw_key or "").strip().lower()
|
||||
val_str = str(raw_val).strip()
|
||||
if not key or not val_str:
|
||||
continue
|
||||
|
||||
entries = mapping.get(key, [])
|
||||
resolved: Optional[str] = None
|
||||
val_lower = val_str.lower()
|
||||
for entry in entries:
|
||||
text = str(entry.get("text") or "").strip()
|
||||
value = str(entry.get("value") or "").strip()
|
||||
aliases = [str(a).strip() for a in entry.get("aliases", []) if a is not None]
|
||||
if val_lower in {text.lower(), value.lower()} or val_lower in {a.lower() for a in aliases}:
|
||||
resolved = value or text or val_str
|
||||
break
|
||||
|
||||
if resolved is None:
|
||||
resolved = val_str
|
||||
|
||||
transform = transforms.get(key)
|
||||
if callable(transform):
|
||||
try:
|
||||
resolved = transform(resolved)
|
||||
except Exception:
|
||||
pass
|
||||
if resolved:
|
||||
filters[key] = str(resolved)
|
||||
|
||||
return filters
|
||||
return resolve_filter(provider, inline_args, field_transforms=field_transforms)
|
||||
|
||||
|
||||
def clear_plugin_cache() -> None:
|
||||
|
||||
@@ -410,10 +410,10 @@ def get_cmdlet_arg_choices(
|
||||
|
||||
matrix_conf = {}
|
||||
try:
|
||||
providers = config.get("provider") or {}
|
||||
matrix_conf = providers.get("matrix") or {}
|
||||
plugins = config.get("plugin") or {}
|
||||
matrix_conf = plugins.get("matrix") or {}
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to read matrix provider config: %s", exc)
|
||||
logger.exception("Failed to read matrix plugin config: %s", exc)
|
||||
matrix_conf = {}
|
||||
|
||||
raw = None
|
||||
|
||||
@@ -77,6 +77,50 @@ def QueryArg(
|
||||
)
|
||||
|
||||
|
||||
def collect_registered_cmdlet_names(
|
||||
cmdlet_obj: Any,
|
||||
*,
|
||||
fallback_name: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""Return normalized registration keys for a cmdlet object.
|
||||
|
||||
Prefers the cmdlet object's own `_collect_names()` implementation when
|
||||
available, then normalizes names to the registry key form used by callers.
|
||||
"""
|
||||
|
||||
raw_names: List[Any] = []
|
||||
|
||||
collector = getattr(cmdlet_obj, "_collect_names", None)
|
||||
if callable(collector):
|
||||
try:
|
||||
raw_names.extend(list(collector() or []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if fallback_name:
|
||||
raw_names.append(fallback_name)
|
||||
|
||||
if not raw_names:
|
||||
raw_name = getattr(cmdlet_obj, "name", None)
|
||||
if raw_name:
|
||||
raw_names.append(raw_name)
|
||||
for alias_attr in ("alias", "aliases"):
|
||||
alias_values = getattr(cmdlet_obj, alias_attr, None)
|
||||
if not alias_values:
|
||||
continue
|
||||
raw_names.extend(list(alias_values))
|
||||
|
||||
seen: Set[str] = set()
|
||||
normalized_names: List[str] = []
|
||||
for raw_name in raw_names:
|
||||
key = str(raw_name or "").replace("_", "-").lower().strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
normalized_names.append(key)
|
||||
return normalized_names
|
||||
|
||||
|
||||
class SharedArgs:
|
||||
"""Registry of shared CmdletArg definitions used across multiple cmdlet."""
|
||||
|
||||
@@ -100,6 +144,13 @@ class SharedArgs:
|
||||
description="selects plugin",
|
||||
)
|
||||
|
||||
INSTANCE = CmdletArg(
|
||||
name="instance",
|
||||
type="string",
|
||||
description="Selects a plugin instance",
|
||||
query_key="instance",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_store_choices(config: Optional[Dict[str, Any]] = None, force: bool = False) -> List[str]:
|
||||
if not force and hasattr(SharedArgs, "_cached_available_stores"):
|
||||
|
||||
+122
-228
@@ -95,8 +95,6 @@ def clear_config_cache() -> None:
|
||||
def _log_config_load_summary(config: Dict[str, Any]) -> None:
|
||||
try:
|
||||
plugin_block = config.get("plugin")
|
||||
if not isinstance(plugin_block, dict):
|
||||
plugin_block = config.get("provider")
|
||||
if isinstance(plugin_block, dict):
|
||||
# Count distinct plugin names; note multi-instance plugins appear once per name
|
||||
plugin_names = list(plugin_block.keys())
|
||||
@@ -265,7 +263,9 @@ def set_nested_config_value(
|
||||
def get_hydrus_instance(
|
||||
config: Dict[str, Any], instance_name: str = "home"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific Hydrus instance config by name from plugin/provider config."""
|
||||
"""Get a specific Hydrus instance config by name from plugin config."""
|
||||
_canonicalize_plugin_config(config)
|
||||
|
||||
def _lookup_in(source: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(source, dict) or not source:
|
||||
return None
|
||||
@@ -286,16 +286,13 @@ def get_hydrus_instance(
|
||||
candidate = source.get(first_key) if first_key else None
|
||||
return candidate if isinstance(candidate, dict) else None
|
||||
|
||||
# New format: config["plugin"]["hydrusnetwork"] or config["provider"]["hydrusnetwork"]
|
||||
# (both point to the same dict after normalization)
|
||||
for section in ("plugin", "provider"):
|
||||
section_cfg = config.get(section)
|
||||
if isinstance(section_cfg, dict):
|
||||
hydrus_cfg = section_cfg.get("hydrusnetwork")
|
||||
if isinstance(hydrus_cfg, dict):
|
||||
result = _lookup_in(hydrus_cfg)
|
||||
if result is not None:
|
||||
return result
|
||||
plugin_cfg = config.get("plugin")
|
||||
if isinstance(plugin_cfg, dict):
|
||||
hydrus_cfg = plugin_cfg.get("hydrusnetwork")
|
||||
if isinstance(hydrus_cfg, dict):
|
||||
result = _lookup_in(hydrus_cfg)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
@@ -339,17 +336,17 @@ def get_hydrus_url(config: Dict[str, Any], instance_name: str = "home") -> Optio
|
||||
return str(url).strip() if url else None
|
||||
|
||||
|
||||
def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
|
||||
_normalize_plugin_config_aliases(config)
|
||||
provider_cfg = config.get("provider")
|
||||
if not isinstance(provider_cfg, dict):
|
||||
def get_plugin_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
|
||||
_canonicalize_plugin_config(config)
|
||||
plugin_cfg = config.get("plugin")
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
return {}
|
||||
normalized = _normalize_provider_name(name)
|
||||
if normalized:
|
||||
block = provider_cfg.get(normalized)
|
||||
block = plugin_cfg.get(normalized)
|
||||
if isinstance(block, dict):
|
||||
return block
|
||||
for key, block in provider_cfg.items():
|
||||
for key, block in plugin_cfg.items():
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if _normalize_provider_name(key) == normalized:
|
||||
@@ -358,13 +355,13 @@ def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_soulseek_username(config: Dict[str, Any]) -> Optional[str]:
|
||||
block = get_provider_block(config, "soulseek")
|
||||
block = get_plugin_block(config, "soulseek")
|
||||
val = block.get("username") or block.get("USERNAME")
|
||||
return str(val).strip() if val else None
|
||||
|
||||
|
||||
def get_soulseek_password(config: Dict[str, Any]) -> Optional[str]:
|
||||
block = get_provider_block(config, "soulseek")
|
||||
block = get_plugin_block(config, "soulseek")
|
||||
val = block.get("password") or block.get("PASSWORD")
|
||||
return str(val).strip() if val else None
|
||||
|
||||
@@ -415,33 +412,33 @@ def resolve_output_dir(config: Dict[str, Any]) -> Path:
|
||||
|
||||
|
||||
def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
|
||||
"""Get local storage path from config.
|
||||
"""Return the configured default local plugin destination path.
|
||||
|
||||
Supports multiple formats:
|
||||
- Old: config["storage"]["local"]["path"]
|
||||
- Old: config["Local"]["path"]
|
||||
|
||||
Args:
|
||||
config: Configuration dict
|
||||
|
||||
Returns:
|
||||
Path object if found, None otherwise
|
||||
This helper is intentionally narrow: it reports a real local library/export
|
||||
root only when the canonical `plugin.local` config defines one. Callers that
|
||||
want a staging/output directory should use `resolve_output_dir(...)` instead.
|
||||
"""
|
||||
# Fall back to storage.local.path format
|
||||
storage = config.get("storage", {})
|
||||
if isinstance(storage, dict):
|
||||
local_config = storage.get("local", {})
|
||||
if isinstance(local_config, dict):
|
||||
path_str = local_config.get("path")
|
||||
if path_str:
|
||||
return expand_path(path_str)
|
||||
local_block = get_plugin_block(config, "local")
|
||||
if not isinstance(local_block, dict) or not local_block:
|
||||
return None
|
||||
|
||||
# Fall back to old Local format
|
||||
local_config = config.get("Local", {})
|
||||
if isinstance(local_config, dict):
|
||||
path_str = local_config.get("path")
|
||||
if path_str:
|
||||
return expand_path(path_str)
|
||||
if _is_multi_instance_plugin_config(local_block):
|
||||
if "default" in local_block and isinstance(local_block.get("default"), dict):
|
||||
local_config = local_block.get("default")
|
||||
else:
|
||||
local_config = next(
|
||||
(value for value in local_block.values() if isinstance(value, dict)),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
local_config = local_block
|
||||
|
||||
if not isinstance(local_config, dict):
|
||||
return None
|
||||
|
||||
path_str = local_config.get("path") or local_config.get("PATH")
|
||||
if path_str:
|
||||
return expand_path(path_str)
|
||||
|
||||
return None
|
||||
|
||||
@@ -449,7 +446,7 @@ def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
|
||||
def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> Optional[str]:
|
||||
"""Get Debrid API key from config.
|
||||
|
||||
Checks the plugin/provider block first (canonical format).
|
||||
Checks the plugin block first (canonical format).
|
||||
|
||||
Args:
|
||||
config: Configuration dict
|
||||
@@ -458,37 +455,23 @@ def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> O
|
||||
Returns:
|
||||
API key string if found, None otherwise
|
||||
"""
|
||||
# 1) Canonical plugin/provider block: config["plugin"]["alldebrid"]["api_key"]
|
||||
provider_block = config.get("provider") or config.get("plugin")
|
||||
if isinstance(provider_block, dict):
|
||||
alldebrid_entry = provider_block.get("alldebrid")
|
||||
_canonicalize_plugin_config(config)
|
||||
|
||||
# 1) Canonical plugin block: config["plugin"]["alldebrid"]["api_key"]
|
||||
plugin_block = config.get("plugin")
|
||||
if isinstance(plugin_block, dict):
|
||||
alldebrid_entry = plugin_block.get("alldebrid")
|
||||
if isinstance(alldebrid_entry, dict):
|
||||
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
|
||||
val = alldebrid_entry.get(k)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
|
||||
# 2) Migrated legacy debrid plugin entry: config["plugin"]["debrid"]["all-debrid"]["api_key"]
|
||||
if isinstance(provider_block, dict):
|
||||
service_key = str(service).strip().lower()
|
||||
debrid_plugin = provider_block.get("debrid")
|
||||
if isinstance(debrid_plugin, dict):
|
||||
entry = debrid_plugin.get(service_key)
|
||||
if isinstance(entry, dict):
|
||||
api_key = entry.get("api_key")
|
||||
return str(api_key).strip() if api_key else None
|
||||
if isinstance(entry, str):
|
||||
return entry.strip() or None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]:
|
||||
"""Get provider credentials (email/password) from config.
|
||||
|
||||
Supports both formats:
|
||||
- New: config["provider"][provider] = {"email": "...", "password": "..."}
|
||||
- Old: config[provider.capitalize()] = {"email": "...", "password": "..."}
|
||||
def get_plugin_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]:
|
||||
"""Get plugin credentials (email/password) from config.
|
||||
|
||||
Args:
|
||||
config: Configuration dict
|
||||
@@ -497,22 +480,11 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[
|
||||
Returns:
|
||||
Dict with credentials if found, None otherwise
|
||||
"""
|
||||
# Try new format first
|
||||
provider_config = config.get("provider", {})
|
||||
if isinstance(provider_config, dict):
|
||||
creds = provider_config.get(provider.lower(), {})
|
||||
if isinstance(creds, dict) and creds:
|
||||
return creds
|
||||
_canonicalize_plugin_config(config)
|
||||
|
||||
# Fall back to old format (capitalized key)
|
||||
old_key_map = {
|
||||
"openlibrary": "OpenLibrary",
|
||||
"archive": "Archive",
|
||||
"soulseek": "Soulseek",
|
||||
}
|
||||
old_key = old_key_map.get(provider.lower())
|
||||
if old_key:
|
||||
creds = config.get(old_key, {})
|
||||
plugin_config = config.get("plugin", {})
|
||||
if isinstance(plugin_config, dict):
|
||||
creds = plugin_config.get(provider.lower(), {})
|
||||
if isinstance(creds, dict) and creds:
|
||||
return creds
|
||||
|
||||
@@ -522,19 +494,19 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[
|
||||
def resolve_cookies_path(
|
||||
config: Dict[str, Any], script_dir: Optional[Path] = None
|
||||
) -> Optional[Path]:
|
||||
# Only support modular config style:
|
||||
# [tool=ytdlp]
|
||||
# Only support plugin config style:
|
||||
# [plugin=ytdlp]
|
||||
# cookies="C:\\path\\cookies.txt"
|
||||
values: list[Any] = []
|
||||
try:
|
||||
tool = config.get("tool")
|
||||
if isinstance(tool, dict):
|
||||
ytdlp = tool.get("ytdlp")
|
||||
plugin = config.get("plugin")
|
||||
if isinstance(plugin, dict):
|
||||
ytdlp = plugin.get("ytdlp")
|
||||
if isinstance(ytdlp, dict):
|
||||
values.append(ytdlp.get("cookies"))
|
||||
values.append(ytdlp.get("cookiefile"))
|
||||
except Exception as exc:
|
||||
logger.debug("resolve_cookies_path: failed to read tool.ytdlp cookies: %s", exc, exc_info=True)
|
||||
logger.debug("resolve_cookies_path: failed to read plugin.ytdlp cookies: %s", exc, exc_info=True)
|
||||
|
||||
base_dir = _resolve_app_root(script_dir)
|
||||
for value in values:
|
||||
@@ -627,54 +599,26 @@ def resolve_plugin_asset_path(
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_plugin_config_aliases(config: Dict[str, Any]) -> None:
|
||||
def _canonicalize_plugin_config(config: Dict[str, Any]) -> None:
|
||||
if not isinstance(config, dict):
|
||||
return
|
||||
|
||||
config.pop("provider", None)
|
||||
config.pop("store", None)
|
||||
plugin_block = config.get("plugin")
|
||||
provider_block = config.get("provider")
|
||||
|
||||
normalized_provider: Dict[str, Any] = {}
|
||||
|
||||
if isinstance(provider_block, dict):
|
||||
for key, value in provider_block.items():
|
||||
normalized_key = _normalize_provider_name(key)
|
||||
if normalized_key and normalized_key not in normalized_provider:
|
||||
normalized_provider[normalized_key] = value
|
||||
normalized_plugin: Dict[str, Any] = {}
|
||||
|
||||
if isinstance(plugin_block, dict):
|
||||
for key, value in plugin_block.items():
|
||||
normalized_key = _normalize_provider_name(key)
|
||||
if normalized_key and normalized_key not in normalized_provider:
|
||||
normalized_provider[normalized_key] = value
|
||||
if normalized_key:
|
||||
normalized_plugin[normalized_key] = value
|
||||
|
||||
# Fold legacy config["store"] entries into the plugin namespace.
|
||||
# store format: {type: {instance_name: {key: val}}} — multi-instance.
|
||||
# After folding, remove config["store"] so it is no longer consulted.
|
||||
store_block = config.pop("store", None)
|
||||
if isinstance(store_block, dict):
|
||||
for store_type, instances in store_block.items():
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
normalized_key = _normalize_provider_name(store_type)
|
||||
if not normalized_key:
|
||||
continue
|
||||
existing = normalized_provider.get(normalized_key)
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
normalized_provider[normalized_key] = existing
|
||||
for instance_name, settings in instances.items():
|
||||
if isinstance(settings, dict) and instance_name not in existing:
|
||||
existing[instance_name] = dict(settings)
|
||||
|
||||
if normalized_provider:
|
||||
config["provider"] = normalized_provider
|
||||
config["plugin"] = normalized_provider
|
||||
if normalized_plugin or isinstance(plugin_block, dict):
|
||||
config["plugin"] = normalized_plugin
|
||||
else:
|
||||
if isinstance(provider_block, dict):
|
||||
config["plugin"] = provider_block
|
||||
elif isinstance(plugin_block, dict):
|
||||
config["provider"] = plugin_block
|
||||
config.pop("plugin", None)
|
||||
|
||||
def _extract_api_key(value: Any) -> Optional[str]:
|
||||
if isinstance(value, dict):
|
||||
@@ -698,40 +642,24 @@ def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None:
|
||||
if not isinstance(config, dict):
|
||||
return
|
||||
|
||||
_normalize_plugin_config_aliases(config)
|
||||
|
||||
providers = config.get("provider")
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
config["provider"] = providers
|
||||
|
||||
provider_entry = providers.get("alldebrid")
|
||||
provider_section: Dict[str, Any] | None = None
|
||||
provider_key = None
|
||||
if isinstance(provider_entry, dict):
|
||||
provider_section = provider_entry
|
||||
provider_key = _extract_api_key(provider_section)
|
||||
elif isinstance(provider_entry, str):
|
||||
provider_key = provider_entry.strip()
|
||||
if provider_key:
|
||||
provider_section = {"api_key": provider_key}
|
||||
providers["alldebrid"] = provider_section
|
||||
|
||||
# If no key found in provider block, check for a migrated debrid plugin entry.
|
||||
# (rows_to_config migrates store.debrid.all-debrid → plugin.debrid.all-debrid)
|
||||
if not provider_key:
|
||||
plugin_block = config.get("plugin") or providers
|
||||
debrid_plugin = plugin_block.get("debrid") if isinstance(plugin_block, dict) else None
|
||||
if isinstance(debrid_plugin, dict):
|
||||
service_entry = debrid_plugin.get("all-debrid")
|
||||
legacy_key = _extract_api_key(service_entry) if service_entry else None
|
||||
if legacy_key:
|
||||
if provider_section is None:
|
||||
provider_section = {}
|
||||
providers["alldebrid"] = provider_section
|
||||
provider_section.setdefault("api_key", legacy_key)
|
||||
_canonicalize_plugin_config(config)
|
||||
|
||||
plugins = config.get("plugin")
|
||||
if not isinstance(plugins, dict):
|
||||
plugins = {}
|
||||
config["plugin"] = plugins
|
||||
|
||||
plugin_entry = plugins.get("alldebrid")
|
||||
plugin_section: Dict[str, Any] | None = None
|
||||
plugin_key = None
|
||||
if isinstance(plugin_entry, dict):
|
||||
plugin_section = plugin_entry
|
||||
plugin_key = _extract_api_key(plugin_section)
|
||||
elif isinstance(plugin_entry, str):
|
||||
plugin_key = plugin_entry.strip()
|
||||
if plugin_key:
|
||||
plugin_section = {"api_key": plugin_key}
|
||||
plugins["alldebrid"] = plugin_section
|
||||
|
||||
def _is_multi_instance_plugin_config(value: Any) -> bool:
|
||||
"""Return True if `value` looks like a multi-instance plugin config (dict-of-dicts).
|
||||
@@ -754,12 +682,9 @@ def _is_multi_instance_plugin_config(value: Any) -> bool:
|
||||
|
||||
def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]:
|
||||
entries: Dict[Tuple[str, str, str, str], Any] = {}
|
||||
_normalize_plugin_config_aliases(config)
|
||||
_canonicalize_plugin_config(config)
|
||||
for key, value in config.items():
|
||||
if key == 'plugin':
|
||||
# plugin == provider after normalization; skip duplicate
|
||||
continue
|
||||
if key == 'provider' and isinstance(value, dict):
|
||||
if key == 'plugin' and isinstance(value, dict):
|
||||
for subtype, plugin_cfg in value.items():
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
continue
|
||||
@@ -773,21 +698,13 @@ def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str,
|
||||
else:
|
||||
# Single-instance: {key: val}
|
||||
for k, v in plugin_cfg.items():
|
||||
entries[('provider', subtype, 'default', k)] = v
|
||||
elif key in ('store', 'tool') and isinstance(value, dict):
|
||||
entries[('plugin', subtype, 'default', k)] = v
|
||||
elif key == 'tool' and isinstance(value, dict):
|
||||
for subtype, instances in value.items():
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
if key == 'store':
|
||||
# Legacy store: migrate to plugin category
|
||||
for name, settings in instances.items():
|
||||
if not isinstance(settings, dict):
|
||||
continue
|
||||
for k, v in settings.items():
|
||||
entries[('plugin', subtype, name, k)] = v
|
||||
else: # tool
|
||||
for k, v in instances.items():
|
||||
entries[(key, subtype, 'default', k)] = v
|
||||
for k, v in instances.items():
|
||||
entries[('tool', subtype, 'default', k)] = v
|
||||
elif not key.startswith('_') and value is not None:
|
||||
entries[('global', 'none', 'none', key)] = value
|
||||
return entries
|
||||
@@ -817,14 +734,6 @@ def _config_from_flattened_entries(
|
||||
config[key] = value
|
||||
continue
|
||||
|
||||
if category == "store":
|
||||
# Legacy: migrate to plugin namespace at reconstitution time
|
||||
plugin_block = config.setdefault("plugin", {})
|
||||
subtype_block = plugin_block.setdefault(subtype, {})
|
||||
item_block = subtype_block.setdefault(item_name, {})
|
||||
item_block[key] = value
|
||||
continue
|
||||
|
||||
if category == "plugin":
|
||||
plugin_block = config.setdefault("plugin", {})
|
||||
subtype_block = plugin_block.setdefault(subtype, {})
|
||||
@@ -835,7 +744,7 @@ def _config_from_flattened_entries(
|
||||
item_block[key] = value
|
||||
continue
|
||||
|
||||
if category in {"provider", "tool"}:
|
||||
if category == "tool":
|
||||
category_block = config.setdefault(category, {})
|
||||
subtype_block = category_block.setdefault(subtype, {})
|
||||
subtype_block[key] = value
|
||||
@@ -849,7 +758,7 @@ def _config_from_flattened_entries(
|
||||
if isinstance(item_block, dict):
|
||||
item_block[key] = value
|
||||
|
||||
_normalize_plugin_config_aliases(config)
|
||||
_canonicalize_plugin_config(config)
|
||||
_sync_alldebrid_api_key(config)
|
||||
return config
|
||||
|
||||
@@ -880,9 +789,9 @@ def _merge_non_conflicting_config_changes(
|
||||
def _extract_expected_alldebrid_key(config: Dict[str, Any]) -> Optional[str]:
|
||||
expected_key = None
|
||||
try:
|
||||
providers = config.get("provider", {}) if isinstance(config, dict) else {}
|
||||
if isinstance(providers, dict):
|
||||
entry = providers.get("alldebrid")
|
||||
plugins = config.get("plugin", {}) if isinstance(config, dict) else {}
|
||||
if isinstance(plugins, dict):
|
||||
entry = plugins.get("alldebrid")
|
||||
if entry is not None:
|
||||
if isinstance(entry, dict):
|
||||
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
|
||||
@@ -908,18 +817,10 @@ def load_config(*, emit_summary: bool = False) -> Dict[str, Any]:
|
||||
_CONFIG_SUMMARY_PENDING = False
|
||||
return _CONFIG_CACHE
|
||||
|
||||
# One-time DB migration: move category='store' rows to category='plugin'.
|
||||
# This is idempotent — a no-op if no store rows exist.
|
||||
try:
|
||||
from SYS.database import migrate_store_category_to_plugin
|
||||
migrate_store_category_to_plugin()
|
||||
except Exception:
|
||||
logger.debug("Store→plugin DB migration skipped or failed", exc_info=True)
|
||||
|
||||
# Load strictly from database
|
||||
db_config = get_config_all()
|
||||
if db_config:
|
||||
_normalize_plugin_config_aliases(db_config)
|
||||
_canonicalize_plugin_config(db_config)
|
||||
_sync_alldebrid_api_key(db_config)
|
||||
_CONFIG_CACHE = db_config
|
||||
_LAST_SAVED_CONFIG = deepcopy(db_config)
|
||||
@@ -1007,7 +908,7 @@ def _release_save_lock(lock_dir: Path) -> None:
|
||||
|
||||
def save_config(config: Dict[str, Any]) -> int:
|
||||
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
|
||||
_normalize_plugin_config_aliases(config)
|
||||
_canonicalize_plugin_config(config)
|
||||
_sync_alldebrid_api_key(config)
|
||||
|
||||
# Acquire cross-process save lock to avoid concurrent saves from different
|
||||
@@ -1065,31 +966,39 @@ def save_config(config: Dict[str, Any]) -> int:
|
||||
# Proceed with writing when no conflicting external changes detected
|
||||
conn.execute("DELETE FROM config")
|
||||
for key, value in config_to_write.items():
|
||||
if key in ('store', 'provider', 'tool') and isinstance(value, dict):
|
||||
if key in ('plugin', 'tool') and isinstance(value, dict):
|
||||
for subtype, instances in value.items():
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
if key == 'store':
|
||||
for name, settings in instances.items():
|
||||
if isinstance(settings, dict):
|
||||
if key == 'plugin':
|
||||
normalized_subtype = _normalize_provider_name(subtype)
|
||||
if not normalized_subtype:
|
||||
continue
|
||||
if _is_multi_instance_plugin_config(instances):
|
||||
for name, settings in instances.items():
|
||||
if not isinstance(settings, dict):
|
||||
continue
|
||||
for k, v in settings.items():
|
||||
val_str = json.dumps(v) if not isinstance(v, str) else v
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
|
||||
(key, subtype, name, k, val_str),
|
||||
("plugin", normalized_subtype, name, k, val_str),
|
||||
)
|
||||
count += 1
|
||||
else:
|
||||
for k, v in instances.items():
|
||||
val_str = json.dumps(v) if not isinstance(v, str) else v
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
|
||||
("plugin", normalized_subtype, "default", k, val_str),
|
||||
)
|
||||
count += 1
|
||||
else:
|
||||
normalized_subtype = subtype
|
||||
if key == 'provider':
|
||||
normalized_subtype = _normalize_provider_name(subtype)
|
||||
if not normalized_subtype:
|
||||
continue
|
||||
for k, v in instances.items():
|
||||
val_str = json.dumps(v) if not isinstance(v, str) else v
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
|
||||
(key, normalized_subtype, "default", k, val_str),
|
||||
("tool", subtype, "default", k, val_str),
|
||||
)
|
||||
count += 1
|
||||
else:
|
||||
@@ -1197,30 +1106,15 @@ def save_config_and_verify(config: Dict[str, Any], retries: int = 3, delay: floa
|
||||
# Nothing special to verify; return success.
|
||||
return saved
|
||||
|
||||
# Reload directly from disk and compare the canonical debrid/provider keys
|
||||
# Reload directly from disk and compare the canonical plugin key.
|
||||
clear_config_cache()
|
||||
reloaded = load_config()
|
||||
# Provider-level key
|
||||
prov_block = reloaded.get("provider", {}) if isinstance(reloaded, dict) else {}
|
||||
prov_key = None
|
||||
if isinstance(prov_block, dict):
|
||||
aentry = prov_block.get("alldebrid")
|
||||
if isinstance(aentry, dict):
|
||||
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
|
||||
v = aentry.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
prov_key = v.strip()
|
||||
break
|
||||
elif isinstance(aentry, str) and aentry.strip():
|
||||
prov_key = aentry.strip()
|
||||
|
||||
# Store-level key
|
||||
try:
|
||||
store_key = get_debrid_api_key(reloaded, service="All-debrid")
|
||||
reloaded_key = _extract_expected_alldebrid_key(reloaded)
|
||||
except Exception:
|
||||
store_key = None
|
||||
reloaded_key = None
|
||||
|
||||
if prov_key == expected_key or store_key == expected_key:
|
||||
if reloaded_key == expected_key:
|
||||
try:
|
||||
# Log a short, masked fingerprint to aid debugging without exposing the key itself
|
||||
import hashlib
|
||||
|
||||
+13
-51
@@ -504,67 +504,29 @@ def rows_to_config(rows) -> Dict[str, Any]:
|
||||
if cat == 'global':
|
||||
config[key] = parsed_val
|
||||
else:
|
||||
# Modular structure: config[cat][sub][name][key]
|
||||
if cat in ('provider', 'tool'):
|
||||
# Modular structure: config[category][subtype][item_name?][key]
|
||||
if cat == 'plugin':
|
||||
cat_dict = config.setdefault('plugin', {})
|
||||
sub_dict = cat_dict.setdefault(sub, {})
|
||||
if str(name or '').strip().lower() == 'default':
|
||||
sub_dict[key] = parsed_val
|
||||
else:
|
||||
name_dict = sub_dict.setdefault(name, {})
|
||||
name_dict[key] = parsed_val
|
||||
elif cat in ('provider', 'store'):
|
||||
continue
|
||||
elif cat == 'tool':
|
||||
cat_dict = config.setdefault(cat, {})
|
||||
sub_dict = cat_dict.setdefault(sub, {})
|
||||
sub_dict[key] = parsed_val
|
||||
elif cat == 'store':
|
||||
# Migrate legacy store rows into the unified plugin namespace.
|
||||
# store config used a 4-level path: (store, type, instance_name, key).
|
||||
# Plugin config uses: config["plugin"][type][instance_name][key].
|
||||
cat_dict = config.setdefault('plugin', {})
|
||||
sub_dict = cat_dict.setdefault(sub, {})
|
||||
name_dict = sub_dict.setdefault(name, {})
|
||||
name_dict[key] = parsed_val
|
||||
else:
|
||||
config.setdefault(cat, {})[key] = parsed_val
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def migrate_store_category_to_plugin() -> int:
|
||||
"""One-time migration: re-key category='store' DB rows to category='plugin'.
|
||||
|
||||
The 'store' category used ``(store, type, instance_name, key)`` tuples;
|
||||
the unified plugin system uses the same 4-level path under category='plugin'.
|
||||
Existing 'plugin' rows for the same (subtype, item_name, key) are overwritten.
|
||||
|
||||
Returns the number of rows that were migrated (0 if already migrated).
|
||||
"""
|
||||
try:
|
||||
count_row = db.fetchone(
|
||||
"SELECT COUNT(*) AS n FROM config WHERE category='store' AND subtype != 'folder'"
|
||||
)
|
||||
count = int(count_row['n']) if count_row else 0
|
||||
if count == 0:
|
||||
# Also clean up any lingering folder-store rows
|
||||
db.execute("DELETE FROM config WHERE category='store'")
|
||||
with db._conn_lock:
|
||||
db.conn.commit()
|
||||
return 0
|
||||
# Copy store rows to plugin, replacing any pre-existing plugin rows for
|
||||
# the same (subtype, item_name, key), then delete the old store rows.
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO config (category, subtype, item_name, key, value)
|
||||
SELECT 'plugin', subtype, item_name, key, value
|
||||
FROM config
|
||||
WHERE category = 'store' AND subtype != 'folder'
|
||||
"""
|
||||
)
|
||||
db.execute("DELETE FROM config WHERE category = 'store'")
|
||||
with db._conn_lock:
|
||||
db.conn.commit()
|
||||
logger.info("Migrated %d config rows from category='store' to category='plugin'", count)
|
||||
return count
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate store config rows to plugin category")
|
||||
return 0
|
||||
|
||||
|
||||
def get_config_all() -> Dict[str, Any]:
|
||||
"""Retrieve all configuration from the database in the legacy dict format."""
|
||||
"""Retrieve all configuration from the database in the canonical plugin-centric dict format."""
|
||||
rows = db.fetchall("SELECT category, subtype, item_name, key, value FROM config")
|
||||
return rows_to_config(rows)
|
||||
|
||||
|
||||
+2
-2
@@ -277,12 +277,12 @@ def extract_records(doc_or_html: Any, base_url: Optional[str] = None, xpaths: Op
|
||||
return normed, chosen
|
||||
|
||||
|
||||
# Small convenience: convert records to SearchResult. Providers can call this or
|
||||
# Small convenience: convert records to SearchResult. Plugins can call this or
|
||||
# use their own mapping when they need full SearchResult objects.
|
||||
from PluginCore.base import SearchResult # local import to avoid circular issues
|
||||
|
||||
|
||||
def records_to_search_results(records: List[Dict[str, str]], table: str = "provider") -> List[SearchResult]:
|
||||
def records_to_search_results(records: List[Dict[str, str]], table: str = "plugin") -> List[SearchResult]:
|
||||
out: List[SearchResult] = []
|
||||
for rec in records:
|
||||
title = rec.get("title") or rec.get("name") or ""
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ class PipeObject:
|
||||
|
||||
hash: str
|
||||
store: str
|
||||
provider: Optional[str] = None
|
||||
plugin: Optional[str] = None
|
||||
tag: List[str] = field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
@@ -144,8 +144,8 @@ class PipeObject:
|
||||
"store": self.store,
|
||||
}
|
||||
|
||||
if self.provider:
|
||||
data["provider"] = self.provider
|
||||
if self.plugin:
|
||||
data["plugin"] = self.plugin
|
||||
|
||||
if self.tag:
|
||||
data["tag"] = self.tag
|
||||
|
||||
@@ -61,12 +61,12 @@ def florencevision_missing_modules() -> List[str]:
|
||||
|
||||
def _provider_missing_modules(config: Dict[str, Any]) -> Dict[str, List[str]]:
|
||||
missing: Dict[str, List[str]] = {}
|
||||
provider_cfg = (config or {}).get("provider")
|
||||
if not isinstance(provider_cfg, dict):
|
||||
plugin_cfg = (config or {}).get("plugin")
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
return missing
|
||||
|
||||
for provider_name, requirements in _PROVIDER_DEPENDENCIES.items():
|
||||
block = provider_cfg.get(provider_name)
|
||||
block = plugin_cfg.get(provider_name)
|
||||
if not isinstance(block, dict) or not block:
|
||||
continue
|
||||
missing_for_provider = [
|
||||
|
||||
+4
-4
@@ -191,11 +191,11 @@ def coerce_to_pipe_object(
|
||||
pipe_obj = models.PipeObject(
|
||||
hash=hash_val,
|
||||
store=store_val,
|
||||
provider=str(
|
||||
value.get("provider")
|
||||
plugin=str(
|
||||
value.get("plugin")
|
||||
or value.get("prov")
|
||||
or value.get("source")
|
||||
or extra.get("provider")
|
||||
or extra.get("plugin")
|
||||
or extra.get("source")
|
||||
or ""
|
||||
).strip()
|
||||
@@ -253,7 +253,7 @@ def coerce_to_pipe_object(
|
||||
pipe_obj = models.PipeObject(
|
||||
hash=hash_val,
|
||||
store=store_val,
|
||||
provider=None,
|
||||
plugin=None,
|
||||
path=str(path_val) if path_val and path_val != "unknown" else None,
|
||||
title=title_val,
|
||||
url=url_val,
|
||||
|
||||
+3
-19
@@ -1545,19 +1545,16 @@ class PipelineExecutor:
|
||||
table_meta = meta if isinstance(meta, dict) else None
|
||||
if isinstance(meta, dict):
|
||||
_add(meta.get("plugin"))
|
||||
_add(meta.get("provider"))
|
||||
except Exception:
|
||||
logger.exception("Failed to inspect current_table/table metadata in _maybe_run_class_selector")
|
||||
|
||||
for item in selected_items or []:
|
||||
if isinstance(item, dict):
|
||||
_add(item.get("plugin"))
|
||||
_add(item.get("provider"))
|
||||
_add(item.get("store"))
|
||||
_add(item.get("table"))
|
||||
else:
|
||||
_add(getattr(item, "plugin", None))
|
||||
_add(getattr(item, "provider", None))
|
||||
_add(getattr(item, "store", None))
|
||||
_add(getattr(item, "table", None))
|
||||
|
||||
@@ -1664,17 +1661,14 @@ class PipelineExecutor:
|
||||
meta = None
|
||||
if isinstance(meta, dict):
|
||||
_add(meta.get("plugin"))
|
||||
_add(meta.get("provider"))
|
||||
|
||||
for item in selected_items or []:
|
||||
if isinstance(item, dict):
|
||||
_add(item.get("plugin"))
|
||||
_add(item.get("provider"))
|
||||
_add(item.get("table"))
|
||||
_add(item.get("source"))
|
||||
else:
|
||||
_add(getattr(item, "plugin", None))
|
||||
_add(getattr(item, "provider", None))
|
||||
_add(getattr(item, "table", None))
|
||||
_add(getattr(item, "source", None))
|
||||
|
||||
@@ -3129,20 +3123,10 @@ class PipelineExecutor:
|
||||
mod = import_cmd_module(cmd_name, reload_loaded=True)
|
||||
data = getattr(mod, "CMDLET", None) if mod else None
|
||||
if data and hasattr(data, "exec") and callable(getattr(data, "exec")):
|
||||
from SYS.cmdlet_spec import collect_registered_cmdlet_names
|
||||
|
||||
run_fn = getattr(data, "exec")
|
||||
registered_names = set()
|
||||
raw_name = getattr(data, "name", None)
|
||||
if raw_name:
|
||||
registered_names.add(str(raw_name).replace("_", "-").lower())
|
||||
registered_names.add(str(cmd_name).replace("_", "-").lower())
|
||||
for alias_attr in ("alias", "aliases"):
|
||||
alias_values = getattr(data, alias_attr, None)
|
||||
if alias_values:
|
||||
for alias in alias_values:
|
||||
alias_text = str(alias or "").replace("_", "-").lower().strip()
|
||||
if alias_text:
|
||||
registered_names.add(alias_text)
|
||||
for registered_name in registered_names:
|
||||
for registered_name in collect_registered_cmdlet_names(data, fallback_name=cmd_name):
|
||||
REGISTRY[registered_name] = run_fn
|
||||
cmd_fn = run_fn
|
||||
except Exception:
|
||||
|
||||
+51
-51
@@ -14,6 +14,36 @@ logger = logging.getLogger(__name__)
|
||||
ConfigField = Dict[str, Any]
|
||||
|
||||
|
||||
def _import_plugin_support_module(plugin_name: str) -> Optional[Any]:
|
||||
normalized = str(plugin_name or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
try:
|
||||
return importlib.import_module(f"plugins.{normalized}")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _iter_plugin_module_names() -> List[str]:
|
||||
names: List[str] = []
|
||||
try:
|
||||
import plugins as plugin_package
|
||||
except Exception:
|
||||
logger.exception("Failed to import plugins package for config discovery")
|
||||
return names
|
||||
|
||||
package_path = getattr(plugin_package, "__path__", None)
|
||||
if not package_path:
|
||||
return names
|
||||
|
||||
for module_info in pkgutil.iter_modules(package_path):
|
||||
name = str(module_info.name or "").strip()
|
||||
if not name or name.startswith("_"):
|
||||
continue
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _normalize_schema(fields: Optional[Iterable[Any]]) -> List[ConfigField]:
|
||||
normalized: List[ConfigField] = []
|
||||
seen: set[str] = set()
|
||||
@@ -55,48 +85,38 @@ def _call_schema(owner: Any, label: str) -> List[ConfigField]:
|
||||
def get_store_schema(store_type: str) -> List[ConfigField]:
|
||||
"""Return config schema for a store type.
|
||||
|
||||
After the store→plugin migration, store types are plugins. We look up the
|
||||
plugin schema by name; if not found we return an empty list.
|
||||
Store types are now plugins. We look up the plugin schema by name; if not
|
||||
found we return an empty list.
|
||||
"""
|
||||
normalized = str(store_type or "").strip()
|
||||
# Strip a legacy "store-" prefix so callers using the old type name still work
|
||||
if normalized.startswith("store-"):
|
||||
normalized = normalized[len("store-"):]
|
||||
return get_plugin_schema(normalized)
|
||||
return get_plugin_schema(str(store_type or "").strip())
|
||||
|
||||
|
||||
def get_plugin_schema(plugin_name: str) -> List[ConfigField]:
|
||||
plugin_class = get_plugin_class(str(plugin_name or "").strip())
|
||||
if plugin_class is None:
|
||||
normalized_name = str(plugin_name or "").strip()
|
||||
if not normalized_name:
|
||||
return []
|
||||
return _call_schema(plugin_class, f"plugin '{plugin_name}'")
|
||||
|
||||
plugin_class = get_plugin_class(normalized_name)
|
||||
if plugin_class is not None:
|
||||
schema = _call_schema(plugin_class, f"plugin '{normalized_name}'")
|
||||
if schema:
|
||||
return schema
|
||||
|
||||
def get_tool_schema(tool_name: str) -> List[ConfigField]:
|
||||
tool_name = str(tool_name or "").strip()
|
||||
if not tool_name:
|
||||
module = _import_plugin_support_module(normalized_name)
|
||||
if module is None:
|
||||
return []
|
||||
try:
|
||||
module = importlib.import_module(f"tool.{tool_name}")
|
||||
except Exception:
|
||||
logger.exception("Failed to import tool module 'tool.%s'", tool_name)
|
||||
return []
|
||||
return _call_schema(module, f"tool '{tool_name}'")
|
||||
return _call_schema(module, f"plugin support '{normalized_name}'")
|
||||
|
||||
|
||||
def get_item_schema(item_type: str, item_name: str) -> List[ConfigField]:
|
||||
normalized_type = str(item_type or "").strip()
|
||||
normalized_name = str(item_name or "").strip()
|
||||
if normalized_type.startswith("store-"):
|
||||
return get_store_schema(normalized_type.replace("store-", "", 1))
|
||||
if normalized_type.startswith("plugin-"):
|
||||
# Multi-instance plugin: plugin-{ptype}; item_name is the instance name
|
||||
ptype = normalized_type[len("plugin-"):]
|
||||
return get_plugin_schema(ptype)
|
||||
if normalized_type in {"provider", "plugin"}:
|
||||
if normalized_type == "plugin":
|
||||
return get_plugin_schema(normalized_name)
|
||||
if normalized_type == "tool":
|
||||
return get_tool_schema(normalized_name)
|
||||
return []
|
||||
|
||||
|
||||
@@ -143,13 +163,6 @@ def build_default_plugin_config(plugin_name: str) -> Dict[str, Any]:
|
||||
return config
|
||||
|
||||
|
||||
def build_default_tool_config(tool_name: str) -> Dict[str, Any]:
|
||||
config: Dict[str, Any] = {}
|
||||
for field in get_tool_schema(tool_name):
|
||||
config[field["key"]] = field.get("default", "")
|
||||
return config
|
||||
|
||||
|
||||
def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
|
||||
normalized_type = str(item_type or "").strip()
|
||||
normalized_name = str(item_name or "").strip()
|
||||
@@ -170,9 +183,9 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
|
||||
if field.get("required"):
|
||||
_add_key(field.get("key"))
|
||||
|
||||
if normalized_type.startswith("plugin-") or normalized_type.startswith("store-"):
|
||||
# Multi-instance plugin (plugin-{ptype}) or legacy store-{type}: look up by plugin name
|
||||
ptype = normalized_type.replace("plugin-", "", 1).replace("store-", "", 1)
|
||||
if normalized_type.startswith("plugin-"):
|
||||
# Multi-instance plugin (plugin-{ptype}): look up by plugin name.
|
||||
ptype = normalized_type.replace("plugin-", "", 1)
|
||||
plugin_class = get_plugin_class(ptype)
|
||||
if plugin_class is not None:
|
||||
try:
|
||||
@@ -180,7 +193,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
|
||||
_add_key(required_key)
|
||||
except Exception:
|
||||
logger.exception("Failed to load required config keys for plugin '%s'", ptype)
|
||||
elif normalized_type in {"provider", "plugin"}:
|
||||
elif normalized_type == "plugin":
|
||||
plugin_class = get_plugin_class(normalized_name)
|
||||
if plugin_class is not None:
|
||||
try:
|
||||
@@ -211,20 +224,7 @@ def get_configurable_plugin_types() -> List[str]:
|
||||
plugin_cls = info.plugin_class
|
||||
if get_plugin_schema(info.canonical_name) or getattr(plugin_cls, 'MULTI_INSTANCE', False):
|
||||
options.append(info.canonical_name)
|
||||
return sorted(set(options))
|
||||
|
||||
|
||||
def get_configurable_tool_types() -> List[str]:
|
||||
options: List[str] = []
|
||||
try:
|
||||
import tool as tool_package
|
||||
|
||||
for module_info in pkgutil.iter_modules(tool_package.__path__):
|
||||
tool_name = str(module_info.name or "").strip()
|
||||
if not tool_name:
|
||||
continue
|
||||
if get_tool_schema(tool_name):
|
||||
options.append(tool_name)
|
||||
except Exception:
|
||||
logger.exception("Failed to discover configurable tool modules")
|
||||
for module_name in _iter_plugin_module_names():
|
||||
if get_plugin_schema(module_name):
|
||||
options.append(module_name)
|
||||
return sorted(set(options))
|
||||
@@ -51,7 +51,7 @@ class Plugin:
|
||||
raise RuntimeError(f"plugin '{self.name}' adapter failed") from exc
|
||||
|
||||
cols = self.get_columns(rows)
|
||||
return ResultTable(provider=self.name, rows=rows, columns=cols, meta=self.metadata or {})
|
||||
return ResultTable(plugin=self.name, rows=rows, columns=cols, meta=self.metadata or {})
|
||||
|
||||
def serialize_row(self, row: ResultModel) -> Dict[str, Any]:
|
||||
r = ensure_result_model(row)
|
||||
|
||||
@@ -35,21 +35,21 @@ class ResultModel:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultTable:
|
||||
"""Concrete, provider-owned table of rows/columns.
|
||||
"""Concrete, plugin-owned table of rows/columns.
|
||||
|
||||
This is intentionally minimal: it only stores rows, column specs, and
|
||||
optional metadata used by renderers. It does not auto-normalize legacy
|
||||
objects or infer columns.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
plugin: str
|
||||
rows: List[ResultModel]
|
||||
columns: List[ColumnSpec]
|
||||
meta: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not str(self.provider or "").strip():
|
||||
raise ValueError("provider required for ResultTable")
|
||||
if not str(self.plugin or "").strip():
|
||||
raise ValueError("plugin required for ResultTable")
|
||||
object.__setattr__(self, "rows", [ensure_result_model(r) for r in self.rows])
|
||||
if not self.columns:
|
||||
raise ValueError("columns are required for ResultTable")
|
||||
@@ -70,7 +70,7 @@ class ResultTable:
|
||||
"ext": r.ext,
|
||||
"size_bytes": r.size_bytes,
|
||||
"metadata": r.metadata or {},
|
||||
"source": r.source or self.provider,
|
||||
"source": r.source or self.plugin,
|
||||
"_selection_args": list(selection or []),
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -516,12 +516,12 @@ def extract_link(result: Any, args: Iterable[str]) -> Any | None:
|
||||
|
||||
|
||||
def get_api_key(config: dict[str, Any], service: str, key_path: str) -> str | None:
|
||||
"""Get API key from config with fallback support.
|
||||
"""Get API key from a dot-notation config path.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
service: Service name for logging
|
||||
key_path: Dot-notation path to key (e.g., "Debrid.All-debrid")
|
||||
key_path: Dot-notation path to key (e.g., "plugin.alldebrid.api_key")
|
||||
|
||||
Returns:
|
||||
API key if found and not empty, None otherwise
|
||||
|
||||
+2
-11
@@ -81,7 +81,7 @@ class Worker:
|
||||
"""
|
||||
try:
|
||||
if self.manager:
|
||||
self.manager.append_worker_stdout(self.id, text)
|
||||
self.manager.append_stdout(self.id, text)
|
||||
else:
|
||||
self._stdout_buffer.append(text)
|
||||
except Exception as e:
|
||||
@@ -232,7 +232,7 @@ class WorkerLoggingHandler(logging.StreamHandler):
|
||||
log_text = "\n".join(self.buffer)
|
||||
try:
|
||||
if self.manager:
|
||||
self.manager.append_worker_stdout(
|
||||
self.manager.append_stdout(
|
||||
self.worker_id,
|
||||
log_text,
|
||||
channel="log"
|
||||
@@ -872,15 +872,6 @@ class WorkerManager:
|
||||
logger.error(f"[WorkerManager] Error getting stdout: {e}", exc_info=True)
|
||||
return ""
|
||||
|
||||
def append_worker_stdout(
|
||||
self,
|
||||
worker_id: str,
|
||||
text: str,
|
||||
channel: str = "stdout"
|
||||
) -> bool:
|
||||
"""Compatibility wrapper for append_stdout."""
|
||||
return self.append_stdout(worker_id, text, channel=channel)
|
||||
|
||||
def clear_stdout(self, worker_id: str) -> bool:
|
||||
"""Clear stdout logs for a worker.
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ class PipelineHubApp(App):
|
||||
|
||||
# Initialize the store choices cache at startup (filters disabled stores)
|
||||
try:
|
||||
from cmdlet._shared import SharedArgs
|
||||
from SYS.cmdlet_spec import SharedArgs
|
||||
config = load_config()
|
||||
SharedArgs._refresh_store_choices_cache(config)
|
||||
except Exception:
|
||||
@@ -617,13 +617,9 @@ class PipelineHubApp(App):
|
||||
try:
|
||||
cfg = load_config() or {}
|
||||
plugin_block = cfg.get("plugin")
|
||||
if not isinstance(plugin_block, dict):
|
||||
plugin_block = cfg.get("provider")
|
||||
provs = list(plugin_block.keys()) if isinstance(plugin_block, dict) else []
|
||||
stores = list(cfg.get("store", {}).keys()) if isinstance(cfg.get("store"), dict) else []
|
||||
prov_display = ", ".join(provs[:10]) + ("..." if len(provs) > 10 else "")
|
||||
store_display = ", ".join(stores[:10]) + ("..." if len(stores) > 10 else "")
|
||||
self._append_log_line(f"Startup config: plugins={len(provs)} ({prov_display or '(none)'}), stores={len(stores)} ({store_display or '(none)'}), db={db.db_path.name}")
|
||||
self._append_log_line(f"Startup config: plugins={len(provs)} ({prov_display or '(none)'}), db={db.db_path.name}")
|
||||
except Exception:
|
||||
logger.exception("Failed to produce startup config summary")
|
||||
|
||||
@@ -836,7 +832,7 @@ class PipelineHubApp(App):
|
||||
"""Call when the config modal is dismissed to reload session data."""
|
||||
try:
|
||||
from SYS.config import load_config, clear_config_cache
|
||||
from cmdlet._shared import SharedArgs
|
||||
from SYS.cmdlet_spec import SharedArgs
|
||||
# Force a fresh load from disk
|
||||
clear_config_cache()
|
||||
cfg = load_config()
|
||||
|
||||
+5
-776
@@ -17,7 +17,6 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
from SYS.logger import log, debug, debug_panel
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from SYS import models
|
||||
from SYS import pipeline as pipeline_context
|
||||
from SYS.item_accessors import get_field as _item_accessor_get_field
|
||||
@@ -25,533 +24,11 @@ from SYS.payload_builders import build_file_result_payload, build_table_result_p
|
||||
from SYS.result_publication import publish_result_table
|
||||
from SYS.result_table import Table
|
||||
from SYS.rich_display import stderr_console as get_stderr_console
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg, QueryArg, SharedArgs, parse_cmdlet_args
|
||||
from rich.prompt import Confirm
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
|
||||
|
||||
@dataclass
|
||||
class CmdletArg:
|
||||
"""Represents a single cmdlet argument with optional enum choices."""
|
||||
|
||||
name: str
|
||||
"""Argument name, e.g., '-path' or 'location'"""
|
||||
type: str = "string"
|
||||
"""Argument type: 'string', 'int', 'flag', 'enum', etc."""
|
||||
required: bool = False
|
||||
"""Whether this argument is required"""
|
||||
|
||||
description: str = ""
|
||||
"""Human-readable description of the argument"""
|
||||
choices: List[str] = field(default_factory=list)
|
||||
"""Optional list of valid choices for enum/autocomplete, e.g., ['hydrus', 'local', '0x0.st']"""
|
||||
alias: str = ""
|
||||
"""Optional alias for the argument name, e.g., 'loc' for 'location'"""
|
||||
handler: Optional[Any] = None
|
||||
"""Optional handler function/callable for processing this argument's value"""
|
||||
variadic: bool = False
|
||||
"""Whether this argument accepts multiple values (consumes remaining positional args)"""
|
||||
usage: str = ""
|
||||
"""dsf"""
|
||||
requires_db: bool = False
|
||||
"""Whether this argument requires the local DB/library root to be configured."""
|
||||
|
||||
# Query-mapping support:
|
||||
# Some cmdlets use a unified `-query` string. When configured, individual args
|
||||
# can be populated from fields inside `-query` (e.g., -query "hash:<sha256>").
|
||||
query_key: Optional[str] = None
|
||||
"""Field name inside -query that maps to this argument (e.g., 'hash')."""
|
||||
query_aliases: List[str] = field(default_factory=list)
|
||||
"""Additional field names inside -query that map to this argument."""
|
||||
query_only: bool = False
|
||||
"""When True, do not accept a dedicated CLI flag for this arg; only map from -query."""
|
||||
|
||||
def resolve(self, value: Any) -> Any:
|
||||
"""Resolve/process the argument value using the handler if available.
|
||||
|
||||
Args:
|
||||
value: The raw argument value to process
|
||||
|
||||
Returns:
|
||||
Processed value from handler, or original value if no handler
|
||||
|
||||
Example:
|
||||
# For STORAGE arg with a handler
|
||||
storage_path = SharedArgs.STORAGE.resolve('local') # Returns Path(tempfile.gettempdir())
|
||||
"""
|
||||
if self.handler is not None and callable(self.handler):
|
||||
return self.handler(value)
|
||||
return value
|
||||
|
||||
def to_flags(self) -> tuple[str, ...]:
|
||||
"""Generate all flag variants (short and long form) for this argument.
|
||||
|
||||
Returns a tuple of all valid flag forms for this argument, including:
|
||||
- Long form with double dash: --name
|
||||
- Single dash multi-char form: -name (for convenience)
|
||||
- Short form with single dash: -alias (if alias exists)
|
||||
|
||||
For flags, also generates negation forms:
|
||||
- --no-name, -name (negation of multi-char form)
|
||||
- --no-name, -nalias (negation with alias)
|
||||
|
||||
Returns:
|
||||
Tuple of flag strings, e.g., ('--archive', '-archive', '-arch')
|
||||
or for flags: ('--archive', '-archive', '-arch', '--no-archive', '-narch')
|
||||
|
||||
Example:
|
||||
archive_flags = SharedArgs.ARCHIVE.to_flags()
|
||||
# Returns: ('--archive', '-archive', '-arch', '--no-archive', '-narch')
|
||||
|
||||
storage_flags = SharedArgs.STORAGE.to_flags()
|
||||
# Returns: ('--storage', '-storage', '-s')
|
||||
"""
|
||||
normalized_name = str(self.name or "").lstrip("-")
|
||||
if not normalized_name:
|
||||
return tuple()
|
||||
|
||||
flags = [
|
||||
f"--{normalized_name}",
|
||||
f"-{normalized_name}"
|
||||
] # Both double-dash and single-dash variants
|
||||
|
||||
# Add short form if alias exists
|
||||
if self.alias:
|
||||
flags.append(f"-{self.alias}")
|
||||
|
||||
# Add negation forms for flag type
|
||||
if self.type == "flag":
|
||||
flags.append(f"--no-{normalized_name}")
|
||||
flags.append(f"-no{normalized_name}") # Single-dash negation variant
|
||||
if self.alias:
|
||||
flags.append(f"-n{self.alias}")
|
||||
|
||||
return tuple(flags)
|
||||
|
||||
|
||||
def QueryArg(
|
||||
name: str,
|
||||
*,
|
||||
key: Optional[str] = None,
|
||||
aliases: Optional[Sequence[str]] = None,
|
||||
type: str = "string",
|
||||
required: bool = False,
|
||||
description: str = "",
|
||||
choices: Optional[Sequence[str]] = None,
|
||||
handler: Optional[Any] = None,
|
||||
query_only: bool = True,
|
||||
) -> CmdletArg:
|
||||
"""Create an argument that can be populated from `-query` fields.
|
||||
|
||||
By default, this does NOT create a dedicated flag (query_only=True). This is
|
||||
useful for deprecating bloat flags like `-hash` while still making `hash:` a
|
||||
first-class, documented, reusable field.
|
||||
"""
|
||||
return CmdletArg(
|
||||
name=str(name),
|
||||
type=str(type or "string"),
|
||||
required=bool(required),
|
||||
description=str(description or ""),
|
||||
choices=list(choices or []),
|
||||
handler=handler,
|
||||
query_key=str(key or name).strip().lower()
|
||||
if str(key or name).strip() else None,
|
||||
query_aliases=[
|
||||
str(a).strip().lower() for a in (aliases or []) if str(a).strip()
|
||||
],
|
||||
query_only=bool(query_only),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SHARED ARGUMENTS - Reusable argument definitions across cmdlet
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SharedArgs:
|
||||
"""Registry of shared CmdletArg definitions used across multiple cmdlet.
|
||||
|
||||
This class provides a centralized location for common arguments so they're
|
||||
defined once and used consistently everywhere. Reduces duplication and ensures
|
||||
all cmdlet handle the same arguments identically.
|
||||
|
||||
Example:
|
||||
CMDLET = Cmdlet(
|
||||
name="my-cmdlet",
|
||||
summary="Does something",
|
||||
usage="my-cmdlet",
|
||||
args=[
|
||||
SharedArgs.QUERY, # Use predefined shared arg (e.g., -query "hash:<sha256>")
|
||||
SharedArgs.LOCATION, # Use another shared arg
|
||||
CmdletArg(...), # Mix with custom args
|
||||
]
|
||||
)
|
||||
"""
|
||||
|
||||
# NOTE: This project no longer exposes a dedicated -hash flag.
|
||||
# Use SharedArgs.QUERY with `hash:` syntax instead (e.g., -query "hash:<sha256>").
|
||||
|
||||
STORE = CmdletArg(
|
||||
name="store",
|
||||
type="enum",
|
||||
choices=[], # Dynamically populated via get_store_choices()
|
||||
description="Selects a storage backend",
|
||||
query_key="store",
|
||||
)
|
||||
|
||||
INSTANCE = CmdletArg(
|
||||
name="instance",
|
||||
type="string",
|
||||
description="Selects a plugin instance",
|
||||
query_key="instance",
|
||||
)
|
||||
|
||||
URL = CmdletArg(
|
||||
name="url",
|
||||
type="string",
|
||||
description="http parser",
|
||||
)
|
||||
PLUGIN = CmdletArg(
|
||||
name="plugin",
|
||||
type="string",
|
||||
description="selects plugin",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_store_choices(config: Optional[Dict[str, Any]] = None, force: bool = False) -> List[str]:
|
||||
"""Get list of available store backend names.
|
||||
|
||||
This method returns the cached list of available backends from the most
|
||||
recent startup check. Stores that failed to initialize are filtered out.
|
||||
Users must restart to refresh the list if stores are enabled/disabled.
|
||||
|
||||
Args:
|
||||
config: Optional config dict. Used if force=True or no cache exists.
|
||||
force: If True, force a fresh check of the backends.
|
||||
|
||||
Returns:
|
||||
List of backend names (e.g., ['default', 'test', 'home', 'work'])
|
||||
Only includes backends that successfully initialized at startup.
|
||||
|
||||
Example:
|
||||
SharedArgs.INSTANCE.choices = SharedArgs.get_store_choices(config)
|
||||
"""
|
||||
# Use the cached startup check result if available (unless force=True)
|
||||
if not force and hasattr(SharedArgs, "_cached_available_stores"):
|
||||
return SharedArgs._cached_available_stores or []
|
||||
|
||||
# Autocomplete and shared arg choices must only expose backends that actually
|
||||
# initialized successfully. Do a full refresh when the cache is missing.
|
||||
SharedArgs._refresh_store_choices_cache(config, skip_instantiation=False)
|
||||
return SharedArgs._cached_available_stores or []
|
||||
|
||||
@staticmethod
|
||||
def _refresh_store_choices_cache(config: Optional[Dict[str, Any]] = None, skip_instantiation: bool = False) -> None:
|
||||
"""Refresh the cached store choices list. Should be called once at startup.
|
||||
|
||||
Store choices are user-facing and should only include backends that actually
|
||||
initialized successfully. When `skip_instantiation` is True, this method keeps
|
||||
the cache empty rather than surfacing configured-but-disabled store names.
|
||||
|
||||
Args:
|
||||
config: Config dict. If not provided, will try to load from config module.
|
||||
skip_instantiation: When True, do not instantiate backend classes; use a lightweight list only.
|
||||
"""
|
||||
try:
|
||||
if config is None:
|
||||
try:
|
||||
from SYS.config import load_config
|
||||
config = load_config(emit_summary=False)
|
||||
except Exception:
|
||||
SharedArgs._cached_available_stores = []
|
||||
return
|
||||
|
||||
SharedArgs._cached_available_stores = []
|
||||
|
||||
# If caller requested a lightweight pass, avoid exposing configured names
|
||||
# that may be disabled or unavailable.
|
||||
if skip_instantiation:
|
||||
return
|
||||
|
||||
names: set[str] = set()
|
||||
|
||||
# Plugin-based multi-instance backends (config["plugin"] / config["provider"] sections)
|
||||
try:
|
||||
from PluginCore.registry import REGISTRY
|
||||
plugin_instances = REGISTRY.list_storage_plugin_instances(config)
|
||||
for _plugin_name, instance_names in plugin_instances.items():
|
||||
names.update(instance_names)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if names:
|
||||
SharedArgs._cached_available_stores = sorted(names)
|
||||
except Exception:
|
||||
SharedArgs._cached_available_stores = []
|
||||
|
||||
LOCATION = CmdletArg(
|
||||
"location",
|
||||
type="enum",
|
||||
choices=["hydrus",
|
||||
"0x0"],
|
||||
required=True,
|
||||
description="Destination location",
|
||||
)
|
||||
|
||||
DELETE = CmdletArg(
|
||||
"delete",
|
||||
type="flag",
|
||||
description="Delete the file after successful operation.",
|
||||
)
|
||||
|
||||
# Metadata arguments
|
||||
ARTIST = CmdletArg(
|
||||
"artist",
|
||||
type="string",
|
||||
description="Filter by artist name (case-insensitive, partial match).",
|
||||
)
|
||||
|
||||
ALBUM = CmdletArg(
|
||||
"album",
|
||||
type="string",
|
||||
description="Filter by album name (case-insensitive, partial match).",
|
||||
)
|
||||
|
||||
TRACK = CmdletArg(
|
||||
"track",
|
||||
type="string",
|
||||
description="Filter by track title (case-insensitive, partial match).",
|
||||
)
|
||||
|
||||
# Library/Search arguments
|
||||
LIBRARY = CmdletArg(
|
||||
"library",
|
||||
type="string",
|
||||
choices=["hydrus",
|
||||
"local",
|
||||
"soulseek",
|
||||
"libgen",
|
||||
"ftp"],
|
||||
description="Search library or source location.",
|
||||
)
|
||||
|
||||
TIMEOUT = CmdletArg(
|
||||
"timeout",
|
||||
type="integer",
|
||||
description="Search or operation timeout in seconds."
|
||||
)
|
||||
|
||||
LIMIT = CmdletArg(
|
||||
"limit",
|
||||
type="integer",
|
||||
description="Maximum number of results to return."
|
||||
)
|
||||
|
||||
# Path/File arguments
|
||||
PATH = CmdletArg("path", type="string", description="File or directory path.")
|
||||
|
||||
|
||||
# Generic arguments
|
||||
QUERY = CmdletArg(
|
||||
"query",
|
||||
type="string",
|
||||
description="Unified query string (e.g., hash:<sha256>, hash:{<h1>,<h2>}).",
|
||||
)
|
||||
|
||||
REASON = CmdletArg(
|
||||
"reason",
|
||||
type="string",
|
||||
description="Reason or explanation for the operation."
|
||||
)
|
||||
|
||||
ARCHIVE = CmdletArg(
|
||||
"archive",
|
||||
type="flag",
|
||||
description=
|
||||
"Archive the URL to Wayback Machine, Archive.today, and Archive.ph (requires URL argument in cmdlet).",
|
||||
alias="arch",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_storage(
|
||||
storage_value: Optional[str],
|
||||
default: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Resolve a storage location name to a filesystem Path.
|
||||
|
||||
Maps storage identifiers to their actual filesystem paths.
|
||||
This project has been refactored to use system temporary directories
|
||||
for all staging/downloads by default.
|
||||
|
||||
Args:
|
||||
storage_value: One of 'hydrus', 'local', 'ftp', or None (currently unified to temp)
|
||||
default: Path to return if storage_value is None (defaults to temp directory)
|
||||
|
||||
Returns:
|
||||
Resolved Path object for the storage location (typically system temp)
|
||||
|
||||
Example:
|
||||
# In a cmdlet:
|
||||
storage_path = SharedArgs.resolve_storage(parsed.get('storage'))
|
||||
# Returns Path(tempfile.gettempdir())
|
||||
"""
|
||||
# We no longer maintain a hardcoded map for 'hydrus' (~/.hydrus) or 'local' (~/Videos).
|
||||
# Everything defaults to the system temp directory unless a specific default is provided.
|
||||
# This ensures environment independence.
|
||||
if default is not None:
|
||||
return default
|
||||
|
||||
return Path(tempfile.gettempdir())
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> Optional[CmdletArg]:
|
||||
"""Get a shared argument by name.
|
||||
|
||||
Args:
|
||||
name: Uppercase name like 'HASH', 'LOCATION', etc.
|
||||
|
||||
Returns:
|
||||
CmdletArg if found, None otherwise
|
||||
|
||||
Example:
|
||||
arg = SharedArgs.get('QUERY') # Returns SharedArgs.QUERY
|
||||
"""
|
||||
try:
|
||||
return getattr(cls, name.upper())
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cmdlet:
|
||||
"""Represents a cmdlet with metadata and arguments.
|
||||
|
||||
Example:
|
||||
cmd = Cmdlet(
|
||||
name="add-file",
|
||||
summary="Upload a media file",
|
||||
usage="add-file <location>",
|
||||
aliases=["add-file-alias"],
|
||||
args=[
|
||||
CmdletArg("location", required=True, description="Destination location"),
|
||||
CmdletArg("-delete", type="flag", description="Delete after upload"),
|
||||
],
|
||||
details=[
|
||||
"- This is a detail line",
|
||||
"- Another detail",
|
||||
]
|
||||
)
|
||||
|
||||
# Access properties
|
||||
log(cmd.name) # "add-file"
|
||||
log(cmd.summary) # "Upload a media file"
|
||||
log(cmd.args[0].name) # "location"
|
||||
"""
|
||||
|
||||
name: str
|
||||
""""""
|
||||
summary: str
|
||||
"""One-line summary of the cmdlet"""
|
||||
usage: str
|
||||
"""Usage string, e.g., 'add-file <location> [-delete]'"""
|
||||
alias: List[str] = field(default_factory=list)
|
||||
"""List of aliases for this cmdlet, e.g., ['add', 'add-f']"""
|
||||
arg: List[CmdletArg] = field(default_factory=list)
|
||||
"""List of arguments accepted by this cmdlet"""
|
||||
detail: List[str] = field(default_factory=list)
|
||||
"""Detailed explanation lines (for help text)"""
|
||||
examples: List[str] = field(default_factory=list)
|
||||
"""Example invocations shown in `.help`."""
|
||||
# Execution function: func(result, args, config) -> int
|
||||
exec: Optional[Callable[[Any,
|
||||
Sequence[str],
|
||||
Dict[str,
|
||||
Any]],
|
||||
int]] = field(default=None)
|
||||
|
||||
def _collect_names(self) -> List[str]:
|
||||
"""Collect primary name plus aliases, de-duplicated and normalized."""
|
||||
names: List[str] = []
|
||||
if self.name:
|
||||
names.append(self.name)
|
||||
for alias in self.alias or []:
|
||||
if alias:
|
||||
names.append(alias)
|
||||
for alias in getattr(self, "aliases", None) or []:
|
||||
if alias:
|
||||
names.append(alias)
|
||||
|
||||
seen: Set[str] = set()
|
||||
deduped: List[str] = []
|
||||
for name in names:
|
||||
key = name.replace("_", "-").lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(name)
|
||||
return deduped
|
||||
|
||||
def register(self) -> "Cmdlet":
|
||||
"""Register this cmdlet's exec under its name and aliases."""
|
||||
if not callable(self.exec):
|
||||
return self
|
||||
try:
|
||||
from . import (
|
||||
register_callable as _register_callable,
|
||||
) # Local import to avoid circular import cost
|
||||
except Exception:
|
||||
return self
|
||||
|
||||
names = self._collect_names()
|
||||
if not names:
|
||||
return self
|
||||
|
||||
_register_callable(names, self.exec)
|
||||
return self
|
||||
|
||||
def get_flags(self, arg_name: str) -> set[str]:
|
||||
"""Generate -name and --name flag variants for an argument.
|
||||
|
||||
Args:
|
||||
arg_name: The argument name (e.g., 'library', 'tag', 'size')
|
||||
|
||||
Returns:
|
||||
Set containing both single-dash and double-dash variants
|
||||
(e.g., {'-library', '--library'})
|
||||
|
||||
Example:
|
||||
if low in cmdlet.get_flags('library'):
|
||||
# handle library flag
|
||||
"""
|
||||
return {f"-{arg_name}",
|
||||
f"--{arg_name}"}
|
||||
|
||||
def build_flag_registry(self) -> Dict[str, set[str]]:
|
||||
"""Build a registry of all flag variants for this cmdlet's arguments.
|
||||
|
||||
Automatically generates all -name and --name variants for each argument.
|
||||
Useful for parsing command-line arguments without hardcoding flags.
|
||||
|
||||
Returns:
|
||||
Dict mapping argument names to their flag sets
|
||||
(e.g., {'library': {'-library', '--library'}, 'tag': {'-tag', '--tag'}})
|
||||
|
||||
Example:
|
||||
flags = cmdlet.build_flag_registry()
|
||||
|
||||
if low in flags.get('library', set()):
|
||||
# handle library
|
||||
elif low in flags.get('tag', set()):
|
||||
# handle tag
|
||||
"""
|
||||
registry: Dict[str, set[str]] = {}
|
||||
for arg in self.arg:
|
||||
try:
|
||||
registry[arg.name] = {str(flag).lower() for flag in arg.to_flags()}
|
||||
except Exception:
|
||||
registry[arg.name] = {flag.lower() for flag in self.get_flags(arg.name)}
|
||||
return registry
|
||||
|
||||
|
||||
# Tag groups cache (loaded from JSON config file)
|
||||
_TAG_GROUPS_CACHE: Optional[Dict[str, List[str]]] = None
|
||||
_TAG_GROUPS_MTIME: Optional[float] = None
|
||||
@@ -566,240 +43,6 @@ def set_tag_groups_path(path: Path) -> None:
|
||||
TAG_GROUPS_PATH = path
|
||||
|
||||
|
||||
def parse_cmdlet_args(args: Sequence[str],
|
||||
cmdlet_spec: Dict[str,
|
||||
Any] | Cmdlet) -> Dict[str,
|
||||
Any]:
|
||||
"""Parse command-line arguments based on cmdlet specification.
|
||||
|
||||
Extracts argument values from command-line tokens using the argument names
|
||||
and types defined in the cmdlet metadata. Automatically supports single-dash
|
||||
and double-dash variants of flag names. Arguments without dashes in definition
|
||||
are treated as positional arguments.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments (e.g., ["-path", "/home/file.txt", "-foo", "bar"])
|
||||
cmdlet_spec: Cmdlet metadata dict with "args" key containing list of arg specs,
|
||||
or a Cmdlet object. Each arg spec should have at least "name" key.
|
||||
Argument names can be defined with or without prefixes.
|
||||
|
||||
Returns:
|
||||
Dict mapping canonical arg names to their parsed values. If an arg is not
|
||||
provided, it will not be in the dict. Lookup will normalize prefixes.
|
||||
|
||||
Example:
|
||||
cmdlet = {
|
||||
"args": [
|
||||
{"name": "path", "type": "string"}, # Positional - matches bare value or -path/--path
|
||||
{"name": "count", "type": "int"} # Positional - matches bare value or -count/--count
|
||||
]
|
||||
}
|
||||
result = parse_cmdlet_args(["value1", "-count", "5"], cmdlet)
|
||||
# result = {"path": "value1", "count": "5"}
|
||||
"""
|
||||
try:
|
||||
from SYS.cmdlet_spec import parse_cmdlet_args as _parse_cmdlet_args_fast
|
||||
|
||||
return _parse_cmdlet_args_fast(args, cmdlet_spec)
|
||||
except Exception:
|
||||
# Fall back to local implementation below to preserve behavior if the
|
||||
# lightweight parser is unavailable.
|
||||
pass
|
||||
|
||||
result: Dict[str,
|
||||
Any] = {}
|
||||
|
||||
# Only accept Cmdlet objects
|
||||
if not isinstance(cmdlet_spec, Cmdlet):
|
||||
raise TypeError(f"Expected Cmdlet, got {type(cmdlet_spec).__name__}")
|
||||
|
||||
# Build arg specs from cmdlet
|
||||
arg_specs: List[CmdletArg] = cmdlet_spec.arg
|
||||
positional_args: List[CmdletArg] = [] # args without prefix in definition
|
||||
flagged_args: List[CmdletArg] = [] # args with prefix in definition
|
||||
query_mapped_args: List[CmdletArg] = []
|
||||
|
||||
arg_spec_map: Dict[str,
|
||||
str] = {} # prefix variant -> canonical name (without prefix)
|
||||
|
||||
for spec in arg_specs:
|
||||
name = spec.name
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Track args that can be populated from -query.
|
||||
try:
|
||||
if getattr(spec, "query_key", None):
|
||||
query_mapped_args.append(spec)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
name_str = str(name)
|
||||
canonical_name = name_str.lstrip("-")
|
||||
|
||||
# Query-only args do not register dedicated flags/positionals.
|
||||
try:
|
||||
if bool(getattr(spec, "query_only", False)):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine if this is positional (no dashes in original definition)
|
||||
if "-" not in name_str:
|
||||
positional_args.append(spec)
|
||||
else:
|
||||
flagged_args.append(spec)
|
||||
|
||||
# Register all supported flag variants, including legacy aliases.
|
||||
arg_spec_map[canonical_name.lower()] = canonical_name # bare canonical name
|
||||
try:
|
||||
for flag in spec.to_flags():
|
||||
arg_spec_map[str(flag).lower()] = canonical_name
|
||||
except Exception:
|
||||
arg_spec_map[f"-{canonical_name}".lower()] = canonical_name
|
||||
arg_spec_map[f"--{canonical_name}".lower()] = canonical_name
|
||||
|
||||
# Parse arguments
|
||||
i = 0
|
||||
positional_index = 0 # Track which positional arg we're on
|
||||
|
||||
while i < len(args):
|
||||
token = str(args[i])
|
||||
token_lower = token.lower()
|
||||
|
||||
# Legacy guidance: -hash/--hash was removed in favor of -query "hash:...".
|
||||
# However, some cmdlets may explicitly re-introduce a -hash flag.
|
||||
if token_lower in {"-hash",
|
||||
"--hash"} and token_lower not in arg_spec_map:
|
||||
try:
|
||||
log(
|
||||
'Legacy flag -hash is no longer supported. Use: -query "hash:<sha256>"',
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check if this token is a known flagged argument
|
||||
if token_lower in arg_spec_map:
|
||||
canonical_name = arg_spec_map[token_lower]
|
||||
spec = next(
|
||||
(
|
||||
s for s in arg_specs
|
||||
if str(s.name).lstrip("-").lower() == canonical_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Check if it's a flag type (which doesn't consume next value, just marks presence)
|
||||
is_flag = spec and spec.type == "flag"
|
||||
|
||||
if is_flag:
|
||||
# For flags, just mark presence without consuming next token
|
||||
result[canonical_name] = True
|
||||
i += 1
|
||||
else:
|
||||
# For non-flags, consume next token as the value
|
||||
if i + 1 < len(args) and not str(args[i + 1]).startswith("-"):
|
||||
value = args[i + 1]
|
||||
|
||||
# Check if variadic
|
||||
is_variadic = spec and spec.variadic
|
||||
if is_variadic:
|
||||
if canonical_name not in result:
|
||||
result[canonical_name] = []
|
||||
elif not isinstance(result[canonical_name], list):
|
||||
result[canonical_name] = [result[canonical_name]]
|
||||
result[canonical_name].append(value)
|
||||
else:
|
||||
result[canonical_name] = value
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
# Otherwise treat as positional if we have positional args remaining
|
||||
elif positional_index < len(positional_args):
|
||||
positional_spec = positional_args[positional_index]
|
||||
canonical_name = str(positional_spec.name).lstrip("-")
|
||||
is_variadic = positional_spec.variadic
|
||||
|
||||
if is_variadic:
|
||||
# For variadic args, append to a list
|
||||
if canonical_name not in result:
|
||||
result[canonical_name] = []
|
||||
elif not isinstance(result[canonical_name], list):
|
||||
# Should not happen if logic is correct, but safety check
|
||||
result[canonical_name] = [result[canonical_name]]
|
||||
|
||||
result[canonical_name].append(token)
|
||||
# Do not increment positional_index so subsequent tokens also match this arg
|
||||
# Note: Variadic args should typically be the last positional argument
|
||||
i += 1
|
||||
else:
|
||||
result[canonical_name] = token
|
||||
positional_index += 1
|
||||
i += 1
|
||||
else:
|
||||
# Unknown token, skip it
|
||||
i += 1
|
||||
|
||||
# Populate query-mapped args from the unified -query string.
|
||||
try:
|
||||
raw_query = result.get("query")
|
||||
except Exception:
|
||||
raw_query = None
|
||||
|
||||
if query_mapped_args and raw_query is not None:
|
||||
try:
|
||||
from SYS.cli_syntax import parse_query as _parse_query
|
||||
|
||||
parsed_query = _parse_query(str(raw_query))
|
||||
fields = parsed_query.get("fields",
|
||||
{}) if isinstance(parsed_query,
|
||||
dict) else {}
|
||||
norm_fields = (
|
||||
{
|
||||
str(k).strip().lower(): v
|
||||
for k, v in fields.items()
|
||||
} if isinstance(fields,
|
||||
dict) else {}
|
||||
)
|
||||
except Exception:
|
||||
norm_fields = {}
|
||||
|
||||
for spec in query_mapped_args:
|
||||
canonical_name = str(getattr(spec, "name", "") or "").lstrip("-")
|
||||
if not canonical_name:
|
||||
continue
|
||||
# Do not override explicit flags.
|
||||
if canonical_name in result and result.get(canonical_name) not in (None,
|
||||
""):
|
||||
continue
|
||||
try:
|
||||
key = str(getattr(spec, "query_key", "") or "").strip().lower()
|
||||
aliases = getattr(spec, "query_aliases", None)
|
||||
alias_list = [
|
||||
str(a).strip().lower() for a in (aliases or []) if str(a).strip()
|
||||
]
|
||||
except Exception:
|
||||
key = ""
|
||||
alias_list = []
|
||||
candidates = [k for k in [key, canonical_name] + alias_list if k]
|
||||
val = None
|
||||
for k in candidates:
|
||||
if k in norm_fields:
|
||||
val = norm_fields.get(k)
|
||||
break
|
||||
if val is None:
|
||||
continue
|
||||
try:
|
||||
result[canonical_name] = spec.resolve(val)
|
||||
except Exception:
|
||||
result[canonical_name] = val
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def resolve_target_dir(
|
||||
parsed: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
@@ -3011,20 +2254,6 @@ def collapse_namespace_tags(
|
||||
kept_ns = True
|
||||
result.append(text)
|
||||
return result
|
||||
|
||||
|
||||
def collapse_namespace_tag(
|
||||
tags: Optional[Iterable[Any]],
|
||||
namespace: str,
|
||||
prefer: str = "last"
|
||||
) -> list[str]:
|
||||
"""Singular alias for collapse_namespace_tags.
|
||||
|
||||
Some cmdlet prefer the singular name; keep behavior centralized.
|
||||
"""
|
||||
return collapse_namespace_tags(tags, namespace, prefer=prefer)
|
||||
|
||||
|
||||
def extract_tag_from_result(result: Any) -> list[str]:
|
||||
"""Extract all tags from a result dict or PipeObject.
|
||||
|
||||
@@ -3395,11 +2624,11 @@ def coerce_to_pipe_object(
|
||||
pipe_obj = models.PipeObject(
|
||||
hash=hash_val,
|
||||
store=store_val,
|
||||
provider=str(
|
||||
value.get("provider")
|
||||
plugin=str(
|
||||
value.get("plugin")
|
||||
or value.get("prov")
|
||||
or value.get("source")
|
||||
or extra.get("provider")
|
||||
or extra.get("plugin")
|
||||
or extra.get("source")
|
||||
or ""
|
||||
).strip() or None,
|
||||
@@ -3456,7 +2685,7 @@ def coerce_to_pipe_object(
|
||||
pipe_obj = models.PipeObject(
|
||||
hash=hash_val,
|
||||
store=store_val,
|
||||
provider=None,
|
||||
plugin=None,
|
||||
path=str(path_val) if path_val and path_val != "unknown" else None,
|
||||
title=title_val,
|
||||
url=url_val,
|
||||
|
||||
+22
-23
@@ -17,7 +17,7 @@ from SYS.result_publication import overlay_existing_result_table, publish_result
|
||||
from SYS.rich_display import show_available_plugins_panel, show_plugin_config_panel
|
||||
from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS
|
||||
from PluginCore.backend_registry import BackendRegistry
|
||||
from API.HTTP import _download_direct_file
|
||||
from API.HTTP import download_direct_file
|
||||
from .. import _shared as sh
|
||||
|
||||
Cmdlet = sh.Cmdlet
|
||||
@@ -31,7 +31,7 @@ merge_sequences = sh.merge_sequences
|
||||
extract_relationships = sh.extract_relationships
|
||||
extract_duration = sh.extract_duration
|
||||
coerce_to_pipe_object = sh.coerce_to_pipe_object
|
||||
collapse_namespace_tag = sh.collapse_namespace_tag
|
||||
collapse_namespace_tags = sh.collapse_namespace_tags
|
||||
resolve_target_dir = sh.resolve_target_dir
|
||||
resolve_media_kind_by_extension = sh.resolve_media_kind_by_extension
|
||||
coerce_to_path = sh.coerce_to_path
|
||||
@@ -102,10 +102,10 @@ def _maybe_apply_florencevision_tags(
|
||||
config: Dict[str, Any],
|
||||
pipe_obj: Optional[models.PipeObject] = None,
|
||||
) -> List[str]:
|
||||
"""Optionally auto-tag images using the FlorenceVision tool.
|
||||
"""Optionally auto-tag images using the FlorenceVision plugin helper.
|
||||
|
||||
Controlled via config:
|
||||
[tool=florencevision]
|
||||
[plugin=florencevision]
|
||||
enabled=true
|
||||
strict=false
|
||||
|
||||
@@ -114,8 +114,8 @@ def _maybe_apply_florencevision_tags(
|
||||
"""
|
||||
strict = False
|
||||
try:
|
||||
tool_block = (config or {}).get("tool")
|
||||
fv_block = tool_block.get("florencevision") if isinstance(tool_block, dict) else None
|
||||
plugin_block = (config or {}).get("plugin")
|
||||
fv_block = plugin_block.get("florencevision") if isinstance(plugin_block, dict) else None
|
||||
enabled = False
|
||||
if isinstance(fv_block, dict):
|
||||
enabled = bool(fv_block.get("enabled"))
|
||||
@@ -123,7 +123,7 @@ def _maybe_apply_florencevision_tags(
|
||||
if not enabled:
|
||||
return tags
|
||||
|
||||
from tool.florencevision import FlorenceVisionTool
|
||||
from plugins.florencevision import FlorenceVisionTool
|
||||
|
||||
# Special-case: if this file was produced by the `screen-shot` cmdlet,
|
||||
# OCR is more useful than caption/detection for tagging screenshots.
|
||||
@@ -134,12 +134,12 @@ def _maybe_apply_florencevision_tags(
|
||||
if action.lower().startswith("cmdlet:"):
|
||||
cmdlet_name = action.split(":", 1)[1].strip().lower()
|
||||
if cmdlet_name in {"screen-shot", "screen_shot", "screenshot"}:
|
||||
tool_block2 = dict((config or {}).get("tool") or {})
|
||||
fv_block2 = dict(tool_block2.get("florencevision") or {})
|
||||
plugin_block2 = dict((config or {}).get("plugin") or {})
|
||||
fv_block2 = dict(plugin_block2.get("florencevision") or {})
|
||||
fv_block2["task"] = "ocr"
|
||||
tool_block2["florencevision"] = fv_block2
|
||||
plugin_block2["florencevision"] = fv_block2
|
||||
cfg_for_tool = dict(config or {})
|
||||
cfg_for_tool["tool"] = tool_block2
|
||||
cfg_for_tool["plugin"] = plugin_block2
|
||||
except Exception:
|
||||
cfg_for_tool = config
|
||||
|
||||
@@ -1237,7 +1237,7 @@ class Add_File(Cmdlet):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
downloaded = _download_direct_file(
|
||||
downloaded = download_direct_file(
|
||||
url_text,
|
||||
download_root,
|
||||
quiet=False,
|
||||
@@ -1693,9 +1693,8 @@ class Add_File(Cmdlet):
|
||||
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
|
||||
plugin_key = None
|
||||
for source in (
|
||||
pipe_obj.provider,
|
||||
pipe_obj.plugin,
|
||||
get_field(result, "plugin"),
|
||||
get_field(result, "provider"),
|
||||
get_field(result, "table"),
|
||||
):
|
||||
candidate = Add_File._normalize_provider_key(source)
|
||||
@@ -1760,7 +1759,7 @@ class Add_File(Cmdlet):
|
||||
str(r_hash),
|
||||
source_url,
|
||||
)
|
||||
downloaded = _download_direct_file(
|
||||
downloaded = download_direct_file(
|
||||
source_url,
|
||||
download_dir,
|
||||
quiet=True,
|
||||
@@ -2028,7 +2027,7 @@ class Add_File(Cmdlet):
|
||||
*,
|
||||
hash_value: str,
|
||||
store: str,
|
||||
provider: Optional[str] = None,
|
||||
plugin: Optional[str] = None,
|
||||
path: Optional[str],
|
||||
tag: List[str],
|
||||
title: Optional[str],
|
||||
@@ -2037,7 +2036,7 @@ class Add_File(Cmdlet):
|
||||
) -> None:
|
||||
pipe_obj.hash = hash_value
|
||||
pipe_obj.store = store
|
||||
pipe_obj.provider = provider
|
||||
pipe_obj.plugin = plugin
|
||||
pipe_obj.is_temp = False
|
||||
pipe_obj.path = path
|
||||
pipe_obj.tag = tag
|
||||
@@ -2260,7 +2259,7 @@ class Add_File(Cmdlet):
|
||||
t for t in tags_from_result
|
||||
if not str(t).strip().lower().startswith("title:")
|
||||
]
|
||||
sidecar_tags = collapse_namespace_tag(
|
||||
sidecar_tags = collapse_namespace_tags(
|
||||
[normalize_title_tag(t) for t in sidecar_tags],
|
||||
"title",
|
||||
prefer="last"
|
||||
@@ -2449,15 +2448,15 @@ class Add_File(Cmdlet):
|
||||
or "unknown"
|
||||
).strip() or "unknown"
|
||||
store_value = str(payload.get("store") or "").strip()
|
||||
provider_value = payload.get("provider")
|
||||
if provider_value is None and plugin_name:
|
||||
provider_value = plugin_name
|
||||
plugin_value = payload.get("plugin")
|
||||
if plugin_value is None and plugin_name:
|
||||
plugin_value = plugin_name
|
||||
|
||||
Add_File._update_pipe_object_destination(
|
||||
pipe_obj,
|
||||
hash_value=hash_value,
|
||||
store=store_value,
|
||||
provider=str(provider_value) if provider_value else None,
|
||||
plugin=str(plugin_value) if plugin_value else None,
|
||||
path=path_value,
|
||||
tag=tag_values,
|
||||
title=title_value,
|
||||
@@ -2584,7 +2583,7 @@ class Add_File(Cmdlet):
|
||||
pipe_obj,
|
||||
hash_value=f_hash or "unknown",
|
||||
store="",
|
||||
provider=plugin_name or None,
|
||||
plugin=plugin_name or None,
|
||||
path=file_path,
|
||||
tag=pipe_obj.tag,
|
||||
title=pipe_obj.title or (media_path.name if media_path else None),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -49,9 +49,9 @@ def _extract_hash_from_hydrus_file_url(url: str) -> str:
|
||||
def _hydrus_instance_names(config: Dict[str, Any]) -> Set[str]:
|
||||
instances: Set[str] = set()
|
||||
try:
|
||||
store_cfg = config.get("store") if isinstance(config, dict) else None
|
||||
if isinstance(store_cfg, dict):
|
||||
hydrus_cfg = store_cfg.get("hydrusnetwork")
|
||||
plugin_cfg = config.get("plugin") if isinstance(config, dict) else None
|
||||
if isinstance(plugin_cfg, dict):
|
||||
hydrus_cfg = plugin_cfg.get("hydrusnetwork")
|
||||
if isinstance(hydrus_cfg, dict):
|
||||
instances = {
|
||||
str(k).strip().lower()
|
||||
|
||||
@@ -133,13 +133,13 @@ class Delete_File(sh.Cmdlet):
|
||||
provider_name = None
|
||||
full_metadata: Dict[str, Any] = {}
|
||||
if isinstance(item, dict):
|
||||
provider_name = item.get("provider") or item.get("table")
|
||||
provider_name = item.get("plugin") or item.get("table")
|
||||
raw_meta = item.get("full_metadata") or item.get("metadata")
|
||||
if isinstance(raw_meta, dict):
|
||||
full_metadata = raw_meta
|
||||
else:
|
||||
try:
|
||||
provider_name = sh.get_field(item, "provider") or sh.get_field(item, "table")
|
||||
provider_name = sh.get_field(item, "plugin") or sh.get_field(item, "table")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
@@ -542,4 +542,4 @@ class Delete_File(sh.Cmdlet):
|
||||
|
||||
|
||||
# Instantiate and register the cmdlet
|
||||
Delete_File()
|
||||
CMDLET = Delete_File()
|
||||
|
||||
+9
-10
@@ -19,7 +19,7 @@ import shutil
|
||||
import webbrowser
|
||||
|
||||
|
||||
from API.HTTP import _download_direct_file
|
||||
from API.HTTP import download_direct_file
|
||||
from SYS.models import DownloadError, DownloadOptions, DownloadMediaResult
|
||||
from SYS.logger import log, debug_panel, is_debug_enabled
|
||||
from SYS.payload_builders import build_file_result_payload, build_table_result_payload
|
||||
@@ -235,7 +235,7 @@ class Download_File(Cmdlet):
|
||||
|
||||
action = str(
|
||||
result.get("action")
|
||||
or result.get("provider_action")
|
||||
or result.get("plugin_action")
|
||||
or ""
|
||||
).strip().lower()
|
||||
|
||||
@@ -338,12 +338,12 @@ class Download_File(Cmdlet):
|
||||
path_value: Optional[Any] = path
|
||||
|
||||
if isinstance(path, dict):
|
||||
provider_action = str(
|
||||
plugin_action = str(
|
||||
path.get("action")
|
||||
or path.get("provider_action")
|
||||
or path.get("plugin_action")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if provider_action == "download_items" or bool(path.get("download_items")):
|
||||
if plugin_action == "download_items" or bool(path.get("download_items")):
|
||||
request_metadata = path.get("metadata") or path.get("full_metadata") or {}
|
||||
if not isinstance(request_metadata, dict):
|
||||
request_metadata = {}
|
||||
@@ -522,7 +522,7 @@ class Download_File(Cmdlet):
|
||||
|
||||
# Direct Download Fallback
|
||||
attempted_download = True
|
||||
result_obj = _download_direct_file(
|
||||
result_obj = download_direct_file(
|
||||
str(url),
|
||||
final_output_dir,
|
||||
quiet=quiet_mode,
|
||||
@@ -569,7 +569,7 @@ class Download_File(Cmdlet):
|
||||
key = self._normalize_provider_key(table_hint)
|
||||
if key:
|
||||
return key
|
||||
provider_hint = get_field(item, "provider")
|
||||
provider_hint = get_field(item, "plugin")
|
||||
key = self._normalize_provider_key(provider_hint)
|
||||
if key:
|
||||
return key
|
||||
@@ -743,7 +743,7 @@ class Download_File(Cmdlet):
|
||||
and isinstance(target, str) and target.startswith("http")):
|
||||
|
||||
suggested_name = str(title).strip() if title is not None else None
|
||||
result_obj = _download_direct_file(
|
||||
result_obj = download_direct_file(
|
||||
target,
|
||||
final_output_dir,
|
||||
quiet=quiet_mode,
|
||||
@@ -926,7 +926,6 @@ class Download_File(Cmdlet):
|
||||
}
|
||||
if provider_hint:
|
||||
payload["plugin"] = str(provider_hint)
|
||||
payload["provider"] = str(provider_hint)
|
||||
if full_metadata:
|
||||
payload["metadata"] = full_metadata
|
||||
if notes:
|
||||
@@ -1125,7 +1124,7 @@ class Download_File(Cmdlet):
|
||||
filename += ext_text
|
||||
|
||||
if download_url:
|
||||
result_obj = _download_direct_file(
|
||||
result_obj = download_direct_file(
|
||||
download_url,
|
||||
final_output_dir,
|
||||
quiet=True,
|
||||
|
||||
+16
-16
@@ -43,7 +43,7 @@ from SYS import pipeline as pipeline_context
|
||||
# Playwright & Screenshot Dependencies
|
||||
# ============================================================================
|
||||
|
||||
from tool.playwright import PlaywrightTimeoutError, PlaywrightTool
|
||||
from plugins.playwright import PlaywrightTimeoutError, PlaywrightTool
|
||||
|
||||
try:
|
||||
from SYS.config import resolve_output_dir
|
||||
@@ -1525,22 +1525,22 @@ def _capture(
|
||||
{}) or {})
|
||||
except Exception:
|
||||
base_cfg = {}
|
||||
tool_block = dict(base_cfg.get("tool") or {}
|
||||
plugin_block = dict(base_cfg.get("plugin") or {}
|
||||
) if isinstance(base_cfg,
|
||||
dict) else {}
|
||||
pw_block = (
|
||||
dict(tool_block.get("playwright") or {})
|
||||
if isinstance(tool_block,
|
||||
dict(plugin_block.get("playwright") or {})
|
||||
if isinstance(plugin_block,
|
||||
dict) else {}
|
||||
)
|
||||
pw_block["browser"] = "chromium"
|
||||
tool_block["playwright"] = pw_block
|
||||
plugin_block["playwright"] = pw_block
|
||||
if isinstance(base_cfg, dict):
|
||||
base_cfg["tool"] = tool_block
|
||||
base_cfg["plugin"] = plugin_block
|
||||
tool = PlaywrightTool(base_cfg)
|
||||
except Exception:
|
||||
tool = PlaywrightTool({
|
||||
"tool": {
|
||||
"plugin": {
|
||||
"playwright": {
|
||||
"browser": "chromium"
|
||||
}
|
||||
@@ -1888,8 +1888,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
quality_value: Optional[int] = None
|
||||
if not format_value:
|
||||
try:
|
||||
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {}
|
||||
pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None
|
||||
plugin_cfg = config.get("plugin", {}) if isinstance(config, dict) else {}
|
||||
pw_cfg = plugin_cfg.get("playwright") if isinstance(plugin_cfg, dict) else None
|
||||
if isinstance(pw_cfg, dict):
|
||||
format_value = pw_cfg.get("format")
|
||||
except Exception:
|
||||
@@ -1901,8 +1901,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
quality_value = _normalize_quality(raw_quality_value)
|
||||
else:
|
||||
try:
|
||||
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {}
|
||||
pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None
|
||||
plugin_cfg = config.get("plugin", {}) if isinstance(config, dict) else {}
|
||||
pw_cfg = plugin_cfg.get("playwright") if isinstance(plugin_cfg, dict) else None
|
||||
if isinstance(pw_cfg, dict) and pw_cfg.get("screenshot_quality") not in (None, ""):
|
||||
quality_value = _normalize_quality(pw_cfg.get("screenshot_quality"))
|
||||
except Exception:
|
||||
@@ -1994,18 +1994,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
shared_playwright_tool: Optional[PlaywrightTool] = None
|
||||
try:
|
||||
if isinstance(config, dict):
|
||||
tool_block = dict(config.get("tool") or {})
|
||||
pw_block = dict(tool_block.get("playwright") or {})
|
||||
plugin_block = dict(config.get("plugin") or {})
|
||||
pw_block = dict(plugin_block.get("playwright") or {})
|
||||
pw_block["browser"] = "chromium"
|
||||
pw_block["user_agent"] = "native"
|
||||
pw_block["viewport_width"] = int(DEFAULT_VIEWPORT.get("width", 1920))
|
||||
pw_block["viewport_height"] = int(DEFAULT_VIEWPORT.get("height", 1080))
|
||||
tool_block["playwright"] = pw_block
|
||||
plugin_block["playwright"] = pw_block
|
||||
pw_local_cfg = dict(config)
|
||||
pw_local_cfg["tool"] = tool_block
|
||||
pw_local_cfg["plugin"] = plugin_block
|
||||
else:
|
||||
pw_local_cfg = {
|
||||
"tool": {
|
||||
"plugin": {
|
||||
"playwright": {
|
||||
"browser": "chromium",
|
||||
"user_agent": "native",
|
||||
|
||||
@@ -164,7 +164,7 @@ def _summarize_worker_results(results: Sequence[Dict[str, Any]], preview_limit:
|
||||
|
||||
|
||||
class search_file(Cmdlet):
|
||||
"""Class-based search-file cmdlet for searching backends and providers."""
|
||||
"""Class-based search-file cmdlet for searching backends and plugins."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
@@ -187,9 +187,9 @@ class search_file(Cmdlet):
|
||||
),
|
||||
],
|
||||
detail=[
|
||||
"Search across configured store backends or plugin providers.",
|
||||
"Search across configured storage backends or plugins.",
|
||||
"Use -instance to target a specific configured backend/instance by name.",
|
||||
"Use -plugin with -instance to target a named provider config.",
|
||||
"Use -plugin with -instance to target a named plugin config.",
|
||||
"URL search: url:* (any URL) or url:<value> (URL substring)",
|
||||
"Extension search: ext:<value> (e.g., ext:png)",
|
||||
"Hydrus-style extension: system:filetype = png",
|
||||
@@ -1216,7 +1216,7 @@ class search_file(Cmdlet):
|
||||
try:
|
||||
table.set_table_metadata(
|
||||
{
|
||||
"provider": "web",
|
||||
"plugin": "web",
|
||||
"site": site_host,
|
||||
"query": search_query,
|
||||
"filetype": requested_type,
|
||||
@@ -1490,7 +1490,7 @@ class search_file(Cmdlet):
|
||||
|
||||
return 1
|
||||
|
||||
# Align with provider default when user did not set -limit.
|
||||
# Align with plugin default when user did not set -limit.
|
||||
if not limit_set:
|
||||
limit = 50
|
||||
|
||||
@@ -1632,7 +1632,7 @@ class search_file(Cmdlet):
|
||||
if "table" not in item_dict:
|
||||
item_dict["table"] = table_type
|
||||
|
||||
# Ensure provider source is present so downstream cmdlets (select) can resolve provider
|
||||
# Ensure plugin source is present so downstream cmdlets can resolve the owner.
|
||||
if "source" not in item_dict:
|
||||
item_dict["source"] = plugin_name
|
||||
|
||||
|
||||
@@ -125,10 +125,6 @@ class File(Cmdlet):
|
||||
if callable(exec_fn):
|
||||
return int(exec_fn(result, args, config))
|
||||
|
||||
fallback_run = getattr(module, "_run", None)
|
||||
if callable(fallback_run):
|
||||
return int(fallback_run(result, args, config))
|
||||
|
||||
log(f"file: cannot dispatch action '{action}' via module '{module_name}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
@@ -640,8 +640,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
except Exception:
|
||||
hydrus_client = None
|
||||
|
||||
# Sidecar/tag import fallback DB root (legacy): if a folder store is selected, use it;
|
||||
# otherwise fall back to configured local storage path.
|
||||
# Use the selected store root when available; otherwise use the configured
|
||||
# local plugin root for sidecar/tag import lookup.
|
||||
from SYS.config import get_local_storage_path
|
||||
|
||||
local_storage_root: Optional[Path] = None
|
||||
@@ -852,8 +852,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"path",
|
||||
None)
|
||||
|
||||
# Legacy LOCAL STORAGE MODE: Handle relationships for local files
|
||||
# (kept as stub - folder store removed)
|
||||
# Handle relationships for local-file results using the configured
|
||||
# local plugin root when available.
|
||||
from SYS.config import get_local_storage_path
|
||||
|
||||
local_storage_path = get_local_storage_path(config) if config else None
|
||||
@@ -869,7 +869,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
try:
|
||||
file_path_obj = Path(str(file_path_from_result))
|
||||
except Exception as exc:
|
||||
log(f"Local storage error: {exc}", file=sys.stderr)
|
||||
log(f"Local library error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not file_path_obj.exists():
|
||||
@@ -879,12 +879,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if file_path_obj is not None:
|
||||
try:
|
||||
if local_storage_root is None:
|
||||
log("Local storage path unavailable", file=sys.stderr)
|
||||
log("Local plugin path unavailable", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with LocalLibrarySearchOptimizer(local_storage_root) as opt:
|
||||
if opt.db is None:
|
||||
log("Local storage DB unavailable", file=sys.stderr)
|
||||
log("Local library DB unavailable", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if king_hash:
|
||||
|
||||
@@ -25,7 +25,7 @@ expand_tag_groups = sh.expand_tag_groups
|
||||
merge_sequences = sh.merge_sequences
|
||||
render_tag_value_templates = sh.render_tag_value_templates
|
||||
parse_cmdlet_args = sh.parse_cmdlet_args
|
||||
collapse_namespace_tag = sh.collapse_namespace_tag
|
||||
collapse_namespace_tags = sh.collapse_namespace_tags
|
||||
should_show_help = sh.should_show_help
|
||||
get_field = sh.get_field
|
||||
|
||||
@@ -800,7 +800,7 @@ class Add_Tag(Cmdlet):
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add = collapse_namespace_tags(
|
||||
item_tag_to_add,
|
||||
"title",
|
||||
prefer="last"
|
||||
@@ -843,7 +843,7 @@ class Add_Tag(Cmdlet):
|
||||
)
|
||||
unresolved_template_count += len(unresolved_templates)
|
||||
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add = collapse_namespace_tags(
|
||||
item_tag_to_add,
|
||||
"title",
|
||||
prefer="last"
|
||||
@@ -877,7 +877,7 @@ class Add_Tag(Cmdlet):
|
||||
]
|
||||
updated_tag_list.extend(actual_tag_to_add)
|
||||
|
||||
updated_tag_list = collapse_namespace_tag(
|
||||
updated_tag_list = collapse_namespace_tags(
|
||||
updated_tag_list,
|
||||
"title",
|
||||
prefer="last"
|
||||
@@ -977,7 +977,7 @@ class Add_Tag(Cmdlet):
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add = collapse_namespace_tags(
|
||||
item_tag_to_add,
|
||||
"title",
|
||||
prefer="last"
|
||||
@@ -1016,7 +1016,7 @@ class Add_Tag(Cmdlet):
|
||||
)
|
||||
unresolved_template_count += len(unresolved_templates)
|
||||
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add = collapse_namespace_tags(
|
||||
item_tag_to_add,
|
||||
"title",
|
||||
prefer="last"
|
||||
@@ -1032,7 +1032,7 @@ class Add_Tag(Cmdlet):
|
||||
]
|
||||
if len(existing_title_tags) > 1:
|
||||
item_tag_to_add.append(existing_title_tags[-1])
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add = collapse_namespace_tags(
|
||||
item_tag_to_add,
|
||||
"title",
|
||||
prefer="last"
|
||||
|
||||
@@ -649,7 +649,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
item_title=str(items[0].get("title") or provider.name),
|
||||
path=None,
|
||||
subject={
|
||||
"provider": provider.name,
|
||||
"plugin": provider.name,
|
||||
"url": str(query_hint)
|
||||
},
|
||||
quiet=emit_mode,
|
||||
@@ -692,7 +692,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
)
|
||||
payload = {
|
||||
"tag": tags,
|
||||
"provider": provider.name,
|
||||
"plugin": provider.name,
|
||||
"title": item.get("title"),
|
||||
"artist": item.get("artist"),
|
||||
"album": item.get("album"),
|
||||
@@ -702,7 +702,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"path": path_for_payload,
|
||||
"extra": {
|
||||
"tag": tags,
|
||||
"provider": provider.name,
|
||||
"plugin": provider.name,
|
||||
},
|
||||
}
|
||||
selection_payload.append(payload)
|
||||
@@ -721,7 +721,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# If the current result already carries a tag list (e.g. a selected metadata
|
||||
# row from get-tag -scrape itunes), APPLY those tags to the file in the store.
|
||||
result_provider = get_field(result, "provider", None)
|
||||
result_provider = get_field(result, "plugin", None)
|
||||
result_tags = get_field(result, "tag", None)
|
||||
|
||||
if result_provider and isinstance(result_tags, list) and result_tags:
|
||||
|
||||
+4
-14
@@ -4,6 +4,8 @@ import os
|
||||
from importlib import import_module
|
||||
from typing import Any, Callable, Dict, Sequence
|
||||
|
||||
from SYS.cmdlet_spec import collect_registered_cmdlet_names
|
||||
|
||||
CmdletFn = Callable[[Any, Sequence[str], Dict[str, Any]], int]
|
||||
|
||||
|
||||
@@ -12,20 +14,8 @@ def _register_cmdlet_object(cmdlet_obj, registry: Dict[str, CmdletFn]) -> None:
|
||||
if not callable(run_fn):
|
||||
return
|
||||
|
||||
if hasattr(cmdlet_obj, "name") and cmdlet_obj.name:
|
||||
registry[cmdlet_obj.name.replace("_", "-").lower()] = run_fn
|
||||
|
||||
# Cmdlet uses 'alias' (List[str]). Some older objects may use 'aliases'.
|
||||
aliases: list[str] = []
|
||||
if hasattr(cmdlet_obj, "alias") and getattr(cmdlet_obj, "alias"):
|
||||
aliases.extend(getattr(cmdlet_obj, "alias") or [])
|
||||
if hasattr(cmdlet_obj, "aliases") and getattr(cmdlet_obj, "aliases"):
|
||||
aliases.extend(getattr(cmdlet_obj, "aliases") or [])
|
||||
|
||||
for alias in aliases:
|
||||
if not alias:
|
||||
continue
|
||||
registry[alias.replace("_", "-").lower()] = run_fn
|
||||
for registered_name in collect_registered_cmdlet_names(cmdlet_obj):
|
||||
registry[registered_name] = run_fn
|
||||
|
||||
|
||||
def _iter_legacy_native_module_names() -> list[str]:
|
||||
|
||||
@@ -34,8 +34,6 @@ def _provider_config_map(config: dict) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
provider_cfg = config.get("plugin")
|
||||
if not isinstance(provider_cfg, dict):
|
||||
provider_cfg = config.get("provider")
|
||||
return provider_cfg if isinstance(provider_cfg, dict) else {}
|
||||
|
||||
|
||||
@@ -100,34 +98,14 @@ def _resolve_startup_instance_text(
|
||||
return ", ".join(_extract_configured_instance_names(configured_entry))
|
||||
|
||||
|
||||
def has_store_subtype(cfg: dict, subtype: str) -> bool:
|
||||
store_cfg = cfg.get("store")
|
||||
if not isinstance(store_cfg, dict):
|
||||
return False
|
||||
bucket = store_cfg.get(subtype)
|
||||
if not isinstance(bucket, dict):
|
||||
return False
|
||||
return any(isinstance(value, dict) and bool(value) for value in bucket.values())
|
||||
|
||||
|
||||
def has_provider(cfg: dict, name: str) -> bool:
|
||||
provider_cfg = cfg.get("plugin")
|
||||
if not isinstance(provider_cfg, dict):
|
||||
provider_cfg = cfg.get("provider")
|
||||
if not isinstance(provider_cfg, dict):
|
||||
return False
|
||||
block = provider_cfg.get(str(name).strip().lower())
|
||||
return isinstance(block, dict) and bool(block)
|
||||
|
||||
|
||||
def has_tool(cfg: dict, name: str) -> bool:
|
||||
tool_cfg = cfg.get("tool")
|
||||
if not isinstance(tool_cfg, dict):
|
||||
return False
|
||||
block = tool_cfg.get(str(name).strip().lower())
|
||||
return isinstance(block, dict) and bool(block)
|
||||
|
||||
|
||||
def ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]:
|
||||
try:
|
||||
from API.HTTP import HTTPClient
|
||||
|
||||
+8
-38
@@ -14,10 +14,8 @@ from SYS.database import LOG_DB_PATH, db
|
||||
from SYS.logger import log
|
||||
from SYS.plugin_config import (
|
||||
build_default_plugin_config,
|
||||
build_default_tool_config,
|
||||
get_configurable_plugin_types,
|
||||
get_configurable_store_types,
|
||||
get_configurable_tool_types,
|
||||
)
|
||||
from SYS import pipeline as ctx
|
||||
from SYS.result_table import Table
|
||||
@@ -32,19 +30,15 @@ from cmdnat._parsing import (
|
||||
|
||||
_PREFERENCES_BROWSE_PATH = "__preferences__"
|
||||
_PLUGINS_BROWSE_PATH = "__plugins__"
|
||||
_PLUGIN_CATEGORY_KEYS = ("plugin", "provider", "tool")
|
||||
_PLUGIN_CATEGORY_KEYS = ("plugin",)
|
||||
_CREATE_INSTANCE_FLAG = "-create-instance"
|
||||
_KNOWN_SECTION_LABELS = {
|
||||
"plugin": "Plugins",
|
||||
"provider": "Plugins",
|
||||
"tool": "Plugins",
|
||||
}
|
||||
_KNOWN_SECTION_DESCRIPTIONS = {
|
||||
_PREFERENCES_BROWSE_PATH: "Global preferences and simple values",
|
||||
_PLUGINS_BROWSE_PATH: "All configured plugins and plugin instances",
|
||||
"provider": "Plugin configuration",
|
||||
"plugin": "Plugin configuration",
|
||||
"tool": "Plugin configuration",
|
||||
}
|
||||
_SENSITIVE_CONFIG_KEYS = {
|
||||
"access_key",
|
||||
@@ -300,19 +294,6 @@ def _get_configurable_plugin_names() -> List[str]:
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_configurable_tool_names() -> List[str]:
|
||||
try:
|
||||
return [
|
||||
str(name).strip().lower()
|
||||
for name in (get_configurable_tool_types() or [])
|
||||
if str(name).strip()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_multi_instance_plugin_names() -> set[str]:
|
||||
try:
|
||||
return {
|
||||
@@ -336,7 +317,7 @@ def _is_multi_instance_plugin_root_path(browse_path: Optional[str]) -> bool:
|
||||
parts = _split_config_path(browse_path)
|
||||
return (
|
||||
len(parts) == 2
|
||||
and parts[0] in {"plugin", "provider"}
|
||||
and parts[0] == "plugin"
|
||||
and _is_multi_instance_plugin_name(parts[1])
|
||||
)
|
||||
|
||||
@@ -397,10 +378,6 @@ def _build_synthetic_plugin_branch(category: str, name: str) -> Optional[Dict[st
|
||||
if not normalized_name:
|
||||
return None
|
||||
|
||||
if normalized_category == "tool":
|
||||
branch = build_default_tool_config(normalized_name)
|
||||
return dict(branch) if isinstance(branch, dict) else None
|
||||
|
||||
branch = build_default_plugin_config(normalized_name)
|
||||
if not isinstance(branch, dict):
|
||||
return None
|
||||
@@ -441,10 +418,7 @@ def _resolve_plugin_branch(
|
||||
if not normalized_name:
|
||||
return None
|
||||
|
||||
if normalized_category == "tool":
|
||||
if normalized_name not in _get_configurable_tool_names():
|
||||
return None
|
||||
elif normalized_name not in _get_configurable_plugin_names():
|
||||
if normalized_name not in _get_configurable_plugin_names():
|
||||
return None
|
||||
|
||||
synthetic = _build_synthetic_plugin_branch(normalized_category, normalized_name)
|
||||
@@ -557,7 +531,7 @@ def _resolve_config_branch(
|
||||
if resolved is None:
|
||||
return None
|
||||
_, current, _ = resolved
|
||||
if parts[0] in {"plugin", "provider"} and _is_multi_instance_plugin_name(parts[1]):
|
||||
if parts[0] == "plugin" and _is_multi_instance_plugin_name(parts[1]):
|
||||
current = _normalize_multi_instance_branch(parts[1], current)
|
||||
for part in parts[2:]:
|
||||
if not isinstance(current, dict):
|
||||
@@ -620,7 +594,7 @@ def _create_or_get_plugin_instance(
|
||||
instance_name: str,
|
||||
) -> tuple[str, bool]:
|
||||
parts = _split_config_path(instance_target)
|
||||
if len(parts) != 2 or parts[0] not in {"plugin", "provider"}:
|
||||
if len(parts) != 2 or parts[0] != "plugin":
|
||||
raise ValueError(f"Unsupported instance target '{instance_target}'")
|
||||
|
||||
category, plugin_name = parts
|
||||
@@ -663,7 +637,7 @@ def _resolve_update_key(config_data: Dict[str, Any], selection_key: str) -> str:
|
||||
parts = _split_config_path(selection_key)
|
||||
if (
|
||||
len(parts) >= 4
|
||||
and parts[0] in {"plugin", "provider"}
|
||||
and parts[0] == "plugin"
|
||||
and parts[2].lower() == "default"
|
||||
and _is_multi_instance_plugin_name(parts[1])
|
||||
):
|
||||
@@ -797,7 +771,7 @@ def _build_config_header_lines(browse_path: Optional[str]) -> List[str]:
|
||||
parts = _split_config_path(text)
|
||||
if (
|
||||
len(parts) == 3
|
||||
and parts[0] in {"plugin", "provider"}
|
||||
and parts[0] == "plugin"
|
||||
and _is_multi_instance_plugin_name(parts[1])
|
||||
):
|
||||
return [
|
||||
@@ -951,17 +925,13 @@ def _resolve_direct_browse_path(
|
||||
lowered = text.lower()
|
||||
if lowered in {"preferences", "prefs"}:
|
||||
return _PREFERENCES_BROWSE_PATH
|
||||
if lowered in {"plugins", "plugin", "providers", "provider", "tools", "tool"}:
|
||||
if lowered in {"plugins", "plugin"}:
|
||||
return _PLUGINS_BROWSE_PATH
|
||||
|
||||
plugin_branch = _resolve_plugin_branch(config_data, "plugin", lowered)
|
||||
if plugin_branch is not None:
|
||||
return f"plugin.{plugin_branch[0]}"
|
||||
|
||||
tool_branch = _resolve_plugin_branch(config_data, "tool", lowered)
|
||||
if tool_branch is not None:
|
||||
return f"tool.{tool_branch[0]}"
|
||||
|
||||
branch = _resolve_config_branch(config_data, text)
|
||||
if isinstance(branch, dict):
|
||||
return text
|
||||
|
||||
+7
-21
@@ -4,7 +4,7 @@ from typing import Any, Dict, Sequence, List, Optional, Tuple
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg, parse_cmdlet_args
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg, collect_registered_cmdlet_names, parse_cmdlet_args
|
||||
from cmdlet import REGISTRY as CMDLET_REGISTRY, ensure_cmdlet_modules_loaded
|
||||
from SYS.logger import log
|
||||
from SYS.result_table import Table
|
||||
@@ -53,26 +53,12 @@ def _normalize_cmdlet_key(name: Optional[str]) -> str:
|
||||
|
||||
|
||||
def _cmdlet_aliases(cmdlet_obj: Cmdlet) -> List[str]:
|
||||
aliases: List[str] = []
|
||||
for attr in ("alias", "aliases"):
|
||||
raw_aliases = getattr(cmdlet_obj, attr, None)
|
||||
if isinstance(raw_aliases, (list, tuple, set)):
|
||||
candidates = raw_aliases
|
||||
else:
|
||||
candidates = (raw_aliases,)
|
||||
for alias in candidates or ():
|
||||
text = str(alias or "").strip()
|
||||
if text:
|
||||
aliases.append(text)
|
||||
seen: set[str] = set()
|
||||
deduped: List[str] = []
|
||||
for alias in aliases:
|
||||
key = alias.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(alias)
|
||||
return deduped
|
||||
canonical_name = _normalize_cmdlet_key(getattr(cmdlet_obj, "name", None))
|
||||
return [
|
||||
registered_name
|
||||
for registered_name in collect_registered_cmdlet_names(cmdlet_obj)
|
||||
if registered_name != canonical_name
|
||||
]
|
||||
|
||||
|
||||
def _cmdlet_arg_to_dict(arg: CmdletArg) -> Dict[str, Any]:
|
||||
|
||||
@@ -52,7 +52,7 @@ from SYS.result_table import ResultTable
|
||||
|
||||
table = ResultTable("Plugin: X results").set_preserve_order(True)
|
||||
table.set_table("plugin_name")
|
||||
table.set_table_metadata({"provider": "plugin_name", "view": "folders"})
|
||||
table.set_table_metadata({"plugin": "plugin_name", "view": "folders"})
|
||||
table.set_source_command("search-file", ["-plugin", "plugin_name", "query"])
|
||||
|
||||
for result in results:
|
||||
@@ -114,8 +114,8 @@ SearchResult(
|
||||
media_kind="folder",
|
||||
full_metadata={
|
||||
"magnet_id": 123,
|
||||
"provider": "alldebrid",
|
||||
"provider_view": "folders",
|
||||
"plugin": "alldebrid",
|
||||
"plugin_view": "folders",
|
||||
},
|
||||
)
|
||||
```
|
||||
@@ -130,8 +130,8 @@ SearchResult(
|
||||
media_kind="file",
|
||||
full_metadata={
|
||||
"magnet_id": 123,
|
||||
"provider": "alldebrid",
|
||||
"provider_view": "files",
|
||||
"plugin": "alldebrid",
|
||||
"plugin_view": "files",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from API.HTTP import HTTPClient, _download_direct_file
|
||||
from API.HTTP import HTTPClient, download_direct_file
|
||||
from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file
|
||||
from PluginCore.base import Provider, SearchResult
|
||||
from SYS.plugin_helpers import TablePluginMixin
|
||||
@@ -197,47 +197,14 @@ def refresh_alldebrid_hoster_cache(*, force: bool = False) -> None:
|
||||
|
||||
|
||||
def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Read AllDebrid API key from config.
|
||||
|
||||
Preferred formats:
|
||||
- config.conf provider block:
|
||||
[provider=alldebrid]
|
||||
api_key=...
|
||||
-> config["provider"]["alldebrid"]["api_key"]
|
||||
|
||||
- plugin-style debrid block:
|
||||
config["plugin"]["debrid"]["all-debrid"]["api_key"]
|
||||
|
||||
Falls back to some legacy keys if present.
|
||||
"""
|
||||
# 1) provider block: [provider=alldebrid]
|
||||
provider = config.get("provider")
|
||||
if isinstance(provider, dict):
|
||||
entry = provider.get("alldebrid")
|
||||
if isinstance(entry, dict):
|
||||
for k in ("api_key", "apikey", "API_KEY", "APIKEY"):
|
||||
val = entry.get(k)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
if isinstance(entry, str) and entry.strip():
|
||||
return entry.strip()
|
||||
|
||||
# 2) plugin debrid block
|
||||
"""Read the canonical AllDebrid API key from config."""
|
||||
try:
|
||||
from SYS.config import get_debrid_api_key
|
||||
|
||||
key = get_debrid_api_key(config, service="All-debrid")
|
||||
return key.strip() if key else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Legacy fallback (kept permissive so older configs still work)
|
||||
for legacy_key in ("alldebrid_api_key", "AllDebrid", "all_debrid_api_key"):
|
||||
val = config.get(legacy_key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _consume_bencoded_value(data: bytes, pos: int) -> int:
|
||||
@@ -399,8 +366,8 @@ def _build_queued_magnet_item(
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"magnet_id": magnet_id,
|
||||
"provider": "alldebrid",
|
||||
"provider_view": "files",
|
||||
"plugin": "alldebrid",
|
||||
"plugin_view": "files",
|
||||
"magnet_spec": magnet_spec,
|
||||
"source_url": magnet_spec,
|
||||
"status": status_label,
|
||||
@@ -412,7 +379,6 @@ def _build_queued_magnet_item(
|
||||
|
||||
return {
|
||||
"table": "alldebrid",
|
||||
"provider": "alldebrid",
|
||||
"plugin": "alldebrid",
|
||||
"path": f"{_ALD_MAGNET_PREFIX}{magnet_id}",
|
||||
"title": title,
|
||||
@@ -539,7 +505,7 @@ def download_magnet(
|
||||
output_dir = target_path
|
||||
|
||||
try:
|
||||
result_obj = _download_direct_file(
|
||||
result_obj = download_direct_file(
|
||||
file_url,
|
||||
output_dir,
|
||||
quiet=quiet_mode,
|
||||
@@ -800,8 +766,8 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
"title": f"magnet-{magnet_id}",
|
||||
"metadata": {
|
||||
"magnet_id": magnet_id,
|
||||
"provider": "alldebrid",
|
||||
"provider_view": "files",
|
||||
"plugin": "alldebrid",
|
||||
"plugin_view": "files",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -952,7 +918,7 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
pipe_progress = None
|
||||
|
||||
try:
|
||||
dl_res = _download_direct_file(
|
||||
dl_res = download_direct_file(
|
||||
unlocked_url,
|
||||
Path(output_dir),
|
||||
quiet=quiet,
|
||||
@@ -965,7 +931,7 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
downloaded_path = Path(str(downloaded_path))
|
||||
except DownloadError as exc:
|
||||
log(
|
||||
f"[alldebrid] _download_direct_file rejected URL ({exc}); no further fallback", file=sys.stderr
|
||||
f"[alldebrid] download_direct_file rejected URL ({exc}); no further fallback", file=sys.stderr
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1360,7 +1326,7 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
suggested_name = rel_path_obj.name or file_name or f"file-{file_idx}"
|
||||
|
||||
try:
|
||||
result_obj = _download_direct_file(
|
||||
result_obj = download_direct_file(
|
||||
file_url,
|
||||
target_path,
|
||||
quiet=quiet_mode,
|
||||
@@ -1482,8 +1448,8 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
full_metadata={
|
||||
"magnet": magnet_status,
|
||||
"magnet_id": magnet_id,
|
||||
"provider": "alldebrid",
|
||||
"provider_view": "files",
|
||||
"plugin": "alldebrid",
|
||||
"plugin_view": "files",
|
||||
"magnet_name": magnet_name,
|
||||
},
|
||||
)
|
||||
@@ -1535,8 +1501,8 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
"magnet_name": magnet_name,
|
||||
"relpath": relpath,
|
||||
"file": file_node,
|
||||
"provider": "alldebrid",
|
||||
"provider_view": "files",
|
||||
"plugin": "alldebrid",
|
||||
"plugin_view": "files",
|
||||
# Selection metadata for table system
|
||||
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
|
||||
"_selection_action": ["download-file", "-plugin", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
|
||||
@@ -1649,8 +1615,8 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
full_metadata={
|
||||
"magnet": magnet,
|
||||
"magnet_id": magnet_id,
|
||||
"provider": "alldebrid",
|
||||
"provider_view": "folders",
|
||||
"plugin": "alldebrid",
|
||||
"plugin_view": "folders",
|
||||
"magnet_name": magnet_name,
|
||||
# Selection metadata: allow @N expansion to drive downloads directly
|
||||
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
|
||||
@@ -1752,7 +1718,7 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
table = Table(f"AllDebrid Files: {title}")._perseverance(True)
|
||||
table.set_table("alldebrid")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "alldebrid", "view": "files", "magnet_id": magnet_id})
|
||||
table.set_table_metadata({"plugin": "alldebrid", "view": "files", "magnet_id": magnet_id})
|
||||
except Exception:
|
||||
pass
|
||||
table.set_source_command("download-file", ["-plugin", "alldebrid"])
|
||||
@@ -1844,7 +1810,7 @@ class AllDebrid(TablePluginMixin, Provider):
|
||||
"Magnet": magnet_name or None,
|
||||
"Magnet ID": magnet_id,
|
||||
"Relative Path": relpath or None,
|
||||
"View": str(meta.get("provider_view") or meta.get("view") or (table_metadata or {}).get("view") or "").strip() or None,
|
||||
"View": str(meta.get("plugin_view") or meta.get("view") or (table_metadata or {}).get("view") or "").strip() or None,
|
||||
"Direct Url": direct_url or None,
|
||||
"Selection Url": selection_url or None,
|
||||
},
|
||||
@@ -1942,7 +1908,7 @@ try:
|
||||
if table_name:
|
||||
metadata.setdefault("table", table_name)
|
||||
metadata.setdefault("source", table_name)
|
||||
metadata.setdefault("provider", table_name)
|
||||
metadata.setdefault("plugin", table_name)
|
||||
|
||||
ext = payload.get("ext")
|
||||
if not ext and isinstance(path_val, str):
|
||||
@@ -2003,8 +1969,8 @@ try:
|
||||
cols.append(metadata_column("ready", "Ready"))
|
||||
if _has_metadata(rows, "relpath"):
|
||||
cols.append(metadata_column("relpath", "File Path"))
|
||||
if _has_metadata(rows, "provider_view"):
|
||||
cols.append(metadata_column("provider_view", "View"))
|
||||
if _has_metadata(rows, "plugin_view"):
|
||||
cols.append(metadata_column("plugin_view", "View"))
|
||||
if _has_metadata(rows, "size"):
|
||||
cols.append(metadata_column("size", "Size"))
|
||||
return cols
|
||||
@@ -2016,7 +1982,7 @@ try:
|
||||
Selection precedence:
|
||||
1. Explicit _selection_action (full command args)
|
||||
2. Explicit _selection_args (URL-specific args)
|
||||
3. Magic routing based on provider_view (files vs folders)
|
||||
3. Magic routing based on plugin_view (files vs folders)
|
||||
4. Magnet ID routing for folder-type rows (via alldebrid:magnet:<id>)
|
||||
5. Direct URL for file rows
|
||||
|
||||
@@ -2035,7 +2001,7 @@ try:
|
||||
return [str(x) for x in args if x is not None]
|
||||
|
||||
# Magic routing by view type
|
||||
view = metadata.get("provider_view") or metadata.get("view") or ""
|
||||
view = metadata.get("plugin_view") or metadata.get("view") or ""
|
||||
if view == "files":
|
||||
# File rows: pass direct URL for immediate download
|
||||
if row.path:
|
||||
|
||||
@@ -1027,7 +1027,7 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
|
||||
unlock-link # Uses URL from pipeline result
|
||||
|
||||
Requires:
|
||||
- AllDebrid API key in config under Debrid.All-debrid
|
||||
- AllDebrid API key in config under plugin.alldebrid.api_key
|
||||
|
||||
Args:
|
||||
result: Pipeline result object
|
||||
@@ -1054,28 +1054,14 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
|
||||
return None
|
||||
|
||||
def _get_alldebrid_api_key_from_config(cfg: Dict[str, Any]) -> Optional[str]:
|
||||
# Current config format
|
||||
try:
|
||||
provider_cfg = cfg.get("provider") if isinstance(cfg, dict) else None
|
||||
ad_cfg = provider_cfg.get("alldebrid"
|
||||
) if isinstance(provider_cfg,
|
||||
dict) else None
|
||||
api_key = ad_cfg.get("api_key") if isinstance(ad_cfg, dict) else None
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
except Exception:
|
||||
pass
|
||||
from SYS.config import get_debrid_api_key
|
||||
|
||||
# Legacy config format fallback (best-effort)
|
||||
try:
|
||||
debrid_cfg = cfg.get("Debrid") if isinstance(cfg, dict) else None
|
||||
api_key = None
|
||||
if isinstance(debrid_cfg, dict):
|
||||
api_key = debrid_cfg.get("All-debrid") or debrid_cfg.get("AllDebrid")
|
||||
api_key = get_debrid_api_key(cfg, service="All-debrid")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional
|
||||
from PluginCore.base import Provider, SearchResult
|
||||
from SYS.logger import log, debug, debug_panel
|
||||
|
||||
from tool.playwright import PlaywrightTool
|
||||
from plugins.playwright import PlaywrightTool
|
||||
|
||||
|
||||
class Bandcamp(Provider):
|
||||
|
||||
@@ -11,10 +11,10 @@ from SYS.logger import log
|
||||
def _pick_provider_config(config: Any) -> Dict[str, Any]:
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
provider = config.get("provider")
|
||||
if not isinstance(provider, dict):
|
||||
plugin_cfg = config.get("plugin")
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
return {}
|
||||
entry = provider.get("file.io")
|
||||
entry = plugin_cfg.get("file.io")
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
return {}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""FlorenceVision support module under the plugin namespace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"FlorenceVisionTool",
|
||||
"FlorenceVisionDefaults",
|
||||
"config_schema",
|
||||
]
|
||||
|
||||
_MODULE_ATTRS = {
|
||||
"FlorenceVisionTool": ".runtime",
|
||||
"FlorenceVisionDefaults": ".runtime",
|
||||
"config_schema": ".runtime",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
submod = _MODULE_ATTRS.get(name)
|
||||
if submod is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
from importlib import import_module
|
||||
|
||||
mod = import_module(submod, package=__name__)
|
||||
obj = getattr(mod, name)
|
||||
globals()[name] = obj
|
||||
return obj
|
||||
@@ -291,8 +291,8 @@ class FlorenceVisionTool:
|
||||
|
||||
Designed to be dependency-light at import time; heavy deps are imported lazily.
|
||||
|
||||
Config:
|
||||
[tool=florencevision]
|
||||
Config:
|
||||
[plugin=florencevision]
|
||||
enabled=true
|
||||
strict=false
|
||||
model="microsoft/Florence-2-large"
|
||||
@@ -316,21 +316,21 @@ class FlorenceVisionTool:
|
||||
|
||||
def _load_defaults(self) -> FlorenceVisionDefaults:
|
||||
cfg = self._config
|
||||
tool_block = _get_nested(cfg, "tool", "florencevision")
|
||||
if not isinstance(tool_block, dict):
|
||||
tool_block = {}
|
||||
plugin_block = _get_nested(cfg, "plugin", "florencevision")
|
||||
if not isinstance(plugin_block, dict):
|
||||
plugin_block = {}
|
||||
|
||||
base = FlorenceVisionDefaults()
|
||||
|
||||
defaults = FlorenceVisionDefaults(
|
||||
enabled=_as_bool(tool_block.get("enabled"), False),
|
||||
strict=_as_bool(tool_block.get("strict"), False),
|
||||
model=str(tool_block.get("model") or base.model),
|
||||
device=str(tool_block.get("device") or base.device),
|
||||
dtype=(str(tool_block.get("dtype")).strip() if tool_block.get("dtype") else None),
|
||||
max_tags=_as_int(tool_block.get("max_tags"), base.max_tags),
|
||||
namespace=str(tool_block.get("namespace") or base.namespace),
|
||||
task=str(tool_block.get("task") or base.task),
|
||||
enabled=_as_bool(plugin_block.get("enabled"), False),
|
||||
strict=_as_bool(plugin_block.get("strict"), False),
|
||||
model=str(plugin_block.get("model") or base.model),
|
||||
device=str(plugin_block.get("device") or base.device),
|
||||
dtype=(str(plugin_block.get("dtype")).strip() if plugin_block.get("dtype") else None),
|
||||
max_tags=_as_int(plugin_block.get("max_tags"), base.max_tags),
|
||||
namespace=str(plugin_block.get("namespace") or base.namespace),
|
||||
task=str(plugin_block.get("task") or base.task),
|
||||
)
|
||||
return defaults
|
||||
|
||||
@@ -1022,4 +1022,58 @@ class FlorenceVisionTool:
|
||||
return self.tags_for_image(media_path)
|
||||
|
||||
|
||||
__all__ = ["FlorenceVisionTool", "FlorenceVisionDefaults"]
|
||||
def config_schema() -> List[Dict[str, Any]]:
|
||||
defaults = FlorenceVisionDefaults()
|
||||
return [
|
||||
{
|
||||
"key": "enabled",
|
||||
"label": "Enable FlorenceVision",
|
||||
"type": "boolean",
|
||||
"default": str(defaults.enabled).lower(),
|
||||
"choices": ["true", "false"],
|
||||
},
|
||||
{
|
||||
"key": "strict",
|
||||
"label": "Strict mode",
|
||||
"type": "boolean",
|
||||
"default": str(defaults.strict).lower(),
|
||||
"choices": ["true", "false"],
|
||||
},
|
||||
{
|
||||
"key": "model",
|
||||
"label": "Model",
|
||||
"default": defaults.model,
|
||||
},
|
||||
{
|
||||
"key": "device",
|
||||
"label": "Device",
|
||||
"default": defaults.device,
|
||||
"choices": ["cpu", "cuda", "mps"],
|
||||
},
|
||||
{
|
||||
"key": "dtype",
|
||||
"label": "DType",
|
||||
"default": defaults.dtype or "",
|
||||
"choices": ["", "float16", "bfloat16", "float32"],
|
||||
},
|
||||
{
|
||||
"key": "max_tags",
|
||||
"label": "Max tags",
|
||||
"type": "integer",
|
||||
"default": defaults.max_tags,
|
||||
},
|
||||
{
|
||||
"key": "namespace",
|
||||
"label": "Namespace",
|
||||
"default": defaults.namespace,
|
||||
},
|
||||
{
|
||||
"key": "task",
|
||||
"label": "Task",
|
||||
"default": defaults.task,
|
||||
"choices": ["tag", "detection", "caption", "ocr"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["FlorenceVisionTool", "FlorenceVisionDefaults", "config_schema"]
|
||||
@@ -359,7 +359,7 @@ class FTP(Provider):
|
||||
table.set_table("ftp")
|
||||
try:
|
||||
table.set_table_metadata({
|
||||
"provider": "ftp",
|
||||
"plugin": "ftp",
|
||||
"instance": instance_name or None,
|
||||
"host": settings.get("host"),
|
||||
"path": target_path,
|
||||
@@ -792,7 +792,7 @@ class FTP(Provider):
|
||||
parent = posixpath.dirname(ftp_path.rstrip("/")) or "/"
|
||||
instance_name = str(settings.get("instance") or "").strip()
|
||||
metadata = {
|
||||
"provider": "ftp",
|
||||
"plugin": "ftp",
|
||||
"instance": instance_name or None,
|
||||
"host": settings.get("host"),
|
||||
"ftp_path": ftp_path,
|
||||
|
||||
@@ -153,7 +153,7 @@ class HelloProvider(Provider):
|
||||
table = Table(f"Hello Details: {title}")._perseverance(True)
|
||||
table.set_table("hello")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "hello", "view": "details", "example_index": idx})
|
||||
table.set_table_metadata({"plugin": "hello", "view": "details", "example_index": idx})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from plugins.tidal.api import (
|
||||
Tidal as TidalApiClient,
|
||||
Tidal,
|
||||
build_track_tags,
|
||||
coerce_duration_seconds,
|
||||
extract_artists,
|
||||
@@ -97,7 +97,7 @@ class HIFI(Provider):
|
||||
self.api_timeout = float(self.config.get("timeout", 10.0))
|
||||
except Exception:
|
||||
self.api_timeout = 10.0
|
||||
self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
|
||||
self.api_clients = [Tidal(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
|
||||
|
||||
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
|
||||
normalized, parsed = parse_inline_query_arguments(query)
|
||||
@@ -744,7 +744,7 @@ class HIFI(Provider):
|
||||
try:
|
||||
table.set_table_metadata(
|
||||
{
|
||||
"provider": "hifi",
|
||||
"plugin": "hifi",
|
||||
"view": "track",
|
||||
"album_id": album_id,
|
||||
"album_title": album_title,
|
||||
@@ -1376,7 +1376,7 @@ class HIFI(Provider):
|
||||
|
||||
return False, None
|
||||
|
||||
def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]:
|
||||
def _get_api_client_for_base(self, base_url: str) -> Optional[Tidal]:
|
||||
base = base_url.rstrip("/")
|
||||
for client in self.api_clients:
|
||||
if getattr(client, "base_url", "").rstrip("/") == base:
|
||||
@@ -1935,7 +1935,7 @@ class HIFI(Provider):
|
||||
table = Table(f"HIFI Albums: {artist_name}")._perseverance(False)
|
||||
table.set_table("hifi.album")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "hifi", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
|
||||
table.set_table_metadata({"plugin": "hifi", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1997,7 +1997,7 @@ class HIFI(Provider):
|
||||
try:
|
||||
table.set_table_metadata(
|
||||
{
|
||||
"provider": "hifi",
|
||||
"plugin": "hifi",
|
||||
"view": "track",
|
||||
"album_id": album_id,
|
||||
"album_title": album_title,
|
||||
@@ -2061,7 +2061,7 @@ class HIFI(Provider):
|
||||
table = Table("HIFI Track")._perseverance(True)
|
||||
table.set_table("hifi.track")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "hifi", "view": "track", "resolved_manifest": True})
|
||||
table.set_table_metadata({"plugin": "hifi", "view": "track", "resolved_manifest": True})
|
||||
except Exception:
|
||||
pass
|
||||
results_payload: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -10,11 +10,11 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
from API.HTTP import _download_direct_file
|
||||
from API.HTTP import download_direct_file
|
||||
from PluginCore.base import Provider, SearchResult
|
||||
from SYS.utils import sanitize_filename, unique_path
|
||||
from SYS.logger import log
|
||||
from SYS.config import get_provider_block
|
||||
from SYS.config import get_plugin_block
|
||||
|
||||
# Helper for download-file: render selectable formats for a details URL.
|
||||
def maybe_show_formats_table(
|
||||
@@ -177,26 +177,20 @@ def _ia() -> Any:
|
||||
def _pick_provider_config(config: Any) -> Dict[str, Any]:
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
provider = config.get("provider")
|
||||
if not isinstance(provider, dict):
|
||||
return {}
|
||||
entry = provider.get("internetarchive")
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
return {}
|
||||
return get_plugin_block(config, "internetarchive")
|
||||
|
||||
|
||||
def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Resolve Archive.org credentials.
|
||||
|
||||
Preference order:
|
||||
1) provider.internetarchive (email/username + password)
|
||||
2) provider.openlibrary (email + password)
|
||||
1) plugin.internetarchive (email/username + password)
|
||||
2) plugin.openlibrary (email + password)
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return None, None
|
||||
|
||||
ia_block = get_provider_block(config, "internetarchive")
|
||||
ia_block = get_plugin_block(config, "internetarchive")
|
||||
if isinstance(ia_block, dict):
|
||||
email = (
|
||||
ia_block.get("email")
|
||||
@@ -209,7 +203,7 @@ def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]
|
||||
if email_text and password_text:
|
||||
return email_text, password_text
|
||||
|
||||
ol_block = get_provider_block(config, "openlibrary")
|
||||
ol_block = get_plugin_block(config, "openlibrary")
|
||||
if isinstance(ol_block, dict):
|
||||
email = ol_block.get("email")
|
||||
password = ol_block.get("password")
|
||||
@@ -744,7 +738,7 @@ class InternetArchive(Provider):
|
||||
if tags:
|
||||
normalized["tags"] = tags
|
||||
normalized["media_kind"] = "book"
|
||||
normalized["provider_action"] = "borrow"
|
||||
normalized["plugin_action"] = "borrow"
|
||||
return normalized
|
||||
|
||||
def validate(self) -> bool:
|
||||
@@ -993,7 +987,7 @@ class InternetArchive(Provider):
|
||||
pipeline_progress = None
|
||||
|
||||
try:
|
||||
direct_result = _download_direct_file(
|
||||
direct_result = download_direct_file(
|
||||
raw_path,
|
||||
output_dir,
|
||||
quiet=quiet_mode,
|
||||
|
||||
@@ -279,7 +279,7 @@ class Local(Provider):
|
||||
return {
|
||||
"hash": hash_value or "unknown",
|
||||
"store": "local",
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"path": str(target_path),
|
||||
"tag": tags,
|
||||
"title": title or target_path.name,
|
||||
|
||||
+13
-17
@@ -327,10 +327,10 @@ class Matrix(TablePluginMixin, Provider):
|
||||
self._init_reason: Optional[str] = None
|
||||
|
||||
matrix_conf = (
|
||||
self.config.get("provider",
|
||||
{}).get("matrix",
|
||||
{}) if isinstance(self.config,
|
||||
dict) else {}
|
||||
self.config.get("plugin",
|
||||
{}).get("matrix",
|
||||
{}) if isinstance(self.config,
|
||||
dict) else {}
|
||||
)
|
||||
homeserver = matrix_conf.get("homeserver")
|
||||
access_token = matrix_conf.get("access_token")
|
||||
@@ -362,16 +362,16 @@ class Matrix(TablePluginMixin, Provider):
|
||||
return False
|
||||
if self._init_ok is False:
|
||||
return False
|
||||
matrix_conf = self.config.get("provider",
|
||||
{}).get("matrix",
|
||||
{})
|
||||
matrix_conf = self.config.get("plugin",
|
||||
{}).get("matrix",
|
||||
{})
|
||||
return bool(
|
||||
matrix_conf.get("homeserver")
|
||||
and matrix_conf.get("access_token")
|
||||
)
|
||||
|
||||
def status_summary(self) -> Dict[str, Any]:
|
||||
matrix_conf = self.config.get("provider", {}).get("matrix", {}) if isinstance(self.config, dict) else {}
|
||||
matrix_conf = self.config.get("plugin", {}).get("matrix", {}) if isinstance(self.config, dict) else {}
|
||||
homeserver = str(matrix_conf.get("homeserver") or "").strip()
|
||||
room_id = str(matrix_conf.get("room_id") or "").strip()
|
||||
detail = homeserver
|
||||
@@ -439,7 +439,7 @@ class Matrix(TablePluginMixin, Provider):
|
||||
full_metadata={
|
||||
"room_id": room_id,
|
||||
"room_name": room_name,
|
||||
"provider": "matrix",
|
||||
"plugin": "matrix",
|
||||
# Selection metadata for table system and @N expansion
|
||||
"_selection_args": ["-room-id", room_id],
|
||||
},
|
||||
@@ -450,9 +450,9 @@ class Matrix(TablePluginMixin, Provider):
|
||||
|
||||
|
||||
def _get_homeserver_and_token(self) -> Tuple[str, str]:
|
||||
matrix_conf = self.config.get("provider",
|
||||
{}).get("matrix",
|
||||
{})
|
||||
matrix_conf = self.config.get("plugin",
|
||||
{}).get("matrix",
|
||||
{})
|
||||
homeserver = matrix_conf.get("homeserver")
|
||||
access_token = matrix_conf.get("access_token")
|
||||
if not homeserver:
|
||||
@@ -681,7 +681,7 @@ class Matrix(TablePluginMixin, Provider):
|
||||
)
|
||||
|
||||
def upload(self, file_path: str, **kwargs: Any) -> str:
|
||||
matrix_conf = self.config.get("provider",
|
||||
matrix_conf = self.config.get("plugin",
|
||||
{}).get("matrix",
|
||||
{})
|
||||
room_id = matrix_conf.get("room_id")
|
||||
@@ -877,7 +877,3 @@ try:
|
||||
except Exception:
|
||||
# best-effort registration
|
||||
pass
|
||||
|
||||
|
||||
# Backward-compatible alias: tests and callers may import `plugins.matrix.cmdnat`.
|
||||
from . import commands as cmdnat # noqa: E402
|
||||
|
||||
+35
-42
@@ -58,8 +58,30 @@ def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]:
|
||||
return extract_arg_value(args, flags={"-set-value"})
|
||||
|
||||
|
||||
def _get_matrix_config_block(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
plugins = config.get("plugin")
|
||||
if not isinstance(plugins, dict):
|
||||
return {}
|
||||
matrix_cfg = plugins.get("matrix")
|
||||
return matrix_cfg if isinstance(matrix_cfg, dict) else {}
|
||||
|
||||
|
||||
def _ensure_matrix_config_block(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
plugins = config.setdefault("plugin", {})
|
||||
if not isinstance(plugins, dict):
|
||||
plugins = {}
|
||||
config["plugin"] = plugins
|
||||
matrix_cfg = plugins.setdefault("matrix", {})
|
||||
if not isinstance(matrix_cfg, dict):
|
||||
matrix_cfg = {}
|
||||
plugins["matrix"] = matrix_cfg
|
||||
return matrix_cfg
|
||||
|
||||
|
||||
def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
|
||||
"""Update the Matrix provider block in the shared config.
|
||||
"""Update the Matrix plugin block in the shared config.
|
||||
|
||||
This method writes to the unified config store so changes persist between
|
||||
sessions.
|
||||
@@ -71,29 +93,13 @@ def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
|
||||
value_str = str(value)
|
||||
|
||||
current_cfg = load_config() or {}
|
||||
providers = current_cfg.setdefault("provider", {})
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
current_cfg["provider"] = providers
|
||||
|
||||
matrix_cfg = providers.setdefault("matrix", {})
|
||||
if not isinstance(matrix_cfg, dict):
|
||||
matrix_cfg = {}
|
||||
providers["matrix"] = matrix_cfg
|
||||
|
||||
matrix_cfg = _ensure_matrix_config_block(current_cfg)
|
||||
matrix_cfg[key] = value_str
|
||||
|
||||
save_config(current_cfg)
|
||||
|
||||
# Keep the supplied config dict in sync for the running CLI
|
||||
target_providers = config.setdefault("provider", {})
|
||||
if not isinstance(target_providers, dict):
|
||||
target_providers = {}
|
||||
config["provider"] = target_providers
|
||||
target_matrix = target_providers.setdefault("matrix", {})
|
||||
if not isinstance(target_matrix, dict):
|
||||
target_matrix = {}
|
||||
target_providers["matrix"] = target_matrix
|
||||
target_matrix = _ensure_matrix_config_block(config)
|
||||
target_matrix[key] = value_str
|
||||
return True
|
||||
except Exception as exc:
|
||||
@@ -103,13 +109,8 @@ def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
|
||||
|
||||
def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]:
|
||||
try:
|
||||
if not isinstance(config, dict):
|
||||
return []
|
||||
providers = config.get("provider")
|
||||
if not isinstance(providers, dict):
|
||||
return []
|
||||
matrix_conf = providers.get("matrix")
|
||||
if not isinstance(matrix_conf, dict):
|
||||
matrix_conf = _get_matrix_config_block(config)
|
||||
if not matrix_conf:
|
||||
return []
|
||||
raw = None
|
||||
# Support a few common spellings; `room` is the documented key.
|
||||
@@ -138,16 +139,11 @@ def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]:
|
||||
def _get_matrix_size_limit_bytes(config: Dict[str, Any]) -> Optional[int]:
|
||||
"""Return max allowed per-file size in bytes for Matrix uploads.
|
||||
|
||||
Config: [provider=Matrix] size_limit=50 # MB
|
||||
Config: [plugin=matrix] size_limit=50 # MB
|
||||
"""
|
||||
try:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
providers = config.get("provider")
|
||||
if not isinstance(providers, dict):
|
||||
return None
|
||||
matrix_conf = providers.get("matrix")
|
||||
if not isinstance(matrix_conf, dict):
|
||||
matrix_conf = _get_matrix_config_block(config)
|
||||
if not matrix_conf:
|
||||
return None
|
||||
|
||||
raw = None
|
||||
@@ -236,7 +232,7 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str
|
||||
conf_ids = _parse_config_room_filter_ids(config)
|
||||
if conf_ids:
|
||||
# Attempt to fetch names for the configured IDs
|
||||
block = config.get("provider", {}).get("matrix", {})
|
||||
block = _get_matrix_config_block(config)
|
||||
if block and block.get("homeserver") and block.get("access_token"):
|
||||
try:
|
||||
m = _get_matrix_provider(config)
|
||||
@@ -252,7 +248,7 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str
|
||||
pass
|
||||
|
||||
# Last resort: attempt to ask the server for matching rooms (if possible)
|
||||
block = config.get("provider", {}).get("matrix", {})
|
||||
block = _get_matrix_config_block(config)
|
||||
if block and block.get("homeserver") and block.get("access_token"):
|
||||
try:
|
||||
m = _get_matrix_provider(config)
|
||||
@@ -631,7 +627,7 @@ def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]:
|
||||
url = _resolve_plugin_url(url, config)
|
||||
|
||||
try:
|
||||
from API.HTTP import _download_direct_file
|
||||
from API.HTTP import download_direct_file
|
||||
|
||||
base_tmp = None
|
||||
if isinstance(config, dict):
|
||||
@@ -642,7 +638,7 @@ def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]:
|
||||
)
|
||||
output_dir = output_dir / "matrix"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = _download_direct_file(url, output_dir, quiet=True)
|
||||
result = download_direct_file(url, output_dir, quiet=True)
|
||||
if (result and hasattr(result,
|
||||
"path") and isinstance(result.path,
|
||||
Path) and result.path.exists()):
|
||||
@@ -691,10 +687,7 @@ def _show_settings_table(config: Dict[str, Any]) -> int:
|
||||
|
||||
matrix_conf = {}
|
||||
try:
|
||||
if isinstance(config, dict):
|
||||
providers = config.get("provider")
|
||||
if isinstance(providers, dict):
|
||||
matrix_conf = providers.get("matrix") or {}
|
||||
matrix_conf = _get_matrix_config_block(config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+11
-11
@@ -252,7 +252,7 @@ class ITunesMetadataPlugin(MetadataPlugin):
|
||||
"album": r.get("collectionName"),
|
||||
"year": str(r.get("releaseDate",
|
||||
""))[:4],
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"raw": r,
|
||||
}
|
||||
items.append(item)
|
||||
@@ -338,7 +338,7 @@ class OpenLibraryMetadataPlugin(MetadataPlugin):
|
||||
"artist": ", ".join(authors) if authors else "",
|
||||
"album": publisher,
|
||||
"year": str(doc.get("first_publish_year") or ""),
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"authors": authors,
|
||||
"publisher": publisher,
|
||||
"identifiers": {
|
||||
@@ -460,7 +460,7 @@ class GoogleBooksMetadataPlugin(MetadataPlugin):
|
||||
"artist": ", ".join(authors) if authors else "",
|
||||
"album": publisher,
|
||||
"year": year,
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"authors": authors,
|
||||
"publisher": publisher,
|
||||
"identifiers": identifiers,
|
||||
@@ -643,7 +643,7 @@ class ISBNsearchMetadataPlugin(MetadataPlugin):
|
||||
"artist": ", ".join(authors) if authors else "",
|
||||
"album": publisher or "",
|
||||
"year": year or "",
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"authors": authors,
|
||||
"publisher": publisher or "",
|
||||
"language": language or "",
|
||||
@@ -787,7 +787,7 @@ class MusicBrainzMetadataPlugin(MetadataPlugin):
|
||||
"artist": artist,
|
||||
"album": album,
|
||||
"year": year,
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"mbid": mbid,
|
||||
"raw": rec,
|
||||
}
|
||||
@@ -871,7 +871,7 @@ class ImdbMetadataPlugin(MetadataPlugin):
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"year": str(year or ""),
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"imdb_id": imdb_id,
|
||||
"raw": data,
|
||||
}
|
||||
@@ -908,7 +908,7 @@ class ImdbMetadataPlugin(MetadataPlugin):
|
||||
"artist": "",
|
||||
"album": kind,
|
||||
"year": year,
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"imdb_id": imdb_id,
|
||||
"kind": kind,
|
||||
"rating": rating,
|
||||
@@ -1032,7 +1032,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
|
||||
"artist": str(artist or ""),
|
||||
"album": str(album or ""),
|
||||
"year": str(year or ""),
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"url": url,
|
||||
"raw": info,
|
||||
}
|
||||
@@ -1214,7 +1214,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
|
||||
return None
|
||||
|
||||
try:
|
||||
from tool.ytdlp import is_url_supported_by_ytdlp
|
||||
from plugins.ytdlp.tooling import is_url_supported_by_ytdlp
|
||||
|
||||
for text in candidates:
|
||||
try:
|
||||
@@ -1322,7 +1322,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
|
||||
"artist": str(info.get("artist") or info.get("uploader") or info.get("channel") or ""),
|
||||
"album": str(info.get("album") or info.get("playlist_title") or ""),
|
||||
"year": str((str(info.get("release_date") or "") or str(info.get("upload_date") or ""))[:4]),
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"url": str(url or "").strip(),
|
||||
"raw": info,
|
||||
}
|
||||
@@ -1927,7 +1927,7 @@ class TidalMetadataPlugin(MetadataPlugin):
|
||||
"year": year,
|
||||
"lyrics": lyrics,
|
||||
"tags": tags,
|
||||
"provider": self.name,
|
||||
"plugin": self.name,
|
||||
"path": getattr(result, "path", ""),
|
||||
"track_id": track_id,
|
||||
"full_metadata": metadata,
|
||||
|
||||
@@ -2878,7 +2878,7 @@ local function _start_screenshot_store_save(store, out_path, tags)
|
||||
screenshot_url = ''
|
||||
end
|
||||
local cmd = 'file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store)
|
||||
.. ' -path ' .. quote_pipeline_arg(out_path)
|
||||
.. ' ' .. quote_pipeline_arg(out_path)
|
||||
if screenshot_url ~= '' then
|
||||
cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url)
|
||||
end
|
||||
@@ -6367,7 +6367,7 @@ local function _start_trim_with_range(range)
|
||||
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
|
||||
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
|
||||
' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(selected_store) ..
|
||||
' -path ' .. quote_pipeline_arg(output_path) ..
|
||||
' ' .. quote_pipeline_arg(output_path) ..
|
||||
' | add-relationship -store "' .. selected_store .. '"' ..
|
||||
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
|
||||
else
|
||||
@@ -6375,7 +6375,7 @@ local function _start_trim_with_range(range)
|
||||
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
|
||||
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
|
||||
' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store_hash.store) ..
|
||||
' -path ' .. quote_pipeline_arg(output_path) ..
|
||||
' ' .. quote_pipeline_arg(output_path) ..
|
||||
' | add-relationship -store "' .. store_hash.store .. '"' ..
|
||||
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
|
||||
end
|
||||
@@ -6386,7 +6386,7 @@ local function _start_trim_with_range(range)
|
||||
_lua_log('trim: building file -add command to selected_store=' .. selected_store)
|
||||
-- Don't add title if empty - the file path will be used as title by default
|
||||
pipeline_cmd = 'file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(selected_store) ..
|
||||
' -path ' .. quote_pipeline_arg(output_path)
|
||||
' ' .. quote_pipeline_arg(output_path)
|
||||
_lua_log('trim: pipeline_cmd=' .. pipeline_cmd)
|
||||
else
|
||||
mp.osd_message('Trim complete: ' .. output_path, 5)
|
||||
|
||||
@@ -71,7 +71,7 @@ from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
|
||||
from SYS.repl_queue import enqueue_repl_command, repl_state_is_alive # noqa: E402
|
||||
from SYS.utils import format_bytes # noqa: E402
|
||||
from PluginCore.registry import get_plugin, get_plugin_class # noqa: E402
|
||||
from tool.ytdlp import get_display_format_id, get_selection_format_id # noqa: E402
|
||||
from plugins.ytdlp.tooling import get_display_format_id, get_selection_format_id # noqa: E402
|
||||
|
||||
REQUEST_PROP = "user-data/medeia-pipeline-request"
|
||||
RESPONSE_PROP = "user-data/medeia-pipeline-response"
|
||||
|
||||
@@ -674,7 +674,7 @@ class OpenLibrary(Provider):
|
||||
if not isinstance(config, dict):
|
||||
return _DEFAULT_PREFERRED_LANGUAGE
|
||||
|
||||
entry = config.get("provider", {}).get("openlibrary", {})
|
||||
entry = config.get("plugin", {}).get("openlibrary", {})
|
||||
if not isinstance(entry, dict):
|
||||
return _DEFAULT_PREFERRED_LANGUAGE
|
||||
|
||||
@@ -1118,7 +1118,7 @@ class OpenLibrary(Provider):
|
||||
table = Table(f"OpenLibrary Editions: {title}")._perseverance(True)
|
||||
table.set_table("openlibrary.edition")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "openlibrary", "view": "borrowable_editions"})
|
||||
table.set_table_metadata({"plugin": "openlibrary", "view": "borrowable_editions"})
|
||||
except Exception:
|
||||
pass
|
||||
table.set_source_command("search-file", ["-plugin", "openlibrary"])
|
||||
@@ -1274,7 +1274,7 @@ class OpenLibrary(Provider):
|
||||
if not isinstance(config, dict):
|
||||
return None, None
|
||||
|
||||
entry = config.get("provider", {}).get("openlibrary", {})
|
||||
entry = config.get("plugin", {}).get("openlibrary", {})
|
||||
if isinstance(entry, dict):
|
||||
email = entry.get("email")
|
||||
password = entry.get("password")
|
||||
@@ -1287,11 +1287,11 @@ class OpenLibrary(Provider):
|
||||
|
||||
@classmethod
|
||||
def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int:
|
||||
"""Resolve Archive.org book-reader scale from provider config."""
|
||||
"""Resolve Archive.org book-reader scale from plugin config."""
|
||||
if not isinstance(config, dict):
|
||||
return _DEFAULT_ARCHIVE_SCALE
|
||||
|
||||
entry = config.get("provider", {}).get("openlibrary", {})
|
||||
entry = config.get("plugin", {}).get("openlibrary", {})
|
||||
if not isinstance(entry, dict):
|
||||
return _DEFAULT_ARCHIVE_SCALE
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Playwright support module under the plugin namespace.
|
||||
|
||||
This package provides shared browser automation defaults/helpers for cmdlets and
|
||||
plugins. It is intentionally lightweight at import time so plugin discovery can
|
||||
import `plugins.playwright` even when Playwright itself is not installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"PlaywrightTimeoutError",
|
||||
"PlaywrightTool",
|
||||
"PlaywrightDefaults",
|
||||
"PlaywrightDownloadResult",
|
||||
"config_schema",
|
||||
]
|
||||
|
||||
_MODULE_ATTRS = {
|
||||
"PlaywrightTimeoutError": ".runtime",
|
||||
"PlaywrightTool": ".runtime",
|
||||
"PlaywrightDefaults": ".runtime",
|
||||
"PlaywrightDownloadResult": ".runtime",
|
||||
"config_schema": ".runtime",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
submod = _MODULE_ATTRS.get(name)
|
||||
if submod is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
from importlib import import_module
|
||||
|
||||
mod = import_module(submod, package=__name__)
|
||||
obj = getattr(mod, name)
|
||||
globals()[name] = obj
|
||||
return obj
|
||||
@@ -13,8 +13,17 @@ from typing import Any, Dict, Iterator, Optional, Union
|
||||
from SYS.config import get_nested_config_value as _get_nested
|
||||
from SYS.logger import debug
|
||||
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import sync_playwright
|
||||
try:
|
||||
from playwright.sync_api import TimeoutError as _SyncPlaywrightTimeoutError
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception: # pragma: no cover - handled at runtime
|
||||
sync_playwright = None
|
||||
|
||||
class PlaywrightTimeoutError(RuntimeError):
|
||||
"""Fallback timeout type when Playwright is unavailable."""
|
||||
|
||||
else:
|
||||
PlaywrightTimeoutError = _SyncPlaywrightTimeoutError
|
||||
|
||||
# Re-export for consumers (e.g. cmdlets catching navigation timeouts)
|
||||
__all__ = [
|
||||
@@ -100,16 +109,16 @@ class PlaywrightTool:
|
||||
- user-agent/viewport defaults
|
||||
- ffmpeg path resolution (for video recording)
|
||||
|
||||
Config overrides (top-level keys):
|
||||
- playwright.browser="chromium"
|
||||
- playwright.headless=true
|
||||
- playwright.user_agent="..."
|
||||
- playwright.viewport_width=1280
|
||||
- playwright.viewport_height=1200
|
||||
- playwright.navigation_timeout_ms=90000
|
||||
- playwright.ignore_https_errors=true
|
||||
- playwright.screenshot_quality=8
|
||||
- playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
|
||||
Config overrides (plugin.playwright keys):
|
||||
- plugin.playwright.browser="chromium"
|
||||
- plugin.playwright.headless=true
|
||||
- plugin.playwright.user_agent="..."
|
||||
- plugin.playwright.viewport_width=1280
|
||||
- plugin.playwright.viewport_height=1200
|
||||
- plugin.playwright.navigation_timeout_ms=90000
|
||||
- plugin.playwright.ignore_https_errors=true
|
||||
- plugin.playwright.screenshot_quality=8
|
||||
- plugin.playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
|
||||
|
||||
FFmpeg resolution (in order):
|
||||
1. Config key: playwright.ffmpeg_path
|
||||
@@ -127,22 +136,12 @@ class PlaywrightTool:
|
||||
def _load_defaults(self) -> PlaywrightDefaults:
|
||||
cfg = self._config
|
||||
defaults = PlaywrightDefaults()
|
||||
tool_block = _get_nested(cfg, "tool", "playwright")
|
||||
if not isinstance(tool_block, dict):
|
||||
tool_block = {}
|
||||
pw_block = cfg.get("playwright") if isinstance(cfg.get("playwright"),
|
||||
dict) else {}
|
||||
pw_block = _get_nested(cfg, "plugin", "playwright")
|
||||
if not isinstance(pw_block, dict):
|
||||
pw_block = {}
|
||||
|
||||
def _get(name: str, fallback: Any) -> Any:
|
||||
val = tool_block.get(name)
|
||||
if val is None:
|
||||
val = pw_block.get(name)
|
||||
if val is None:
|
||||
val = cfg.get(f"playwright_{name}")
|
||||
if val is None:
|
||||
val = _get_nested(cfg, "playwright", name)
|
||||
val = pw_block.get(name)
|
||||
return fallback if val is None else val
|
||||
|
||||
browser = str(_get("browser", defaults.browser)).strip().lower() or "chromium"
|
||||
@@ -211,7 +210,7 @@ class PlaywrightTool:
|
||||
if not ffmpeg_path:
|
||||
# Try to find bundled ffmpeg in the project (Windows-only, in MPV/ffmpeg/bin)
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundled_ffmpeg = repo_root / "MPV" / "ffmpeg" / "bin"
|
||||
if bundled_ffmpeg.exists():
|
||||
ffmpeg_exe = bundled_ffmpeg / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
|
||||
@@ -11,11 +11,11 @@ from SYS.utils import format_bytes
|
||||
|
||||
|
||||
def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]:
|
||||
provider = config.get("provider")
|
||||
if not isinstance(provider, dict):
|
||||
plugin_cfg = config.get("plugin")
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
return "", ""
|
||||
|
||||
entry = provider.get("podcastindex")
|
||||
entry = plugin_cfg.get("podcastindex")
|
||||
if not isinstance(entry, dict):
|
||||
return "", ""
|
||||
|
||||
@@ -290,7 +290,7 @@ class PodcastIndex(Provider):
|
||||
pass
|
||||
|
||||
try:
|
||||
from API.HTTP import _download_direct_file
|
||||
from API.HTTP import download_direct_file
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
@@ -308,7 +308,7 @@ class PodcastIndex(Provider):
|
||||
title_hint = str(item.get("title") or md.get("title") or "episode").strip() or "episode"
|
||||
|
||||
try:
|
||||
result_obj = _download_direct_file(
|
||||
result_obj = download_direct_file(
|
||||
enc_url,
|
||||
Path(output_dir),
|
||||
quiet=False,
|
||||
@@ -357,12 +357,12 @@ class PodcastIndex(Provider):
|
||||
"path": str(local_path),
|
||||
"hash": sha256,
|
||||
"title": title_hint,
|
||||
"action": "provider:podcastindex.selector",
|
||||
"action": "plugin:podcastindex.selector",
|
||||
"download_mode": "file",
|
||||
"store": "local",
|
||||
"media_kind": "audio",
|
||||
"tag": tags,
|
||||
"provider": "podcastindex",
|
||||
"plugin": "podcastindex",
|
||||
"url": enc_url,
|
||||
}
|
||||
if isinstance(md, dict) and md:
|
||||
|
||||
@@ -390,7 +390,7 @@ class SCP(Provider):
|
||||
table.set_table("scp")
|
||||
try:
|
||||
table.set_table_metadata({
|
||||
"provider": "scp",
|
||||
"plugin": "scp",
|
||||
"instance": instance_name or None,
|
||||
"host": settings.get("host"),
|
||||
"path": target_path,
|
||||
@@ -958,7 +958,7 @@ class SCP(Provider):
|
||||
parent = posixpath.dirname(scp_path.rstrip("/")) or "/"
|
||||
instance_name = str(settings.get("instance") or "").strip()
|
||||
metadata = {
|
||||
"provider": "scp",
|
||||
"plugin": "scp",
|
||||
"instance": instance_name or None,
|
||||
"host": settings.get("host"),
|
||||
"scp_path": scp_path,
|
||||
|
||||
@@ -690,7 +690,7 @@ class Soulseek(Provider):
|
||||
"album": item["album"],
|
||||
"track_num": item["track_num"],
|
||||
"ext": item["ext"],
|
||||
"provider": "soulseek"
|
||||
"plugin": "soulseek"
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -194,7 +194,7 @@ class Telegram(Provider):
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(config)
|
||||
telegram_conf = (
|
||||
self.config.get("provider",
|
||||
self.config.get("plugin",
|
||||
{}).get("telegram",
|
||||
{}) if isinstance(self.config,
|
||||
dict) else {}
|
||||
@@ -1280,7 +1280,7 @@ class Telegram(Provider):
|
||||
|
||||
info: Dict[str,
|
||||
Any] = {
|
||||
"provider": "telegram",
|
||||
"plugin": "telegram",
|
||||
"source_url": url,
|
||||
"chat": {
|
||||
"key": chat,
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from plugins.tidal.api import (
|
||||
Tidal as TidalApiClient,
|
||||
Tidal,
|
||||
build_track_tags,
|
||||
coerce_duration_seconds,
|
||||
extract_artists,
|
||||
@@ -208,7 +208,7 @@ class Tidal(Provider):
|
||||
self.api_timeout = float(self.config.get("timeout", 10.0))
|
||||
except Exception:
|
||||
self.api_timeout = 10.0
|
||||
self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
|
||||
self.api_clients = [Tidal(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
|
||||
|
||||
def resolve_playback_path(self, item: Any, **_kwargs: Any) -> Optional[str]:
|
||||
return resolve_tidal_manifest_path(item)
|
||||
@@ -960,7 +960,7 @@ class Tidal(Provider):
|
||||
try:
|
||||
table.set_table_metadata(
|
||||
{
|
||||
"provider": "tidal",
|
||||
"plugin": "tidal",
|
||||
"view": "track",
|
||||
"album_id": album_id,
|
||||
"album_title": album_title,
|
||||
@@ -1670,7 +1670,7 @@ class Tidal(Provider):
|
||||
|
||||
return downloaded_count
|
||||
|
||||
def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]:
|
||||
def _get_api_client_for_base(self, base_url: str) -> Optional[Tidal]:
|
||||
base = base_url.rstrip("/")
|
||||
for client in self.api_clients:
|
||||
if getattr(client, "base_url", "").rstrip("/") == base:
|
||||
@@ -2206,7 +2206,7 @@ class Tidal(Provider):
|
||||
table = Table(f"Tidal Albums: {artist_name}")._perseverance(False)
|
||||
table.set_table("tidal.album")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "tidal", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
|
||||
table.set_table_metadata({"plugin": "tidal", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2268,7 +2268,7 @@ class Tidal(Provider):
|
||||
try:
|
||||
table.set_table_metadata(
|
||||
{
|
||||
"provider": "tidal",
|
||||
"plugin": "tidal",
|
||||
"view": "track",
|
||||
"album_id": album_id,
|
||||
"album_title": album_title,
|
||||
@@ -2338,7 +2338,7 @@ class Tidal(Provider):
|
||||
table = Table("Tidal Track")._perseverance(True)
|
||||
table.set_table("tidal.track")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "tidal", "view": "track", "resolved_manifest": True})
|
||||
table.set_table_metadata({"plugin": "tidal", "view": "track", "resolved_manifest": True})
|
||||
except Exception:
|
||||
pass
|
||||
results_payload: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -320,8 +320,3 @@ class Tidal(API):
|
||||
border_style="cyan",
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
# Legacy alias for TidalApiClient
|
||||
TidalApiClient = Tidal
|
||||
HifiApiClient = Tidal
|
||||
|
||||
@@ -20,7 +20,7 @@ from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments
|
||||
from PluginCore.inline_utils import resolve_filter
|
||||
from SYS.logger import debug, debug_panel
|
||||
from SYS.plugin_helpers import TablePluginMixin
|
||||
from tool.playwright import PlaywrightTool
|
||||
from plugins.playwright import PlaywrightTool
|
||||
|
||||
|
||||
class Vimm(TablePluginMixin, Provider):
|
||||
|
||||
@@ -23,12 +23,13 @@ from SYS.result_table import Table
|
||||
from SYS.rich_display import stderr_console as get_stderr_console
|
||||
from SYS import pipeline as pipeline_context
|
||||
from SYS.utils import sha256_file
|
||||
from tool.ytdlp import (
|
||||
from .tooling import (
|
||||
YtDlpTool,
|
||||
_best_subtitle_sidecar,
|
||||
_SUBTITLE_EXTS,
|
||||
_download_with_timeout,
|
||||
_format_chapters_note,
|
||||
config_schema as _ytdlp_config_schema,
|
||||
_read_text_file,
|
||||
collapse_picker_formats,
|
||||
format_for_table_selection,
|
||||
@@ -508,6 +509,10 @@ class ytdlp(TablePluginMixin, Provider):
|
||||
PLUGIN_ALIASES = ("youtube",)
|
||||
SEARCH_QUERY_KEYS = ("search", "q")
|
||||
|
||||
@staticmethod
|
||||
def config_schema() -> List[Dict[str, Any]]:
|
||||
return _ytdlp_config_schema()
|
||||
|
||||
@classmethod
|
||||
def url_patterns(cls) -> Tuple[str, ...]:
|
||||
try:
|
||||
|
||||
@@ -997,60 +997,43 @@ class YtDlpTool:
|
||||
# default string value. Use an instance for fallback defaults.
|
||||
_fallback_defaults = YtDlpDefaults()
|
||||
|
||||
tool_block = _get_nested(cfg, "tool", "ytdlp")
|
||||
if not isinstance(tool_block, dict):
|
||||
tool_block = {}
|
||||
|
||||
ytdlp_block = cfg.get("ytdlp") if isinstance(cfg.get("ytdlp"),
|
||||
dict) else {}
|
||||
ytdlp_block = _get_nested(cfg, "plugin", "ytdlp")
|
||||
if not isinstance(ytdlp_block, dict):
|
||||
ytdlp_block = {}
|
||||
|
||||
# Accept both nested and flat styles.
|
||||
video_format = (
|
||||
tool_block.get("video_format") or tool_block.get("format")
|
||||
or ytdlp_block.get("video_format") or ytdlp_block.get("video")
|
||||
or ytdlp_block.get("format_video") or cfg.get("ytdlp_video_format")
|
||||
ytdlp_block.get("video_format") or ytdlp_block.get("video")
|
||||
or ytdlp_block.get("format_video")
|
||||
)
|
||||
audio_format = (
|
||||
tool_block.get("audio_format") or ytdlp_block.get("audio_format")
|
||||
or ytdlp_block.get("audio") or ytdlp_block.get("format_audio")
|
||||
or cfg.get("ytdlp_audio_format")
|
||||
ytdlp_block.get("audio_format") or ytdlp_block.get("audio")
|
||||
or ytdlp_block.get("format_audio")
|
||||
)
|
||||
|
||||
# Also accept dotted keys written as nested dicts: ytdlp.format.video, ytdlp.format.audio
|
||||
nested_video = _get_nested(cfg, "ytdlp", "format", "video")
|
||||
nested_audio = _get_nested(cfg, "ytdlp", "format", "audio")
|
||||
nested_video = _get_nested(cfg, "plugin", "ytdlp", "format", "video")
|
||||
nested_audio = _get_nested(cfg, "plugin", "ytdlp", "format", "audio")
|
||||
|
||||
fmt_sort_val = (
|
||||
tool_block.get("format_sort") or ytdlp_block.get("format_sort")
|
||||
or ytdlp_block.get("formatSort") or cfg.get("ytdlp_format_sort")
|
||||
ytdlp_block.get("format_sort")
|
||||
or ytdlp_block.get("formatSort")
|
||||
or _get_nested(cfg,
|
||||
"plugin",
|
||||
"ytdlp",
|
||||
"format",
|
||||
"sort")
|
||||
)
|
||||
fmt_sort = _parse_csv_list(fmt_sort_val)
|
||||
|
||||
# Cookie source preference: allow forcing a browser DB or 'auto'/'none'
|
||||
cookies_pref = (
|
||||
tool_block.get("cookies_from_browser")
|
||||
or tool_block.get("cookiesfrombrowser")
|
||||
or ytdlp_block.get("cookies_from_browser")
|
||||
ytdlp_block.get("cookies_from_browser")
|
||||
or ytdlp_block.get("cookiesfrombrowser")
|
||||
or cfg.get("ytdlp_cookies_from_browser")
|
||||
or _get_nested(cfg, "ytdlp", "cookies_from_browser")
|
||||
or _get_nested(cfg, "plugin", "ytdlp", "cookies_from_browser")
|
||||
)
|
||||
|
||||
# Unified format preference: prefer explicit 'format' key but accept legacy keys
|
||||
format_pref = (
|
||||
tool_block.get("format")
|
||||
or tool_block.get("video_format")
|
||||
or ytdlp_block.get("format")
|
||||
ytdlp_block.get("format")
|
||||
or ytdlp_block.get("video_format")
|
||||
or cfg.get("ytdlp_format")
|
||||
or cfg.get("ytdlp_video_format")
|
||||
or _get_nested(cfg, "ytdlp", "format")
|
||||
or _get_nested(cfg, "plugin", "ytdlp", "format")
|
||||
)
|
||||
|
||||
defaults = YtDlpDefaults(
|
||||
@@ -1121,7 +1104,7 @@ class YtDlpTool:
|
||||
)
|
||||
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundled_ffmpeg_dir = repo_root / "MPV" / "ffmpeg" / "bin"
|
||||
if bundled_ffmpeg_dir.exists():
|
||||
base_options.setdefault("ffmpeg_location", str(bundled_ffmpeg_dir))
|
||||
@@ -978,7 +978,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
p.add_argument(
|
||||
"--pull",
|
||||
action="store_true",
|
||||
help="Force a repository update before starting the client (legacy alias; startup update is enabled by default)",
|
||||
help="Force a repository update before starting the client",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-update",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Tool helpers.
|
||||
|
||||
This package contains wrappers around external tools (e.g. yt-dlp) so cmdlets can share
|
||||
common defaults (cookies, timeouts, format selectors) and users can override them via
|
||||
`config.conf`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Lazy-loaded to avoid pulling in yt_dlp, playwright, and their heavy transitive
|
||||
# dependencies (~1–2 s) at package import time. Each submodule is loaded only when
|
||||
# a name from it is first accessed through this package namespace.
|
||||
|
||||
__all__ = [
|
||||
"YtDlpTool",
|
||||
"YtDlpDefaults",
|
||||
"PlaywrightTool",
|
||||
"PlaywrightDefaults",
|
||||
"FlorenceVisionTool",
|
||||
"FlorenceVisionDefaults",
|
||||
]
|
||||
|
||||
_MODULE_ATTRS = {
|
||||
"YtDlpTool": ".ytdlp",
|
||||
"YtDlpDefaults": ".ytdlp",
|
||||
"PlaywrightTool": ".playwright",
|
||||
"PlaywrightDefaults": ".playwright",
|
||||
"FlorenceVisionTool": ".florencevision",
|
||||
"FlorenceVisionDefaults": ".florencevision",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
submod = _MODULE_ATTRS.get(name)
|
||||
if submod is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
from importlib import import_module
|
||||
mod = import_module(submod, package=__name__)
|
||||
obj = getattr(mod, name)
|
||||
# Cache on this module so subsequent accesses bypass __getattr__.
|
||||
globals()[name] = obj
|
||||
return obj
|
||||
|
||||
Reference in New Issue
Block a user