From b75faa49a24422a838aedd8e49789573157cf638 Mon Sep 17 00:00:00 2001 From: nose Date: Sat, 20 Dec 2025 02:12:45 -0800 Subject: [PATCH] dfdf --- CLI.py | 4871 ++++++++++++++------------------- MPV/LUA/main.lua | 6 +- MPV/pipeline_helper.py | 29 +- Provider/soulseek.py | 54 +- SYS/worker_manager.py | 2 +- TUI/pipeline_runner.py | 12 +- cmdlet/_shared.py | 71 +- cmdlet/add_file.py | 57 +- cmdlet/add_note.py | 17 +- cmdlet/add_relationship.py | 25 +- cmdlet/add_tag.py | 30 +- cmdlet/add_url.py | 11 +- cmdlet/delete_file.py | 14 +- cmdlet/delete_note.py | 17 +- cmdlet/delete_relationship.py | 23 +- cmdlet/delete_tag.py | 220 +- cmdlet/delete_url.py | 11 +- cmdlet/download_media.py | 443 ++- cmdlet/get_file.py | 13 +- cmdlet/get_metadata.py | 15 +- cmdlet/get_note.py | 17 +- cmdlet/get_relationship.py | 27 +- cmdlet/get_tag.py | 26 +- cmdlet/get_url.py | 11 +- cmdlet/search_store.py | 58 +- medeia_entry.py | 4 +- result_table.py | 128 +- 27 files changed, 2883 insertions(+), 3329 deletions(-) diff --git a/CLI.py b/CLI.py index 2360f0d..d10678d 100644 --- a/CLI.py +++ b/CLI.py @@ -1,105 +1,138 @@ from __future__ import annotations -"""CLI REPL for Medeia-Macina with autocomplete support.""" +"""Medeia-Macina CLI. -import sys +This module intentionally uses a class-based architecture: +- no legacy procedural entrypoints +- no compatibility shims +- all REPL/pipeline/cmdlet execution state lives on objects +""" + +import atexit +import io import json import re -import io -import uuid -import atexit -from copy import deepcopy -from importlib import import_module -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Set, TextIO, TYPE_CHECKING, cast -import time +import shlex +import sys import threading +import time +import uuid +from copy import deepcopy +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, TextIO, cast -from SYS.logger import debug +import typer +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.document import Document +from prompt_toolkit.lexers import Lexer +from prompt_toolkit.styles import Style -try: - import typer -except ImportError: - typer = None +from SYS.background_notifier import ensure_background_notifier +from SYS.logger import debug, set_debug +from SYS.worker_manager import WorkerManager -try: - from result_table import ResultTable, format_result - RESULT_TABLE_AVAILABLE = True -except ImportError: - RESULT_TABLE_AVAILABLE = False - ResultTable = None # type: ignore - format_result = None # type: ignore - -try: - from prompt_toolkit import PromptSession - from prompt_toolkit.completion import Completer, Completion - from prompt_toolkit.document import Document - from prompt_toolkit.lexers import Lexer - from prompt_toolkit.styles import Style - PROMPT_TOOLKIT_AVAILABLE = True -except ImportError: # pragma: no cover - optional dependency - PromptSession = None # type: ignore - Completer = None # type: ignore - Completion = None # type: ignore - Document = None # type: ignore - Lexer = None # type: ignore - Style = None # type: ignore - PROMPT_TOOLKIT_AVAILABLE = False - - -try: - from SYS.worker_manager import WorkerManager -except ImportError: # pragma: no cover - optional dependency - WorkerManager = None # type: ignore - -try: - from SYS.background_notifier import ensure_background_notifier -except ImportError: # pragma: no cover - optional dependency - ensure_background_notifier = lambda *_, **__: None # type: ignore - -if TYPE_CHECKING: # pragma: no cover - typing helper - from SYS.worker_manager import WorkerManager as WorkerManagerType -else: - WorkerManagerType = Any - -# Global toolbar updater callback for prompt_toolkit integration -_TOOLBAR_UPDATER: Optional[Callable[[str], None]] = None -from typing import Callable - - -from config import get_local_storage_path, load_config from cmdlet_catalog import ( - import_cmd_module as _catalog_import_cmd_module, - list_cmdlet_metadata as _catalog_list_cmdlet_metadata, - list_cmdlet_names as _catalog_list_cmdlet_names, - get_cmdlet_arg_flags as _catalog_get_cmdlet_arg_flags, - get_cmdlet_arg_choices as _catalog_get_cmdlet_arg_choices, - get_cmdlet_metadata as _catalog_get_cmdlet_metadata, + ensure_registry_loaded, + get_cmdlet_arg_choices, + get_cmdlet_arg_flags, + get_cmdlet_metadata, + import_cmd_module, + list_cmdlet_metadata, + list_cmdlet_names, ) +from config import get_local_storage_path, load_config +from result_table import ResultTable -class _WorkerOutputMirror(io.TextIOBase): +class SelectionSyntax: + """Parses @ selection syntax into 1-based indices.""" + + _RANGE_RE = re.compile(r"^[0-9\-]+$") + + @staticmethod + def parse(token: str) -> Optional[Set[int]]: + """Return 1-based indices or None when not a concrete selection. + + Concrete selections: + - @2 + - @2-5 + - @{1,3,5} + - @2,5,7-9 + + Special (non-concrete) selectors return None: + - @* (select all) + - @.. (history prev) + - @,, (history next) + """ + + if not token or not token.startswith("@"): + return None + + selector = token[1:].strip() + if selector in (".", ",", "*"): + return None + + if selector.startswith("{") and selector.endswith("}"): + selector = selector[1:-1].strip() + + indices: Set[int] = set() + for part in selector.split(","): + part = part.strip() + if not part: + continue + + if "-" in part: + pieces = part.split("-", 1) + if len(pieces) != 2: + return None + start_str = pieces[0].strip() + end_str = pieces[1].strip() + if not start_str or not end_str: + return None + try: + start = int(start_str) + end = int(end_str) + except ValueError: + return None + if start <= 0 or end <= 0 or start > end: + return None + indices.update(range(start, end + 1)) + continue + + try: + value = int(part) + except ValueError: + return None + if value <= 0: + return None + indices.add(value) + + return indices if indices else None + + +class WorkerOutputMirror(io.TextIOBase): """Mirror stdout/stderr to worker manager while preserving console output.""" - def __init__(self, original: TextIO, manager: WorkerManagerType, worker_id: str, channel: str): + def __init__(self, original: TextIO, manager: WorkerManager, worker_id: str, channel: str): self._original = original self._manager = manager self._worker_id = worker_id self._channel = channel self._pending: str = "" - def write(self, data: str) -> int: + def write(self, data: str) -> int: # type: ignore[override] if not data: return 0 self._original.write(data) self._buffer_text(data) return len(data) - def flush(self) -> None: + def flush(self) -> None: # type: ignore[override] self._original.flush() self._flush_pending(force=True) - def isatty(self) -> bool: # pragma: no cover - passthrough + def isatty(self) -> bool: # pragma: no cover return bool(getattr(self._original, "isatty", lambda: False)()) def _buffer_text(self, data: str) -> None: @@ -108,16 +141,18 @@ class _WorkerOutputMirror(io.TextIOBase): if not lines: self._pending = combined return + if lines[-1].endswith(("\n", "\r")): complete = lines self._pending = "" else: complete = lines[:-1] self._pending = lines[-1] + for chunk in complete: self._emit(chunk) - def _flush_pending(self, force: bool = False) -> None: + def _flush_pending(self, *, force: bool = False) -> None: if self._pending and force: self._emit(self._pending) self._pending = "" @@ -135,17 +170,18 @@ class _WorkerOutputMirror(io.TextIOBase): return getattr(self._original, "encoding", "utf-8") -class _WorkerStageSession: +class WorkerStageSession: """Lifecycle helper for wrapping a CLI cmdlet execution in a worker record.""" def __init__( self, - manager: WorkerManagerType, + *, + manager: WorkerManager, worker_id: str, orig_stdout: TextIO, orig_stderr: TextIO, - stdout_proxy: _WorkerOutputMirror, - stderr_proxy: _WorkerOutputMirror, + stdout_proxy: WorkerOutputMirror, + stderr_proxy: WorkerOutputMirror, config: Optional[Dict[str, Any]], logging_enabled: bool, completion_label: str, @@ -163,7 +199,7 @@ class _WorkerStageSession: self._completion_label = completion_label self._error_label = error_label - def close(self, status: str = "completed", error_msg: str = "") -> None: + def close(self, *, status: str = "completed", error_msg: str = "") -> None: if self.closed: return try: @@ -171,13 +207,16 @@ class _WorkerStageSession: self.stderr_proxy.flush() except Exception: pass + sys.stdout = self.orig_stdout sys.stderr = self.orig_stderr + if self.logging_enabled: try: self.manager.disable_logging_for_worker(self.worker_id) except Exception: pass + try: if status == "completed": self.manager.log_step(self.worker_id, self._completion_label) @@ -185,736 +224,1678 @@ class _WorkerStageSession: self.manager.log_step(self.worker_id, f"{self._error_label}: {error_msg or status}") except Exception: pass + try: self.manager.finish_worker(self.worker_id, result=status or "completed", error_msg=error_msg or "") except Exception: pass - if self.config and self.config.get('_current_worker_id') == self.worker_id: - self.config.pop('_current_worker_id', None) + + if self.config and self.config.get("_current_worker_id") == self.worker_id: + self.config.pop("_current_worker_id", None) self.closed = True -_CLI_WORKER_MANAGER: Optional[WorkerManagerType] = None -_CLI_ORPHAN_CLEANUP_DONE = False -CLI_ROOT = Path(__file__).resolve().parent +class WorkerManagerRegistry: + """Process-wide WorkerManager cache keyed by library_root.""" + _manager: Optional[WorkerManager] = None + _manager_root: Optional[Path] = None + _orphan_cleanup_done: bool = False + _registered: bool = False -def _load_cli_config() -> Dict[str, Any]: - """Load config.conf relative to the CLI script location.""" - try: - return deepcopy(load_config(config_dir=CLI_ROOT)) - except Exception: - return {} + @classmethod + def ensure(cls, config: Dict[str, Any]) -> Optional[WorkerManager]: + if not isinstance(config, dict): + return None + existing = config.get("_worker_manager") + if isinstance(existing, WorkerManager): + return existing -def _get_table_title_for_command( - cmd_name: str, - emitted_items: Optional[List[Any]] = None, - cmd_args: Optional[List[str]] = None, -) -> str: - """Generate a dynamic table title based on the command and emitted items. - - Args: - cmd_name: The command name (e.g., 'search-file', 'get-tag', 'get-file') - emitted_items: The items being displayed - cmd_args: Arguments passed to the command (when available) - - Returns: - A descriptive title for the result table - """ - # Prefer argument-aware titles where possible so table history is self-describing. - if cmd_name in ('search-provider', 'search_provider') and cmd_args: - # Support both positional form: - # search-provider - # and flag form: - # search-provider -provider - provider: str = "" - query: str = "" - tokens = [str(a) for a in (cmd_args or [])] - pos: List[str] = [] - i = 0 - while i < len(tokens): - low = tokens[i].lower() - if low in {"-provider", "--provider"} and i + 1 < len(tokens): - provider = str(tokens[i + 1]).strip() - i += 2 - continue - if low in {"-query", "--query"} and i + 1 < len(tokens): - query = str(tokens[i + 1]).strip() - i += 2 - continue - if low in {"-limit", "--limit"} and i + 1 < len(tokens): - i += 2 - continue - if not str(tokens[i]).startswith("-"): - pos.append(str(tokens[i])) - i += 1 + library_root = get_local_storage_path(config) + if not library_root: + return None - if not provider and pos: - provider = str(pos[0]).strip() - pos = pos[1:] - if not query and pos: - query = " ".join(pos).strip() - - if not provider or not query: - # Fall back to generic mapping below. - provider = "" - query = "" - - provider_lower = provider.lower() - if provider_lower == 'youtube': - provider_label = 'Youtube' - elif provider_lower == 'openlibrary': - provider_label = 'OpenLibrary' - else: - provider_label = provider[:1].upper() + provider[1:] if provider else 'Provider' - if provider and query: - return f"{provider_label}: {query}".strip().rstrip(':') - - # Mapping of commands to title templates - title_map = { - 'search-file': 'Results', - 'search_file': 'Results', - 'download-data': 'Downloads', - 'download_data': 'Downloads', - 'get-tag': 'Tags', - 'get_tag': 'Tags', - 'get-file': 'Results', - 'get_file': 'Results', - 'add-tags': 'Results', - 'add_tags': 'Results', - 'delete-tag': 'Results', - 'delete_tag': 'Results', - 'add-url': 'Results', - 'add_url': 'Results', - 'get-url': 'url', - 'get_url': 'url', - 'delete-url': 'Results', - 'delete_url': 'Results', - 'get-note': 'Notes', - 'get_note': 'Notes', - 'add-note': 'Results', - 'add_note': 'Results', - 'delete-note': 'Results', - 'delete_note': 'Results', - 'get-relationship': 'Relationships', - 'get_relationship': 'Relationships', - 'add-relationship': 'Results', - 'add_relationship': 'Results', - 'add-file': 'Results', - 'add_file': 'Results', - 'delete-file': 'Results', - 'delete_file': 'Results', - 'get-metadata': None, - 'get_metadata': None, - } - - mapped = title_map.get(cmd_name, 'Results') - if mapped is not None: - return mapped - - # For metadata, derive title from first item if available - if emitted_items: - first = emitted_items[0] try: - if isinstance(first, dict) and first.get('title'): - return str(first.get('title')) - if hasattr(first, 'title') and getattr(first, 'title'): - return str(getattr(first, 'title')) + resolved_root = Path(library_root).resolve() except Exception: - pass - return 'Results' + resolved_root = Path(library_root) - -def _close_cli_worker_manager() -> None: - global _CLI_WORKER_MANAGER - if _CLI_WORKER_MANAGER: try: - # print("[CLI] Closing worker manager...", file=sys.stderr) - _CLI_WORKER_MANAGER.close() - except Exception: - pass - _CLI_WORKER_MANAGER = None + if cls._manager is None or cls._manager_root != resolved_root: + if cls._manager is not None: + try: + cls._manager.close() + except Exception: + pass + cls._manager = WorkerManager(resolved_root, auto_refresh_interval=0.5) + cls._manager_root = resolved_root + manager = cls._manager + config["_worker_manager"] = manager -atexit.register(_close_cli_worker_manager) - - -def _ensure_worker_manager(config: Dict[str, Any]) -> Optional[WorkerManagerType]: - """Attach a WorkerManager to the CLI config for cmdlet execution.""" - global _CLI_WORKER_MANAGER, _CLI_ORPHAN_CLEANUP_DONE - if WorkerManager is None: - return None - if not isinstance(config, dict): - return None - existing = config.get('_worker_manager') - if isinstance(existing, WorkerManager): - return existing - library_root = get_local_storage_path(config) - if not library_root: - return None - try: - resolved_root = Path(library_root).resolve() - except Exception: - resolved_root = Path(library_root) - try: - if not _CLI_WORKER_MANAGER or Path(getattr(_CLI_WORKER_MANAGER, 'library_root', '')) != resolved_root: - if _CLI_WORKER_MANAGER: + if manager is not None and not cls._orphan_cleanup_done: try: - _CLI_WORKER_MANAGER.close() + manager.expire_running_workers( + older_than_seconds=120, + worker_id_prefix="cli_%", + reason="CLI session ended unexpectedly; marking worker as failed", + ) except Exception: pass - _CLI_WORKER_MANAGER = WorkerManager(resolved_root, auto_refresh_interval=0.5) - manager = _CLI_WORKER_MANAGER - config['_worker_manager'] = manager - # Do NOT attach notifier here - it will be attached when we have session worker IDs - if manager and not _CLI_ORPHAN_CLEANUP_DONE: - try: - manager.expire_running_workers( - older_than_seconds=120, - worker_id_prefix="cli_%", - reason="CLI session ended unexpectedly; marking worker as failed", - ) - except Exception: - pass - else: - _CLI_ORPHAN_CLEANUP_DONE = True - return manager - except Exception as exc: - print(f"[worker] Could not initialize worker manager: {exc}", file=sys.stderr) - return None + else: + cls._orphan_cleanup_done = True + if not cls._registered: + atexit.register(cls.close) + cls._registered = True -def _start_worker_session( - worker_manager: Optional[WorkerManagerType], - *, - worker_type: str, - title: str, - description: str, - pipe_text: str, - config: Optional[Dict[str, Any]], - completion_label: str, - error_label: str, - skip_logging_for: Optional[Set[str]] = None, - session_worker_ids: Optional[Set[str]] = None, -) -> Optional[_WorkerStageSession]: - """Create a worker session wrapper and mirror stdout/stderr. - - Args: - worker_manager: The worker manager - worker_type: Type of worker (e.g., 'pipeline', 'search-file') - title: Human-readable title - description: Worker description - pipe_text: Pipeline/command text - config: CLI configuration dict - completion_label: Label for successful completion - error_label: Label for errors - skip_logging_for: Set of worker types to skip logging for - session_worker_ids: Optional set to register this worker's ID in (for filtering notifications) - """ - if worker_manager is None: - return None - if skip_logging_for and worker_type in skip_logging_for: - return None - safe_type = worker_type or "cmd" - worker_id = f"cli_{safe_type[:8]}_{uuid.uuid4().hex[:6]}" - try: - tracked = worker_manager.track_worker( - worker_id, - worker_type=worker_type, - title=title, - description=description or "(no args)", - pipe=pipe_text, - ) - if not tracked: + return manager + except Exception as exc: + print(f"[worker] Could not initialize worker manager: {exc}", file=sys.stderr) return None - except Exception as exc: - print(f"[worker] Failed to track {worker_type}: {exc}", file=sys.stderr) - return None - - # Register this worker ID with the session if provided - if session_worker_ids is not None: - session_worker_ids.add(worker_id) - - logging_enabled = False - try: - handler = worker_manager.enable_logging_for_worker(worker_id) - logging_enabled = handler is not None - except Exception: - logging_enabled = False - orig_stdout = sys.stdout - orig_stderr = sys.stderr - stdout_proxy = _WorkerOutputMirror(orig_stdout, worker_manager, worker_id, 'stdout') - stderr_proxy = _WorkerOutputMirror(orig_stderr, worker_manager, worker_id, 'stderr') - sys.stdout = stdout_proxy - sys.stderr = stderr_proxy - if isinstance(config, dict): - config['_current_worker_id'] = worker_id - try: - worker_manager.log_step(worker_id, f"Started {worker_type}") - except Exception: - pass - return _WorkerStageSession( - manager=worker_manager, - worker_id=worker_id, - orig_stdout=orig_stdout, - orig_stderr=orig_stderr, - stdout_proxy=stdout_proxy, - stderr_proxy=stderr_proxy, - config=config, - logging_enabled=logging_enabled, - completion_label=completion_label, - error_label=error_label, - ) - -def _begin_worker_stage( - worker_manager: Optional[WorkerManagerType], - cmd_name: str, - stage_tokens: Sequence[str], - config: Optional[Dict[str, Any]], - command_text: str, -) -> Optional[_WorkerStageSession]: - """Start a worker entry for an individual CLI stage. - - If a session_worker_ids set exists in config, register this stage with it. - """ - description = " ".join(stage_tokens[1:]) if len(stage_tokens) > 1 else "(no args)" - session_worker_ids = None - if isinstance(config, dict): - session_worker_ids = config.get('_session_worker_ids') - - return _start_worker_session( - worker_manager, - worker_type=cmd_name, - title=f"{cmd_name} stage", - description=description, - pipe_text=command_text, - config=config, - completion_label="Stage completed", - error_label="Stage error", - skip_logging_for={".worker", "worker", "workers"}, - session_worker_ids=session_worker_ids, - ) - - -def _begin_pipeline_worker( - worker_manager: Optional[WorkerManagerType], - pipeline_text: str, - config: Optional[Dict[str, Any]], -) -> Optional[_WorkerStageSession]: - """Start a worker that represents the entire pipeline execution. - - Also initializes a session_worker_ids set in config for tracking pipeline workers. - """ - # Create a session ID set for this pipeline execution - session_worker_ids: Set[str] = set() - if isinstance(config, dict): - config['_session_worker_ids'] = session_worker_ids - - return _start_worker_session( - worker_manager, - worker_type="pipeline", - title="Pipeline run", - description=pipeline_text, - pipe_text=pipeline_text, - config=config, - completion_label="Pipeline completed", - error_label="Pipeline error", - session_worker_ids=session_worker_ids, - ) - - -def _get_cmdlet_names() -> List[str]: - """Get list of all available cmdlet names.""" - try: - return _catalog_list_cmdlet_names() - except Exception: - return [] - - -def _import_cmd_module(mod_name: str): - """Import a cmdlet/native module from cmdlet or cmdnat packages.""" - try: - return _catalog_import_cmd_module(mod_name) - except Exception: - return None - - -def _get_cmdlet_args(cmd_name: str) -> List[str]: - """Get list of argument flags for a cmdlet (with - and -- prefixes).""" - try: - return _catalog_get_cmdlet_arg_flags(cmd_name) - except Exception: - return [] - - -def get_store_choices() -> List[str]: - """Return configured store backend names. - - This is the same list used for REPL/Typer autocomplete for `-store`. - """ - try: - from Store import Store - storage = Store(_load_cli_config(), suppress_debug=True) - backends = storage.list_backends() - return list(backends or []) - except Exception: - return [] - - -def _get_arg_choices(cmd_name: str, arg_name: str) -> List[str]: - """Get list of valid choices for a specific cmdlet argument.""" - try: - mod_name = cmd_name.replace("-", "_") - normalized_arg = arg_name.lstrip("-") - - # Dynamic storage backends: use current config to enumerate available storages - # Support both "storage" and "store" argument names - if normalized_arg in ("storage", "store"): - backends = get_store_choices() - if backends: - return backends - - # Dynamic search providers - if normalized_arg == "provider": - try: - canonical_cmd = (cmd_name or "").replace("_", "-").lower() - - # cmdlet-aware provider choices: - # - search-provider: search providers - # - add-file: file providers (0x0, matrix) - if canonical_cmd in {"search-provider"}: - from ProviderCore.registry import list_search_providers - providers = list_search_providers(_load_cli_config()) - available = [name for name, is_ready in providers.items() if is_ready] - return sorted(available) if available else sorted(providers.keys()) - - if canonical_cmd in {"add-file"}: - from ProviderCore.registry import list_file_providers - providers = list_file_providers(_load_cli_config()) - available = [name for name, is_ready in providers.items() if is_ready] - return sorted(available) if available else sorted(providers.keys()) - - # Default behavior (legacy): merge search providers and metadata providers. - from ProviderCore.registry import list_search_providers - providers = list_search_providers(_load_cli_config()) - available = [name for name, is_ready in providers.items() if is_ready] - provider_choices = sorted(available) if available else sorted(providers.keys()) - except Exception: - provider_choices = [] - - try: - from Provider.metadata_provider import list_metadata_providers - meta_providers = list_metadata_providers(_load_cli_config()) - meta_available = [n for n, ready in meta_providers.items() if ready] - meta_choices = sorted(meta_available) if meta_available else sorted(meta_providers.keys()) - except Exception: - meta_choices = [] - - merged = sorted(set(provider_choices + meta_choices)) - if merged: - return merged - - if normalized_arg == "scrape": - try: - from Provider.metadata_provider import list_metadata_providers - meta_providers = list_metadata_providers(_load_cli_config()) - if meta_providers: - return sorted(meta_providers.keys()) - except Exception: - pass - choices = _catalog_get_cmdlet_arg_choices(cmd_name, arg_name) - return choices or [] - except Exception: - return [] - - -if ( - PROMPT_TOOLKIT_AVAILABLE - and PromptSession is not None - and Completion is not None - and Completer is not None - and Document is not None - and Lexer is not None -): - CompletionType = cast(Any, Completion) - - class CmdletCompleter(Completer): - """Custom completer for cmdlet REPL with autocomplete tied to cmdlet metadata.""" - - def __init__(self): - self.cmdlet_names = _get_cmdlet_names() - - def get_completions(self, document: Document, complete_event): # type: ignore[override] - """Generate completions for the current input.""" - text = document.text_before_cursor - tokens = text.split() - ends_with_space = bool(text) and text[-1].isspace() - - # Respect pipeline stages: only use tokens after the last '|' - last_pipe = -1 - for idx, tok in enumerate(tokens): - if tok == "|": - last_pipe = idx - stage_tokens = tokens[last_pipe + 1:] if last_pipe >= 0 else tokens - - if not stage_tokens: - for cmd in self.cmdlet_names: - yield CompletionType(cmd, start_position=0) - return - - # Single token at this stage -> suggest command names/keywords - if len(stage_tokens) == 1: - current = stage_tokens[0].lower() - - # If the user has finished typing the command and added a space, - # complete that command's flags (or sub-choices) instead of command names. - if ends_with_space: - cmd_name = current.replace("_", "-") - if cmd_name in {"help"}: - for cmd in self.cmdlet_names: - yield CompletionType(cmd, start_position=0) - return - - arg_names = _get_cmdlet_args(cmd_name) - logical_seen: Set[str] = set() - for arg in arg_names: - arg_low = arg.lower() - if arg_low.startswith("--"): - continue - logical = arg.lstrip("-").lower() - if logical in logical_seen: - continue - yield CompletionType(arg, start_position=0) - logical_seen.add(logical) - - yield CompletionType("-help", start_position=0) - return - - for cmd in self.cmdlet_names: - if cmd.startswith(current): - yield CompletionType(cmd, start_position=-len(current)) - for keyword in ["help", "exit", "quit"]: - if keyword.startswith(current): - yield CompletionType(keyword, start_position=-len(current)) - return - - # Otherwise treat first token of stage as command and complete its args - cmd_name = stage_tokens[0].replace("_", "-").lower() - if ends_with_space: - current_token = "" - prev_token = stage_tokens[-1].lower() - else: - current_token = stage_tokens[-1].lower() - prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else "" - - choices = _get_arg_choices(cmd_name, prev_token) - if choices: - for choice in choices: - if choice.lower().startswith(current_token): - yield CompletionType(choice, start_position=-len(current_token)) - return - - arg_names = _get_cmdlet_args(cmd_name) - logical_seen: Set[str] = set() - for arg in arg_names: - arg_low = arg.lower() - - prefer_single_dash = current_token in {"", "-"} - - # If the user has only typed '-', prefer single-dash flags (e.g. -url) - # and avoid suggesting both -name and --name for the same logical arg. - if prefer_single_dash and arg_low.startswith("--"): - continue - - logical = arg.lstrip("-").lower() - if prefer_single_dash and logical in logical_seen: - continue - - if arg_low.startswith(current_token): - yield CompletionType(arg, start_position=-len(current_token)) - if prefer_single_dash: - logical_seen.add(logical) - - # Help completion: prefer -help unless user explicitly starts '--' - if current_token.startswith("--"): - if "--help".startswith(current_token): - yield CompletionType("--help", start_position=-len(current_token)) - else: - if "-help".startswith(current_token): - yield CompletionType("-help", start_position=-len(current_token)) - - async def get_completions_async(self, document: Document, complete_event): # type: ignore[override] - for completion in self.get_completions(document, complete_event): - yield completion - - class MedeiaLexer(Lexer): - def lex_document(self, document): - def get_line(lineno): - line = document.lines[lineno] - tokens = [] - - import re - # Match: Whitespace, Pipe, Quoted string, or Word - pattern = re.compile(r''' - (\s+) | # 1. Whitespace - (\|) | # 2. Pipe - ("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*') | # 3. Quoted string - ([^\s\|]+) # 4. Word - ''', re.VERBOSE) - - is_cmdlet = True - - for match in pattern.finditer(line): - ws, pipe, quote, word = match.groups() - - if ws: - tokens.append(('', ws)) - elif pipe: - tokens.append(('class:pipe', pipe)) - is_cmdlet = True - elif quote: - tokens.append(('class:string', quote)) - is_cmdlet = False - elif word: - if is_cmdlet: - tokens.append(('class:cmdlet', word)) - is_cmdlet = False - elif word.startswith('-'): - tokens.append(('class:argument', word)) - else: - tokens.append(('class:value', word)) - - return tokens - return get_line -else: # pragma: no cover - prompt toolkit unavailable - CmdletCompleter = None # type: ignore[assignment] - - -def _create_cmdlet_cli(): - """Create Typer CLI app for cmdlet-based commands.""" - if typer is None: - return None - - app = typer.Typer(help="Medeia-Macina CLI") - - def _complete_search_provider(ctx, param, incomplete: str): # pragma: no cover - """Shell completion for --provider values on the Typer search-provider command.""" + @classmethod + def close(cls) -> None: + if cls._manager is None: + return try: - import click - from click.shell_completion import CompletionItem + cls._manager.close() + except Exception: + pass + cls._manager = None + cls._manager_root = None + cls._orphan_cleanup_done = False + + +class WorkerStages: + """Factory methods for stage/pipeline worker sessions.""" + + @staticmethod + def _start_worker_session( + worker_manager: Optional[WorkerManager], + *, + worker_type: str, + title: str, + description: str, + pipe_text: str, + config: Optional[Dict[str, Any]], + completion_label: str, + error_label: str, + skip_logging_for: Optional[Set[str]] = None, + session_worker_ids: Optional[Set[str]] = None, + ) -> Optional[WorkerStageSession]: + if worker_manager is None: + return None + if skip_logging_for and worker_type in skip_logging_for: + return None + + safe_type = worker_type or "cmd" + worker_id = f"cli_{safe_type[:8]}_{uuid.uuid4().hex[:6]}" + + try: + tracked = worker_manager.track_worker( + worker_id, + worker_type=worker_type, + title=title, + description=description or "(no args)", + pipe=pipe_text, + ) + if not tracked: + return None + except Exception as exc: + print(f"[worker] Failed to track {worker_type}: {exc}", file=sys.stderr) + return None + + if session_worker_ids is not None: + session_worker_ids.add(worker_id) + + logging_enabled = False + try: + handler = worker_manager.enable_logging_for_worker(worker_id) + logging_enabled = handler is not None + except Exception: + logging_enabled = False + + orig_stdout = sys.stdout + orig_stderr = sys.stderr + stdout_proxy = WorkerOutputMirror(orig_stdout, worker_manager, worker_id, "stdout") + stderr_proxy = WorkerOutputMirror(orig_stderr, worker_manager, worker_id, "stderr") + sys.stdout = stdout_proxy + sys.stderr = stderr_proxy + if isinstance(config, dict): + config["_current_worker_id"] = worker_id + + try: + worker_manager.log_step(worker_id, f"Started {worker_type}") + except Exception: + pass + + return WorkerStageSession( + manager=worker_manager, + worker_id=worker_id, + orig_stdout=orig_stdout, + orig_stderr=orig_stderr, + stdout_proxy=stdout_proxy, + stderr_proxy=stderr_proxy, + config=config, + logging_enabled=logging_enabled, + completion_label=completion_label, + error_label=error_label, + ) + + @classmethod + def begin_stage( + cls, + worker_manager: Optional[WorkerManager], + *, + cmd_name: str, + stage_tokens: Sequence[str], + config: Optional[Dict[str, Any]], + command_text: str, + ) -> Optional[WorkerStageSession]: + description = " ".join(stage_tokens[1:]) if len(stage_tokens) > 1 else "(no args)" + session_worker_ids = None + if isinstance(config, dict): + session_worker_ids = config.get("_session_worker_ids") + + return cls._start_worker_session( + worker_manager, + worker_type=cmd_name, + title=f"{cmd_name} stage", + description=description, + pipe_text=command_text, + config=config, + completion_label="Stage completed", + error_label="Stage error", + skip_logging_for={".worker", "worker", "workers"}, + session_worker_ids=session_worker_ids, + ) + + @classmethod + def begin_pipeline( + cls, + worker_manager: Optional[WorkerManager], + *, + pipeline_text: str, + config: Optional[Dict[str, Any]], + ) -> Optional[WorkerStageSession]: + session_worker_ids: Set[str] = set() + if isinstance(config, dict): + config["_session_worker_ids"] = session_worker_ids + + return cls._start_worker_session( + worker_manager, + worker_type="pipeline", + title="Pipeline run", + description=pipeline_text, + pipe_text=pipeline_text, + config=config, + completion_label="Pipeline completed", + error_label="Pipeline error", + session_worker_ids=session_worker_ids, + ) + + +class CmdletIntrospection: + @staticmethod + def cmdlet_names() -> List[str]: + try: + return list_cmdlet_names() or [] except Exception: return [] + @staticmethod + def cmdlet_args(cmd_name: str) -> List[str]: try: - from ProviderCore.registry import list_search_providers - providers = list_search_providers(_load_cli_config()) - available = [n for n, ok in (providers or {}).items() if ok] - choices = sorted(available) if available else sorted((providers or {}).keys()) + return get_cmdlet_arg_flags(cmd_name) or [] except Exception: - choices = [] + return [] - inc = (incomplete or "").lower() - out = [] - for name in choices: - if not name: + @staticmethod + def store_choices(config: Dict[str, Any]) -> List[str]: + try: + from Store import Store + + storage = Store(config=config, suppress_debug=True) + return list(storage.list_backends() or []) + except Exception: + return [] + + @classmethod + def arg_choices(cls, *, cmd_name: str, arg_name: str, config: Dict[str, Any]) -> List[str]: + try: + normalized_arg = (arg_name or "").lstrip("-").strip().lower() + + if normalized_arg in ("storage", "store"): + backends = cls.store_choices(config) + if backends: + return backends + + if normalized_arg == "provider": + canonical_cmd = (cmd_name or "").replace("_", "-").lower() + try: + from ProviderCore.registry import list_search_providers, list_file_providers + except Exception: + list_search_providers = None # type: ignore + list_file_providers = None # type: ignore + + provider_choices: List[str] = [] + + if canonical_cmd in {"search-provider"} and list_search_providers is not None: + providers = list_search_providers(config) or {} + available = [name for name, is_ready in providers.items() if is_ready] + return sorted(available) if available else sorted(providers.keys()) + + if canonical_cmd in {"add-file"} and list_file_providers is not None: + providers = list_file_providers(config) or {} + available = [name for name, is_ready in providers.items() if is_ready] + return sorted(available) if available else sorted(providers.keys()) + + if list_search_providers is not None: + providers = list_search_providers(config) or {} + available = [name for name, is_ready in providers.items() if is_ready] + provider_choices = sorted(available) if available else sorted(providers.keys()) + + try: + from Provider.metadata_provider import list_metadata_providers + meta_providers = list_metadata_providers(config) or {} + meta_available = [n for n, ready in meta_providers.items() if ready] + meta_choices = sorted(meta_available) if meta_available else sorted(meta_providers.keys()) + except Exception: + meta_choices = [] + + merged = sorted(set(provider_choices + meta_choices)) + if merged: + return merged + + if normalized_arg == "scrape": + try: + from Provider.metadata_provider import list_metadata_providers + meta_providers = list_metadata_providers(config) or {} + if meta_providers: + return sorted(meta_providers.keys()) + except Exception: + pass + + return get_cmdlet_arg_choices(cmd_name, arg_name) or [] + except Exception: + return [] + + +class CmdletCompleter(Completer): + """Prompt-toolkit completer for the Medeia cmdlet REPL.""" + + def __init__(self, *, config_loader: "ConfigLoader") -> None: + self._config_loader = config_loader + self.cmdlet_names = CmdletIntrospection.cmdlet_names() + + def get_completions(self, document: Document, complete_event): # type: ignore[override] + text = document.text_before_cursor + tokens = text.split() + ends_with_space = bool(text) and text[-1].isspace() + + last_pipe = -1 + for idx, tok in enumerate(tokens): + if tok == "|": + last_pipe = idx + stage_tokens = tokens[last_pipe + 1 :] if last_pipe >= 0 else tokens + + if not stage_tokens: + for cmd in self.cmdlet_names: + yield Completion(cmd, start_position=0) + return + + if len(stage_tokens) == 1: + current = stage_tokens[0].lower() + + if ends_with_space: + cmd_name = current.replace("_", "-") + + if cmd_name == "help": + for cmd in self.cmdlet_names: + yield Completion(cmd, start_position=0) + return + + if cmd_name not in self.cmdlet_names: + return + + arg_names = CmdletIntrospection.cmdlet_args(cmd_name) + logical_seen: Set[str] = set() + for arg in arg_names: + arg_low = arg.lower() + if arg_low.startswith("--"): + continue + logical = arg.lstrip("-").lower() + if logical in logical_seen: + continue + yield Completion(arg, start_position=0) + logical_seen.add(logical) + + yield Completion("-help", start_position=0) + return + + for cmd in self.cmdlet_names: + if cmd.startswith(current): + yield Completion(cmd, start_position=-len(current)) + for keyword in ("help", "exit", "quit"): + if keyword.startswith(current): + yield Completion(keyword, start_position=-len(current)) + return + + cmd_name = stage_tokens[0].replace("_", "-").lower() + if ends_with_space: + current_token = "" + prev_token = stage_tokens[-1].lower() + else: + current_token = stage_tokens[-1].lower() + prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else "" + + config = self._config_loader.load() + choices = CmdletIntrospection.arg_choices(cmd_name=cmd_name, arg_name=prev_token, config=config) + if choices: + for choice in choices: + if choice.lower().startswith(current_token): + yield Completion(choice, start_position=-len(current_token)) + return + + arg_names = CmdletIntrospection.cmdlet_args(cmd_name) + logical_seen: Set[str] = set() + for arg in arg_names: + arg_low = arg.lower() + prefer_single_dash = current_token in {"", "-"} + if prefer_single_dash and arg_low.startswith("--"): continue - if name.lower().startswith(inc): - out.append(CompletionItem(name)) - return out + logical = arg.lstrip("-").lower() + if prefer_single_dash and logical in logical_seen: + continue + if arg_low.startswith(current_token): + yield Completion(arg, start_position=-len(current_token)) + if prefer_single_dash: + logical_seen.add(logical) - @app.command("search-provider") - def search_provider( - provider: str = typer.Option( - ..., "--provider", "-p", - help="Provider name (bandcamp, libgen, soulseek, youtube)", - shell_complete=_complete_search_provider, - ), - query: str = typer.Argument(..., help="Search query (quote for spaces)"), - limit: int = typer.Option(36, "--limit", "-l", help="Maximum results to return"), - ): - """Search external providers (Typer wrapper around the cmdlet).""" - # Delegate to the existing cmdlet so behavior stays consistent. - _execute_cmdlet("search-provider", ["-provider", provider, query, "-limit", str(limit)]) - - @app.command("pipeline") - def pipeline( - command: str = typer.Option(..., "--pipeline", "-p", help="Pipeline command string to execute"), - seeds_json: Optional[str] = typer.Option(None, "--seeds-json", "-s", help="JSON string of seed items") - ): - """Execute a pipeline command non-interactively.""" - import shlex - import json + if cmd_name in self.cmdlet_names: + if current_token.startswith("--"): + if "--help".startswith(current_token): + yield Completion("--help", start_position=-len(current_token)) + else: + if "-help".startswith(current_token): + yield Completion("-help", start_position=-len(current_token)) + + +class MedeiaLexer(Lexer): + def lex_document(self, document: Document): # type: ignore[override] + def get_line(lineno: int): + line = document.lines[lineno] + tokens: List[tuple[str, str]] = [] + + pattern = re.compile( + r""" + (\s+) | # 1. Whitespace + (\|) | # 2. Pipe + ("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*') | # 3. Quoted string + ([^\s\|]+) # 4. Word + """, + re.VERBOSE, + ) + + is_cmdlet = True + + def _emit_keyed_value(word: str) -> bool: + """Emit `key:` prefixes (comma-separated) as argument tokens. + + Designed for values like: + clip:3m4s-3m14s,1h22m-1h33m,item:2-3 + + Avoids special-casing URLs (://) and Windows drive paths (C:\\...). + Returns True if it handled the token. + """ + if not word or ":" not in word: + return False + # Avoid URLs and common scheme patterns. + if "://" in word: + return False + # Avoid Windows drive paths (e.g., C:\foo or D:/bar) + if re.match(r"^[A-Za-z]:[\\/]", word): + return False + + key_prefix = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*:)(.*)$") + parts = word.split(",") + handled_any = False + for i, part in enumerate(parts): + if i > 0: + tokens.append(("class:value", ",")) + if part == "": + continue + m = key_prefix.match(part) + if m: + tokens.append(("class:argument", m.group(1))) + if m.group(2): + tokens.append(("class:value", m.group(2))) + handled_any = True + else: + tokens.append(("class:value", part)) + handled_any = True + + return handled_any + + for match in pattern.finditer(line): + ws, pipe, quote, word = match.groups() + if ws: + tokens.append(("", ws)) + continue + if pipe: + tokens.append(("class:pipe", pipe)) + is_cmdlet = True + continue + if quote: + # If the quoted token contains a keyed spec (clip:/item:/hash:), + # highlight the `key:` portion in argument-blue even inside quotes. + if len(quote) >= 2 and quote[0] == quote[-1] and quote[0] in ("\"", "'"): + q = quote[0] + inner = quote[1:-1] + start_index = len(tokens) + if _emit_keyed_value(inner): + # _emit_keyed_value already appended tokens for inner; insert opening quote + # before that chunk, then add the closing quote. + tokens.insert(start_index, ("class:string", q)) + tokens.append(("class:string", q)) + is_cmdlet = False + continue + + tokens.append(("class:string", quote)) + is_cmdlet = False + continue + if not word: + continue + + if word.startswith("@"): # selection tokens + rest = word[1:] + if rest and re.fullmatch(r"[0-9\-\*,]+", rest): + tokens.append(("class:selection_at", "@")) + tokens.append(("class:selection_range", rest)) + is_cmdlet = False + continue + if rest == "": + tokens.append(("class:selection_at", "@")) + is_cmdlet = False + continue + + if is_cmdlet: + tokens.append(("class:cmdlet", word)) + is_cmdlet = False + elif word.startswith("-"): + tokens.append(("class:argument", word)) + else: + if not _emit_keyed_value(word): + tokens.append(("class:value", word)) + + return tokens + + return get_line + + +class ConfigLoader: + def __init__(self, *, root: Path) -> None: + self._root = root + + def load(self) -> Dict[str, Any]: + try: + return deepcopy(load_config(config_dir=self._root)) + except Exception: + return {} + + +class CmdletHelp: + @staticmethod + def show_cmdlet_list() -> None: + try: + metadata = list_cmdlet_metadata() or {} + print("\nAvailable cmdlet:") + for cmd_name in sorted(metadata.keys()): + info = metadata[cmd_name] + aliases = info.get("aliases", []) + args = info.get("args", []) + + display = f" cmd:{cmd_name}" + if aliases: + display += f" alias:{', '.join(aliases)}" + if args: + arg_names = [a.get("name") for a in args if a.get("name")] + if arg_names: + display += f" args:{', '.join(arg_names)}" + summary = info.get("summary") + if summary: + display += f" - {summary}" + print(display) + print() + except Exception as exc: + print(f"Error: {exc}\n") + + @staticmethod + def show_cmdlet_help(cmd_name: str) -> None: + try: + meta = get_cmdlet_metadata(cmd_name) + if meta: + CmdletHelp._print_metadata(cmd_name, meta) + return + print(f"Unknown command: {cmd_name}\n") + except Exception as exc: + print(f"Error: {exc}\n") + + @staticmethod + def _print_metadata(cmd_name: str, data: Any) -> None: + d = data.to_dict() if hasattr(data, "to_dict") else data + if not isinstance(d, dict): + print(f"Invalid metadata for {cmd_name}\n") + return + + name = d.get("name", cmd_name) + summary = d.get("summary", "") + usage = d.get("usage", "") + description = d.get("description", "") + args = d.get("args", []) + details = d.get("details", []) + + print("\nNAME") + print(f" {name}") + + print("\nSYNOPSIS") + print(f" {usage or name}") + + if summary or description: + print("\nDESCRIPTION") + if summary: + print(f" {summary}") + if description: + print(f" {description}") + + if args and isinstance(args, list): + print("\nPARAMETERS") + for arg in args: + if isinstance(arg, dict): + name_str = arg.get("name", "?") + typ = arg.get("type", "string") + required = arg.get("required", False) + desc = arg.get("description", "") + else: + name_str = getattr(arg, "name", "?") + typ = getattr(arg, "type", "string") + required = getattr(arg, "required", False) + desc = getattr(arg, "description", "") + + req_marker = "[required]" if required else "[optional]" + print(f" -{name_str} <{typ}>") + if desc: + print(f" {desc}") + print(f" {req_marker}") + print() + + if details: + print("REMARKS") + for detail in details: + print(f" {detail}") + print() + + +class CmdletExecutor: + def __init__(self, *, config_loader: ConfigLoader) -> None: + self._config_loader = config_loader + + @staticmethod + def _get_table_title_for_command( + cmd_name: str, + emitted_items: Optional[List[Any]] = None, + cmd_args: Optional[List[str]] = None, + ) -> str: + if cmd_name in ("search-provider", "search_provider") and cmd_args: + provider: str = "" + query: str = "" + tokens = [str(a) for a in (cmd_args or [])] + pos: List[str] = [] + i = 0 + while i < len(tokens): + low = tokens[i].lower() + if low in {"-provider", "--provider"} and i + 1 < len(tokens): + provider = str(tokens[i + 1]).strip() + i += 2 + continue + if low in {"-query", "--query"} and i + 1 < len(tokens): + query = str(tokens[i + 1]).strip() + i += 2 + continue + if low in {"-limit", "--limit"} and i + 1 < len(tokens): + i += 2 + continue + if not str(tokens[i]).startswith("-"): + pos.append(str(tokens[i])) + i += 1 + + if not provider and pos: + provider = str(pos[0]).strip() + pos = pos[1:] + if not query and pos: + query = " ".join(pos).strip() + + if provider and query: + provider_lower = provider.lower() + if provider_lower == "youtube": + provider_label = "Youtube" + elif provider_lower == "openlibrary": + provider_label = "OpenLibrary" + else: + provider_label = provider[:1].upper() + provider[1:] + return f"{provider_label}: {query}".strip().rstrip(":") + + title_map = { + "search-file": "Results", + "search_file": "Results", + "download-data": "Downloads", + "download_data": "Downloads", + "get-tag": "Tags", + "get_tag": "Tags", + "get-file": "Results", + "get_file": "Results", + "add-tags": "Results", + "add_tags": "Results", + "delete-tag": "Results", + "delete_tag": "Results", + "add-url": "Results", + "add_url": "Results", + "get-url": "url", + "get_url": "url", + "delete-url": "Results", + "delete_url": "Results", + "get-note": "Notes", + "get_note": "Notes", + "add-note": "Results", + "add_note": "Results", + "delete-note": "Results", + "delete_note": "Results", + "get-relationship": "Relationships", + "get_relationship": "Relationships", + "add-relationship": "Results", + "add_relationship": "Results", + "add-file": "Results", + "add_file": "Results", + "delete-file": "Results", + "delete_file": "Results", + "get-metadata": None, + "get_metadata": None, + } + mapped = title_map.get(cmd_name, "Results") + if mapped is not None: + return mapped + + if emitted_items: + first = emitted_items[0] + try: + if isinstance(first, dict) and first.get("title"): + return str(first.get("title")) + if hasattr(first, "title") and getattr(first, "title"): + return str(getattr(first, "title")) + except Exception: + pass + return "Results" + + def execute(self, cmd_name: str, args: List[str]) -> None: import pipeline as ctx - - # Load config - config = _load_cli_config() - - # Initialize debug logging if enabled - if config: - from SYS.logger import set_debug - debug_enabled = config.get("debug", False) - set_debug(debug_enabled) - - # Also configure standard logging for libraries that use it (like local_library.py) - if debug_enabled: - import logging - logging.basicConfig( - level=logging.DEBUG, - format='[%(name)s] %(levelname)s: %(message)s', - stream=sys.stderr - ) + from cmdlet import REGISTRY - # httpx/httpcore can be extremely verbose and will drown useful debug output, - # especially when invoked from MPV (where console output is truncated). - for noisy in ("httpx", "httpcore", "httpcore.http11", "httpcore.connection"): + ensure_registry_loaded() + + cmd_fn = REGISTRY.get(cmd_name) + if not cmd_fn: + # Lazy-import module and register its CMDLET. + try: + mod = import_cmd_module(cmd_name) + data = getattr(mod, "CMDLET", None) if mod else None + if data and hasattr(data, "exec") and callable(getattr(data, "exec")): + run_fn = getattr(data, "exec") + REGISTRY[cmd_name] = run_fn + cmd_fn = run_fn + except Exception: + cmd_fn = None + + if not cmd_fn: + print(f"Unknown command: {cmd_name}\n") + return + + config = self._config_loader.load() + + filtered_args: List[str] = [] + selected_indices: List[int] = [] + select_all = False + + value_flags: Set[str] = set() + try: + meta = get_cmdlet_metadata(cmd_name) + raw = meta.get("raw") if isinstance(meta, dict) else None + arg_specs = getattr(raw, "arg", None) if raw is not None else None + if isinstance(arg_specs, list): + for spec in arg_specs: + spec_type = str(getattr(spec, "type", "string") or "string").strip().lower() + if spec_type == "flag": + continue + spec_name = str(getattr(spec, "name", "") or "") + canonical = spec_name.lstrip("-").strip() + if not canonical: + continue + value_flags.add(f"-{canonical}".lower()) + value_flags.add(f"--{canonical}".lower()) + alias = str(getattr(spec, "alias", "") or "").strip() + if alias: + value_flags.add(f"-{alias}".lower()) + except Exception: + value_flags = set() + + for i, arg in enumerate(args): + if isinstance(arg, str) and arg.startswith("@"): # selection candidate + prev = str(args[i - 1]).lower() if i > 0 else "" + if prev in value_flags: + filtered_args.append(arg) + continue + + if len(arg) >= 2 and arg[1] in {'"', "'"}: + filtered_args.append(arg[1:].strip("\"'")) + continue + + if arg.strip() == "@*": + select_all = True + continue + + selection = SelectionSyntax.parse(arg) + if selection is not None: + zero_based = sorted(idx - 1 for idx in selection) + for idx in zero_based: + if idx not in selected_indices: + selected_indices.append(idx) + continue + + filtered_args.append(arg) + continue + + filtered_args.append(str(arg)) + + piped_items = ctx.get_last_result_items() + result: Any = None + if piped_items: + if select_all: + result = piped_items + elif selected_indices: + result = [piped_items[idx] for idx in selected_indices if 0 <= idx < len(piped_items)] + else: + result = piped_items + + worker_manager = WorkerManagerRegistry.ensure(config) + stage_session = WorkerStages.begin_stage( + worker_manager, + cmd_name=cmd_name, + stage_tokens=[cmd_name, *filtered_args], + config=config, + command_text=" ".join([cmd_name, *filtered_args]).strip() or cmd_name, + ) + + stage_worker_id = stage_session.worker_id if stage_session else None + pipeline_ctx = ctx.PipelineStageContext(stage_index=0, total_stages=1, worker_id=stage_worker_id) + ctx.set_stage_context(pipeline_ctx) + stage_status = "completed" + stage_error = "" + + ctx.set_last_selection(selected_indices) + try: + ret_code = cmd_fn(result, filtered_args, config) + + if getattr(pipeline_ctx, "emits", None): + emits = list(pipeline_ctx.emits) + + # Detect format-selection emits and skip printing (user selects with @N). + is_format_selection = False + if emits: + first_emit = emits[0] + if isinstance(first_emit, dict) and "format_id" in first_emit: + is_format_selection = True + + if is_format_selection: + ctx.set_last_result_items_only(emits) + else: + table_title = self._get_table_title_for_command(cmd_name, emits, filtered_args) + + selectable_commands = { + "search-file", + "download-data", + "download-media", + "search_file", + "download_data", + "download_media", + ".config", + ".worker", + } + display_only_commands = { + "get-url", + "get_url", + "get-note", + "get_note", + "get-relationship", + "get_relationship", + "get-file", + "get_file", + } + self_managing_commands = { + "get-tag", + "get_tag", + "tags", + "search-file", + "search_file", + "search-provider", + "search_provider", + "search-store", + "search_store", + } + + if cmd_name in self_managing_commands: + table = ctx.get_last_result_table() + if table is None: + table = ResultTable(table_title) + for emitted in emits: + table.add_result(emitted) + else: + table = ResultTable(table_title) + for emitted in emits: + table.add_result(emitted) + + if cmd_name in selectable_commands: + table.set_source_command(cmd_name, filtered_args) + ctx.set_last_result_table(table, emits) + ctx.set_current_stage_table(None) + elif cmd_name in display_only_commands: + ctx.set_last_result_items_only(emits) + else: + ctx.set_last_result_items_only(emits) + + print() + print(table.format_plain()) + + if ret_code != 0: + stage_status = "failed" + stage_error = f"exit code {ret_code}" + print(f"[exit code: {ret_code}]\n") + except Exception as exc: + stage_status = "failed" + stage_error = f"{type(exc).__name__}: {exc}" + print(f"[error] {type(exc).__name__}: {exc}\n") + finally: + ctx.clear_last_selection() + if stage_session: + stage_session.close(status=stage_status, error_msg=stage_error) + + +class PipelineExecutor: + def __init__(self, *, config_loader: ConfigLoader) -> None: + self._config_loader = config_loader + self._toolbar_output: Optional[Callable[[str], None]] = None + + def set_toolbar_output(self, output: Optional[Callable[[str], None]]) -> None: + self._toolbar_output = output + + @staticmethod + def _split_stages(tokens: Sequence[str]) -> List[List[str]]: + stages: List[List[str]] = [] + current: List[str] = [] + for token in tokens: + if token == "|": + if current: + stages.append(current) + current = [] + else: + current.append(token) + if current: + stages.append(current) + return stages + + def execute_tokens(self, tokens: List[str]) -> None: + from cmdlet import REGISTRY + import pipeline as ctx + + try: + stages = self._split_stages(tokens) + if not stages: + print("Invalid pipeline syntax\n") + return + + pending_tail = ctx.get_pending_pipeline_tail() if hasattr(ctx, "get_pending_pipeline_tail") else [] + pending_source = ctx.get_pending_pipeline_source() if hasattr(ctx, "get_pending_pipeline_source") else None + + if hasattr(ctx, "get_current_stage_table") and not ctx.get_current_stage_table(): + display_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None + if display_table: + ctx.set_current_stage_table(display_table) + else: + last_table = ctx.get_last_result_table() if hasattr(ctx, "get_last_result_table") else None + if last_table: + ctx.set_current_stage_table(last_table) + + current_source = ( + ctx.get_current_stage_table_source_command() if hasattr(ctx, "get_current_stage_table_source_command") else None + ) + effective_source = current_source or ( + ctx.get_last_result_table_source_command() if hasattr(ctx, "get_last_result_table_source_command") else None + ) + selection_only = len(stages) == 1 and stages[0] and stages[0][0].startswith("@") + if pending_tail and selection_only: + if (pending_source is None) or (effective_source and pending_source == effective_source): + stages.extend(pending_tail) + if hasattr(ctx, "clear_pending_pipeline_tail"): + ctx.clear_pending_pipeline_tail() + elif hasattr(ctx, "clear_pending_pipeline_tail"): + ctx.clear_pending_pipeline_tail() + + config = self._config_loader.load() + if isinstance(config, dict): + config["_quiet_background_output"] = True + + def _resolve_items_for_selection(table_obj, items_list): + return items_list if items_list else [] + + def _maybe_run_class_selector(selected_items: list, *, stage_is_last: bool) -> bool: + if not stage_is_last: + return False + + candidates: list[str] = [] + seen: set[str] = set() + + def _add(value) -> None: try: - logging.getLogger(noisy).setLevel(logging.WARNING) + text = str(value or "").strip().lower() + except Exception: + return + if not text or text in seen: + return + seen.add(text) + candidates.append(text) + + try: + current_table = ctx.get_current_stage_table() or ctx.get_last_result_table() + _add(current_table.table if current_table and hasattr(current_table, "table") else None) + except Exception: + pass + + for item in selected_items or []: + if isinstance(item, dict): + _add(item.get("provider")) + _add(item.get("store")) + _add(item.get("table")) + else: + _add(getattr(item, "provider", None)) + _add(getattr(item, "store", None)) + _add(getattr(item, "table", None)) + + try: + from ProviderCore.registry import get_provider + except Exception: + get_provider = None # type: ignore + + if get_provider is not None: + for key in candidates: + try: + provider = get_provider(key, config) + except Exception: + continue + selector = getattr(provider, "selector", None) + if selector is None: + continue + try: + handled = bool(selector(selected_items, ctx=ctx, stage_is_last=True)) + except Exception as exc: + print(f"{key} selector failed: {exc}\n") + return True + if handled: + return True + + store_keys: list[str] = [] + for item in selected_items or []: + if isinstance(item, dict): + v = item.get("store") + else: + v = getattr(item, "store", None) + name = str(v or "").strip() + if name: + store_keys.append(name) + + if store_keys: + try: + from Store.registry import Store as StoreRegistry + + store_registry = StoreRegistry(config, suppress_debug=True) + _backend_names = list(store_registry.list_backends() or []) + _backend_by_lower = {str(n).lower(): str(n) for n in _backend_names if str(n).strip()} + for name in store_keys: + resolved_name = name + if not store_registry.is_available(resolved_name): + resolved_name = _backend_by_lower.get(str(name).lower(), name) + if not store_registry.is_available(resolved_name): + continue + backend = store_registry[resolved_name] + selector = getattr(backend, "selector", None) + if selector is None: + continue + handled = bool(selector(selected_items, ctx=ctx, stage_is_last=True)) + if handled: + return True except Exception: pass - - # Handle seeds if provided - if seeds_json: + + return False + + first_stage_tokens = stages[0] if stages else [] + first_stage_selection_indices: List[int] = [] + first_stage_had_extra_args = False + first_stage_select_all = False + + if first_stage_tokens: + new_first_stage: List[str] = [] + for token in first_stage_tokens: + if token.startswith("@"): # selection + selection = SelectionSyntax.parse(token) + if selection is not None: + first_stage_selection_indices = sorted([i - 1 for i in selection]) + continue + if token == "@*": + first_stage_select_all = True + continue + new_first_stage.append(token) + + if new_first_stage: + stages[0] = new_first_stage + if first_stage_selection_indices or first_stage_select_all: + first_stage_had_extra_args = True + elif first_stage_selection_indices or first_stage_select_all: + stages.pop(0) + + if first_stage_select_all: + last_items = ctx.get_last_result_items() + if last_items: + first_stage_selection_indices = list(range(len(last_items))) + + piped_result: Any = None + worker_manager = WorkerManagerRegistry.ensure(config) + pipeline_text = " | ".join(" ".join(stage) for stage in stages) + pipeline_session = WorkerStages.begin_pipeline(worker_manager, pipeline_text=pipeline_text, config=config) + + if pipeline_session and worker_manager and isinstance(config, dict): + session_worker_ids = config.get("_session_worker_ids") + if session_worker_ids: + try: + output_fn = self._toolbar_output + quiet_mode = bool(config.get("_quiet_background_output")) + terminal_only = quiet_mode and not output_fn + kwargs: Dict[str, Any] = { + "session_worker_ids": session_worker_ids, + "only_terminal_updates": terminal_only, + "overlay_mode": bool(output_fn), + } + if output_fn: + kwargs["output"] = output_fn + ensure_background_notifier(worker_manager, **kwargs) + except Exception: + pass + + pipeline_status = "completed" + pipeline_error = "" + try: - seeds = json.loads(seeds_json) - # If seeds is a list, use it directly. If single item, wrap in list. - if not isinstance(seeds, list): - seeds = [seeds] - - # Set seeds as the result of a "virtual" previous stage - # This allows the first command in the pipeline to receive them as input - ctx.set_last_result_items_only(seeds) - except Exception as e: - print(f"Error parsing seeds JSON: {e}") + if first_stage_selection_indices: + if not ctx.get_current_stage_table_source_command(): + display_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None + table_for_stage = display_table or ctx.get_last_result_table() + if table_for_stage: + ctx.set_current_stage_table(table_for_stage) + + source_cmd = ctx.get_current_stage_table_source_command() + source_args_raw = ctx.get_current_stage_table_source_args() + if isinstance(source_args_raw, str): + source_args: List[str] = [source_args_raw] + elif isinstance(source_args_raw, list): + source_args = [str(x) for x in source_args_raw if x is not None] + else: + source_args = [] + + current_table = ctx.get_current_stage_table() + table_type = current_table.table if current_table and hasattr(current_table, "table") else None + + command_expanded = False + + if table_type in {"youtube", "soulseek"}: + command_expanded = False + elif source_cmd == "search-file" and source_args and "youtube" in source_args: + command_expanded = False + else: + selected_row_args: List[str] = [] + skip_pipe_expansion = source_cmd == ".pipe" and len(stages) > 0 + if source_cmd and not skip_pipe_expansion: + for idx in first_stage_selection_indices: + row_args = ctx.get_current_stage_table_row_selection_args(idx) + if row_args: + selected_row_args.extend(row_args) + break + + if selected_row_args: + if isinstance(source_cmd, list): + cmd_list: List[str] = [str(x) for x in source_cmd if x is not None] + elif isinstance(source_cmd, str): + cmd_list = [source_cmd] + else: + cmd_list = [] + + expanded_stage: List[str] = cmd_list + source_args + selected_row_args + + if first_stage_had_extra_args and stages: + expanded_stage += stages[0] + stages[0] = expanded_stage + else: + stages.insert(0, expanded_stage) + + if pipeline_session and worker_manager: + try: + worker_manager.log_step( + pipeline_session.worker_id, + f"@N expansion: {source_cmd} + {' '.join(str(x) for x in selected_row_args)}", + ) + except Exception: + pass + + first_stage_selection_indices = [] + command_expanded = True + + if not command_expanded and first_stage_selection_indices: + last_piped_items = ctx.get_last_result_items() + stage_table = ctx.get_current_stage_table() + if not stage_table and hasattr(ctx, "get_display_table"): + stage_table = ctx.get_display_table() + if not stage_table: + stage_table = ctx.get_last_result_table() + + resolved_items = _resolve_items_for_selection(stage_table, last_piped_items) + if last_piped_items: + filtered = [ + resolved_items[i] + for i in first_stage_selection_indices + if 0 <= i < len(resolved_items) + ] + if not filtered: + print("No items matched selection in pipeline\n") + return + + if _maybe_run_class_selector(filtered, stage_is_last=(not stages)): + return + + from cmdlet._shared import coerce_to_pipe_object + + filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered] + piped_result = filtered_pipe_objs if len(filtered_pipe_objs) > 1 else filtered_pipe_objs[0] + + if pipeline_session and worker_manager: + try: + selection_parts = [f"@{i+1}" for i in first_stage_selection_indices] + worker_manager.log_step( + pipeline_session.worker_id, + f"Applied @N selection {' | '.join(selection_parts)}", + ) + except Exception: + pass + + # Auto-insert downloader stages for provider tables. + current_table = ctx.get_current_stage_table() or ctx.get_last_result_table() + table_type = current_table.table if current_table and hasattr(current_table, "table") else None + + if not stages: + if table_type == "youtube": + print("Auto-running YouTube selection via download-media") + stages.append(["download-media"]) + elif table_type in {"soulseek", "openlibrary", "libgen"}: + print("Auto-piping selection to download-file") + stages.append(["download-file"]) + else: + first_cmd = stages[0][0] if stages and stages[0] else None + if table_type == "soulseek" and first_cmd not in ( + "download-file", + "download-media", + "download_media", + ".pipe", + ): + print("Auto-inserting download-file after Soulseek selection") + stages.insert(0, ["download-file"]) + if table_type == "youtube" and first_cmd not in ( + "download-media", + "download_media", + "download-file", + ".pipe", + ): + print("Auto-inserting download-media after YouTube selection") + stages.insert(0, ["download-media"]) + if table_type == "libgen" and first_cmd not in ( + "download-file", + "download-media", + "download_media", + ".pipe", + ): + print("Auto-inserting download-file after Libgen selection") + stages.insert(0, ["download-file"]) + else: + print("No previous results to select from\n") + return + + for stage_index, stage_tokens in enumerate(stages): + if not stage_tokens: + continue + + cmd_name = stage_tokens[0].replace("_", "-").lower() + stage_args = stage_tokens[1:] + + if cmd_name == "@": + subject = ctx.get_last_result_subject() + if subject is None: + print("No current result context available for '@'\n") + pipeline_status = "failed" + pipeline_error = "No result subject for @" + return + piped_result = subject + try: + subject_items = subject if isinstance(subject, list) else [subject] + ctx.set_last_items(subject_items) + except Exception: + pass + if pipeline_session and worker_manager: + try: + worker_manager.log_step(pipeline_session.worker_id, "@ used current table subject") + except Exception: + pass + continue + + if cmd_name.startswith("@"): # selection stage + selection = SelectionSyntax.parse(cmd_name) + is_select_all = cmd_name == "@*" + if selection is None and not is_select_all: + print(f"Invalid selection: {cmd_name}\n") + pipeline_status = "failed" + pipeline_error = f"Invalid selection {cmd_name}" + return + + selected_indices = [] + if is_select_all: + last_items = ctx.get_last_result_items() or [] + selected_indices = list(range(len(last_items))) + else: + selected_indices = sorted([i - 1 for i in selection]) # type: ignore[arg-type] + + stage_table = ctx.get_current_stage_table() + if not stage_table and hasattr(ctx, "get_display_table"): + stage_table = ctx.get_display_table() + if not stage_table: + stage_table = ctx.get_last_result_table() + items_list = ctx.get_last_result_items() or [] + resolved_items = _resolve_items_for_selection(stage_table, items_list) + filtered = [resolved_items[i] for i in selected_indices if 0 <= i < len(resolved_items)] + if not filtered: + print("No items matched selection\n") + pipeline_status = "failed" + pipeline_error = "Empty selection" + return + + if _maybe_run_class_selector(filtered, stage_is_last=(stage_index + 1 >= len(stages))): + return + + # Special case: selecting multiple tags from get-tag and piping into delete-tag + # should batch into a single operation (one backend call). + next_cmd = None + try: + if stage_index + 1 < len(stages) and stages[stage_index + 1]: + next_cmd = str(stages[stage_index + 1][0]).replace("_", "-").lower() + except Exception: + next_cmd = None + + def _is_tag_row(obj: Any) -> bool: + try: + if hasattr(obj, "__class__") and obj.__class__.__name__ == "TagItem" and hasattr(obj, "tag_name"): + return True + except Exception: + pass + try: + if isinstance(obj, dict) and obj.get("tag_name"): + return True + except Exception: + pass + return False + + if next_cmd in {"delete-tag", "delete_tag"} and len(filtered) > 1 and all(_is_tag_row(x) for x in filtered): + from cmdlet._shared import get_field + + tags: List[str] = [] + first_hash = None + first_store = None + first_path = None + for item in filtered: + tag_name = get_field(item, "tag_name") + if tag_name: + tags.append(str(tag_name)) + if first_hash is None: + first_hash = get_field(item, "hash") + if first_store is None: + first_store = get_field(item, "store") + if first_path is None: + first_path = get_field(item, "path") or get_field(item, "target") + + if tags: + grouped = { + "table": "tag.selection", + "media_kind": "tag", + "hash": first_hash, + "store": first_store, + "path": first_path, + "tag": tags, + } + piped_result = grouped + continue + + from cmdlet._shared import coerce_to_pipe_object + + filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered] + piped_result = filtered_pipe_objs if len(filtered_pipe_objs) > 1 else filtered_pipe_objs[0] + + current_table = ctx.get_current_stage_table() or ctx.get_last_result_table() + table_type = current_table.table if current_table and hasattr(current_table, "table") else None + if table_type == "youtube" and stage_index + 1 >= len(stages): + print("Auto-running YouTube selection via download-media") + stages.append(["download-media", *stage_args]) + continue + + ensure_registry_loaded() + cmd_fn = REGISTRY.get(cmd_name) + if not cmd_fn: + print(f"Unknown command: {cmd_name}\n") + pipeline_status = "failed" + pipeline_error = f"Unknown command: {cmd_name}" + return + + stage_session = WorkerStages.begin_stage( + worker_manager, + cmd_name=cmd_name, + stage_tokens=stage_tokens, + config=config, + command_text=" ".join(stage_tokens).strip(), + ) + + stage_worker_id = stage_session.worker_id if stage_session else None + pipeline_ctx = ctx.PipelineStageContext( + stage_index=stage_index, + total_stages=len(stages), + worker_id=stage_worker_id, + ) + ctx.set_stage_context(pipeline_ctx) + stage_status = "completed" + stage_error = "" + + stage_label = f"stage {stage_index + 1}/{len(stages)} ({cmd_name})" + try: + # Avoid leaking interactive selection tables across stages. + # (Selection/expansion happens before this loop, so clearing here is safe.) + try: + if hasattr(ctx, "set_current_stage_table"): + ctx.set_current_stage_table(None) + except Exception: + pass + + ret_code = cmd_fn(piped_result, list(stage_args), config) + + stage_is_last = stage_index + 1 >= len(stages) + + emits: List[Any] = [] + if getattr(pipeline_ctx, "emits", None) is not None: + emits = list(pipeline_ctx.emits or []) + if emits: + # If the cmdlet already installed an overlay table (e.g. get-tag), + # don't overwrite it: set_last_result_items_only() would clear the + # overlay table/subject and break '@' subject piping. + try: + has_overlay = bool(ctx.get_display_table()) if hasattr(ctx, "get_display_table") else False + except Exception: + has_overlay = False + if not has_overlay: + ctx.set_last_result_items_only(emits) + piped_result = emits + else: + piped_result = None + + # Some cmdlets (notably download-media format selection) populate a selectable + # current-stage table without emitting pipeline items. In these cases, render + # the table and pause the pipeline so the user can pick @N. + stage_table = ctx.get_current_stage_table() if hasattr(ctx, "get_current_stage_table") else None + stage_table_type = str(getattr(stage_table, "table", "") or "").strip().lower() if stage_table else "" + if ( + (not stage_is_last) + and (not emits) + and cmd_name in {"download-media", "download_media"} + and stage_table is not None + and hasattr(stage_table, "format_plain") + and stage_table_type in {"ytdlp.formatlist", "download-media", "download_media"} + ): + try: + is_selectable = not bool(getattr(stage_table, "no_choice", False)) + except Exception: + is_selectable = True + + if is_selectable: + try: + already_rendered = bool(getattr(stage_table, "_rendered_by_cmdlet", False)) + except Exception: + already_rendered = False + + if not already_rendered: + print() + print(stage_table.format_plain()) + + try: + remaining = stages[stage_index + 1 :] + source_cmd = ( + ctx.get_current_stage_table_source_command() + if hasattr(ctx, "get_current_stage_table_source_command") + else None + ) + if remaining and hasattr(ctx, "set_pending_pipeline_tail"): + ctx.set_pending_pipeline_tail(remaining, source_command=source_cmd or cmd_name) + except Exception: + pass + return + + # For the final stage, many cmdlets rely on the runner to render the + # table they placed into pipeline context (e.g. get-tag). Prefer a + # display table if one exists, otherwise the current-stage table. + if stage_is_last: + final_table = None + try: + final_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None + except Exception: + final_table = None + if final_table is None: + final_table = stage_table + + if final_table is not None and hasattr(final_table, "format_plain"): + try: + already_rendered = bool(getattr(final_table, "_rendered_by_cmdlet", False)) + except Exception: + already_rendered = False + + if not already_rendered: + print() + print(final_table.format_plain()) + + # Fallback: if a cmdlet emitted results but did not provide a table, + # render a standard ResultTable so last-stage pipelines still show output. + if final_table is None and emits: + try: + table_title = CmdletExecutor._get_table_title_for_command(cmd_name, emits, list(stage_args)) + except Exception: + table_title = "Results" + table = ResultTable(table_title) + for item in emits: + table.add_result(item) + print() + print(table.format_plain()) + + if isinstance(ret_code, int) and ret_code != 0: + stage_status = "failed" + stage_error = f"exit code {ret_code}" + print(f"[{stage_label} exit code: {ret_code}]\n") + pipeline_status = "failed" + pipeline_error = f"{stage_label} failed ({stage_error})" + return + except Exception as exc: + stage_status = "failed" + stage_error = f"{type(exc).__name__}: {exc}" + print(f"[error in {stage_label}]: {stage_error}\n") + pipeline_status = "failed" + pipeline_error = f"{stage_label} error: {exc}" + return + finally: + if stage_session: + stage_session.close(status=stage_status, error_msg=stage_error) + elif pipeline_session and worker_manager: + try: + worker_manager.log_step( + pipeline_session.worker_id, + f"{stage_label} {'completed' if stage_status == 'completed' else 'failed'}", + ) + except Exception: + pass + + if not stages and piped_result is not None: + table = ResultTable("Selection Result") + items = piped_result if isinstance(piped_result, list) else [piped_result] + for item in items: + table.add_result(item) + ctx.set_last_result_items_only(items) + print() + print(table.format_plain()) + except Exception as exc: + pipeline_status = "failed" + pipeline_error = str(exc) + print(f"[error] Failed to execute pipeline: {exc}\n") + finally: + if pipeline_session: + pipeline_session.close(status=pipeline_status, error_msg=pipeline_error) + except Exception as exc: + print(f"[error] Failed to execute pipeline: {exc}\n") + + +class MedeiaCLI: + """Main CLI application object.""" + + ROOT = Path(__file__).resolve().parent + + def __init__(self) -> None: + self._config_loader = ConfigLoader(root=self.ROOT) + self._cmdlet_executor = CmdletExecutor(config_loader=self._config_loader) + self._pipeline_executor = PipelineExecutor(config_loader=self._config_loader) + + @staticmethod + def parse_selection_syntax(token: str) -> Optional[Set[int]]: + return SelectionSyntax.parse(token) + + @classmethod + def get_store_choices(cls) -> List[str]: + loader = ConfigLoader(root=cls.ROOT) + return CmdletIntrospection.store_choices(loader.load()) + + def build_app(self) -> typer.Typer: + app = typer.Typer(help="Medeia-Macina CLI") + + def _complete_search_provider(ctx, param, incomplete: str): # pragma: no cover + try: + from click.shell_completion import CompletionItem + except Exception: + return [] + + try: + from ProviderCore.registry import list_search_providers + + providers = list_search_providers(self._config_loader.load()) or {} + available = [n for n, ok in providers.items() if ok] + choices = sorted(available) if available else sorted(providers.keys()) + except Exception: + choices = [] + + inc = (incomplete or "").lower() + return [CompletionItem(name) for name in choices if name and name.lower().startswith(inc)] + + @app.command("search-provider") + def search_provider( + provider: str = typer.Option( + ..., "--provider", "-p", help="Provider name (bandcamp, libgen, soulseek, youtube)", shell_complete=_complete_search_provider + ), + query: str = typer.Argument(..., help="Search query (quote for spaces)"), + limit: int = typer.Option(36, "--limit", "-l", help="Maximum results to return"), + ) -> None: + self._cmdlet_executor.execute("search-provider", ["-provider", provider, query, "-limit", str(limit)]) + + @app.command("pipeline") + def pipeline( + command: str = typer.Option(..., "--pipeline", "-p", help="Pipeline command string to execute"), + seeds_json: Optional[str] = typer.Option(None, "--seeds-json", "-s", help="JSON string of seed items"), + ) -> None: + import pipeline as ctx + + config = self._config_loader.load() + debug_enabled = bool(config.get("debug", False)) + set_debug(debug_enabled) + + if seeds_json: + try: + seeds = json.loads(seeds_json) + if not isinstance(seeds, list): + seeds = [seeds] + ctx.set_last_result_items_only(seeds) + except Exception as exc: + print(f"Error parsing seeds JSON: {exc}") + return + + try: + from cli_syntax import validate_pipeline_text + + syntax_error = validate_pipeline_text(command) + if syntax_error: + print(syntax_error.message, file=sys.stderr) + return + except Exception: + pass + + try: + tokens = shlex.split(command) + except ValueError as exc: + print(f"Syntax error: {exc}", file=sys.stderr) return - try: - from cli_syntax import validate_pipeline_text - syntax_error = validate_pipeline_text(command) - if syntax_error: - print(syntax_error.message, file=sys.stderr) + if not tokens: return - except Exception: - # Best-effort only; if validator can't load, fall back to shlex handling below. - pass + self._pipeline_executor.execute_tokens(tokens) - try: - tokens = shlex.split(command) - except ValueError as exc: - print(f"Syntax error: {exc}", file=sys.stderr) - return - - if not tokens: - return + @app.command("repl") + def repl() -> None: + self.run_repl() - # Execute - _execute_pipeline(tokens) + @app.callback(invoke_without_command=True) + def main_callback(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + self.run_repl() - @app.command("repl") - def repl(): - """Start interactive REPL for cmdlet with autocomplete.""" - banner = """ + _ = (search_provider, pipeline, repl, main_callback) + + return app + + def run(self) -> None: + self.build_app()() + + def run_repl(self) -> None: + banner = r""" Medeia-Macina ===================== |123456789|ABCDEFGHI| @@ -930,18 +1911,13 @@ def _create_cmdlet_cli(): ===================== """ print(banner) - - # Configurable prompt + prompt_text = "🜂🜄🜁🜃|" - # Prepare startup table (always attempt; fall back gracefully if import fails) - startup_table = None - if RESULT_TABLE_AVAILABLE and ResultTable is not None: - startup_table = ResultTable( - "*********************************************" - ) - if startup_table: - startup_table.set_no_choice(True).set_preserve_order(True) + startup_table = ResultTable( + "*********************************************" + ) + startup_table.set_no_choice(True).set_preserve_order(True) def _add_startup_check( status: str, @@ -952,8 +1928,6 @@ def _create_cmdlet_cli(): files: int | str | None = None, detail: str = "", ) -> None: - if startup_table is None: - return row = startup_table.add_row() row.add_column("Status", status) row.add_column("Name", name) @@ -990,530 +1964,397 @@ def _create_cmdlet_cli(): except Exception as exc: return False, f"{url} ({type(exc).__name__})" - # Load config and initialize debug logging - config = {} - try: - config = _load_cli_config() - except Exception: - config = {} + config = self._config_loader.load() + debug_enabled = bool(config.get("debug", False)) + set_debug(debug_enabled) + if debug_enabled: + debug("✓ Debug logging enabled") try: - if config: - from SYS.logger import set_debug, debug - debug_enabled = config.get("debug", False) - set_debug(debug_enabled) - if debug_enabled: - debug("✓ Debug logging enabled") - - try: - from API.HydrusNetwork import get_client - # get_client(config) # Pre-acquire and cache session key - # debug("✓ Hydrus session key acquired") - except RuntimeError: - # Hydrus is not available - expected sometimes; continue - pass - except Exception as e: - debug(f"⚠ Could not pre-acquire Hydrus session key: {e}") - - # Run startup checks and render table try: - # MPV availability is validated by MPV.MPV.__init__. + from MPV.mpv_ipc import MPV + import shutil + + MPV() + mpv_path = shutil.which("mpv") + _add_startup_check("ENABLED", "MPV", detail=mpv_path or "Available") + except Exception as exc: + _add_startup_check("DISABLED", "MPV", detail=str(exc)) + + store_registry = None + if config: try: - from MPV.mpv_ipc import MPV + from Store import Store as StoreRegistry - MPV() - try: - import shutil + store_registry = StoreRegistry(config=config, suppress_debug=True) + except Exception: + store_registry = None - mpv_path = shutil.which("mpv") - except Exception: - mpv_path = None + if _has_store_subtype(config, "hydrusnetwork"): + store_cfg = config.get("store") + hydrus_cfg = store_cfg.get("hydrusnetwork", {}) if isinstance(store_cfg, dict) else {} + if isinstance(hydrus_cfg, dict): + for instance_name, instance_cfg in hydrus_cfg.items(): + if not isinstance(instance_cfg, dict): + continue + name_key = str(instance_cfg.get("NAME") or instance_name) + url_val = str(instance_cfg.get("URL") or "").strip() - _add_startup_check("ENABLED", "MPV", detail=mpv_path or "Available") - except Exception as exc: - _add_startup_check("DISABLED", "MPV", detail=str(exc)) - - store_registry = None - - if config: - # Instantiate store registry once; store __init__ performs its own validation. - try: - from Store import Store as StoreRegistry - - store_registry = StoreRegistry(config=config, suppress_debug=True) - except Exception: - store_registry = None - - # Only show checks that are configured in config.conf - if _has_store_subtype(config, "hydrusnetwork"): - # HydrusNetwork self-validates in its __init__. We derive instance status from - # store instantiation rather than a separate Hydrus-specific health check. - store_cfg = config.get("store") - hydrus_cfg = store_cfg.get("hydrusnetwork", {}) if isinstance(store_cfg, dict) else {} - if isinstance(hydrus_cfg, dict): - for instance_name, instance_cfg in hydrus_cfg.items(): - if not isinstance(instance_cfg, dict): - continue - name_key = str(instance_cfg.get("NAME") or instance_name) - url_val = str(instance_cfg.get("URL") or "").strip() - - ok = bool(store_registry and store_registry.is_available(name_key)) - status = "ENABLED" if ok else "DISABLED" - if ok: - total = None - try: - if store_registry: - backend = store_registry[name_key] - total = getattr(backend, "total_count", None) - if total is None: - getter = getattr(backend, "get_total_count", None) - if callable(getter): - total = getter() - except Exception: - total = None - - detail = url_val - files = total if isinstance(total, int) and total >= 0 else None - else: - err = None - if store_registry: - err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key) - detail = (url_val + (" - " if url_val else "")) + (err or "Unavailable") - files = None - _add_startup_check(status, name_key, store="hydrusnetwork", files=files, detail=detail) - - # Configured providers (dynamic): show any [provider=...] blocks. - # This complements store checks and avoids hardcoding per-provider rows. - provider_cfg = config.get("provider") if isinstance(config, dict) else None - if isinstance(provider_cfg, dict) and provider_cfg: - try: - from ProviderCore.registry import ( - list_providers, - list_search_providers, - list_file_providers, - ) - except Exception: - list_providers = None # type: ignore - list_search_providers = None # type: ignore - list_file_providers = None # type: ignore - - try: - from Provider.metadata_provider import list_metadata_providers - except Exception: - list_metadata_providers = None # type: ignore - - search_availability = {} - file_availability = {} - meta_availability = {} - provider_availability = {} - - try: - if list_providers is not None: - provider_availability = list_providers(config) or {} - except Exception: - provider_availability = {} - - try: - if list_search_providers is not None: - search_availability = list_search_providers(config) or {} - except Exception: - search_availability = {} - - try: - if list_file_providers is not None: - file_availability = list_file_providers(config) or {} - except Exception: - file_availability = {} - - try: - if list_metadata_providers is not None: - meta_availability = list_metadata_providers(config) or {} - except Exception: - meta_availability = {} - - def _provider_display_name(key: str) -> str: - k = (key or "").strip() - low = k.lower() - if low == "openlibrary": - return "OpenLibrary" - if low == "alldebrid": - return "AllDebrid" - if low == "youtube": - return "YouTube" - return k[:1].upper() + k[1:] if k else "Provider" - - # Avoid duplicating the existing Matrix row. - already_checked = {"matrix"} - - def _default_provider_ping_targets(provider_key: str) -> list[str]: - prov = (provider_key or "").strip().lower() - if prov == "openlibrary": - return ["https://openlibrary.org"] - if prov == "youtube": - return ["https://www.youtube.com"] - if prov == "bandcamp": - return ["https://bandcamp.com"] - if prov == "libgen": + ok = bool(store_registry and store_registry.is_available(name_key)) + status = "ENABLED" if ok else "DISABLED" + if ok: + total = None try: - from Provider.libgen import MIRRORS - - mirrors = [str(x).rstrip("/") for x in (MIRRORS or []) if str(x).strip()] - return [m + "/json.php" for m in mirrors] + if store_registry: + backend = store_registry[name_key] + total = getattr(backend, "total_count", None) + if total is None: + getter = getattr(backend, "get_total_count", None) + if callable(getter): + total = getter() except Exception: - return [] - return [] - - def _ping_first(urls: list[str]) -> tuple[bool, str]: - for u in urls: - ok, detail = _ping_url(u) - if ok: - return True, detail - if urls: - ok, detail = _ping_url(urls[0]) - return ok, detail - return False, "No ping target" - - for provider_name in provider_cfg.keys(): - prov = str(provider_name or "").strip().lower() - if not prov or prov in already_checked: - continue - - display = _provider_display_name(prov) - - # Special-case AllDebrid to show a richer detail and validate connectivity. - if prov == "alldebrid": - try: - from Provider.alldebrid import _get_debrid_api_key # type: ignore - - api_key = _get_debrid_api_key(config) - if not api_key: - _add_startup_check("DISABLED", display, provider=prov, detail="Not configured") - else: - from API.alldebrid import AllDebridClient - - client = AllDebridClient(api_key) - base_url = str(getattr(client, "base_url", "") or "").strip() - _add_startup_check("ENABLED", display, provider=prov, detail=base_url or "Connected") - except Exception as exc: - _add_startup_check("DISABLED", display, provider=prov, detail=str(exc)) - continue - - is_known = False - ok = None - - # Prefer unified provider registry for availability (covers providers that - # implement download-only behavior, like Telegram). - if prov in provider_availability: - is_known = True - ok = bool(provider_availability.get(prov)) - elif prov in search_availability: - is_known = True - ok = bool(search_availability.get(prov)) - elif prov in file_availability: - is_known = True - ok = bool(file_availability.get(prov)) - elif prov in meta_availability: - is_known = True - ok = bool(meta_availability.get(prov)) - - if not is_known: - _add_startup_check("UNKNOWN", display, provider=prov, detail="Not registered") + total = None + detail = url_val + files = total if isinstance(total, int) and total >= 0 else None else: - # For non-login providers, include a lightweight URL reachability check. - detail = "Configured" if ok else "Not configured" - ping_targets = _default_provider_ping_targets(prov) - if ping_targets: - ping_ok, ping_detail = _ping_first(ping_targets) - if ok: - detail = ping_detail - else: - detail = (detail + " | " + ping_detail) if ping_detail else detail - _add_startup_check("ENABLED" if ok else "DISABLED", display, provider=prov, detail=detail) + err = None + if store_registry: + err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key) + detail = (url_val + (" - " if url_val else "")) + (err or "Unavailable") + files = None + _add_startup_check(status, name_key, store="hydrusnetwork", files=files, detail=detail) - already_checked.add(prov) + provider_cfg = config.get("provider") if isinstance(config, dict) else None + if isinstance(provider_cfg, dict) and provider_cfg: + from Provider.metadata_provider import list_metadata_providers + from ProviderCore.registry import list_file_providers, list_providers, list_search_providers - # Also show default non-login providers even if they aren't configured. - # This helps users know what's available/reachable out of the box. - default_search_providers = ["openlibrary", "libgen", "youtube", "bandcamp"] - for prov in default_search_providers: - if prov in already_checked: - continue - display = _provider_display_name(prov) - ok = bool(search_availability.get(prov)) if prov in search_availability else False - ping_targets = _default_provider_ping_targets(prov) - ping_ok, ping_detail = _ping_first(ping_targets) if ping_targets else (False, "No ping target") - detail = ping_detail if ping_detail else ("Available" if ok else "Unavailable") - # If the provider isn't even import/dep available, show that first. - if not ok: - detail = ("Unavailable" + (f" | {ping_detail}" if ping_detail else "")) - _add_startup_check("ENABLED" if (ok and ping_ok) else "DISABLED", display, provider=prov, detail=detail) - already_checked.add(prov) + provider_availability = list_providers(config) or {} + search_availability = list_search_providers(config) or {} + file_availability = list_file_providers(config) or {} + meta_availability = list_metadata_providers(config) or {} - # Default file providers (no login): 0x0 - if "0x0" not in already_checked: - ok = bool(file_availability.get("0x0")) if "0x0" in file_availability else False - ping_ok, ping_detail = _ping_url("https://0x0.st") - detail = ping_detail - if not ok: - detail = ("Unavailable" + (f" | {ping_detail}" if ping_detail else "")) - _add_startup_check("ENABLED" if (ok and ping_ok) else "DISABLED", "0x0", provider="0x0", detail=detail) - already_checked.add("0x0") + def _provider_display_name(key: str) -> str: + k = (key or "").strip() + low = k.lower() + if low == "openlibrary": + return "OpenLibrary" + if low == "alldebrid": + return "AllDebrid" + if low == "youtube": + return "YouTube" + return k[:1].upper() + k[1:] if k else "Provider" - if _has_provider(config, "matrix"): - # Matrix availability is validated by Provider.matrix.Matrix.__init__. - try: - from Provider.matrix import Matrix + already_checked = {"matrix"} - provider = Matrix(config) - matrix_conf = config.get("provider", {}).get("matrix", {}) if isinstance(config, dict) else {} - homeserver = str(matrix_conf.get("homeserver") or "").strip() - room_id = str(matrix_conf.get("room_id") or "").strip() - if homeserver and not homeserver.startswith("http"): - homeserver = f"https://{homeserver}" - target = homeserver.rstrip("/") - if room_id: - target = (target + (" " if target else "")) + f"room:{room_id}" + def _default_provider_ping_targets(provider_key: str) -> list[str]: + prov = (provider_key or "").strip().lower() + if prov == "openlibrary": + return ["https://openlibrary.org"] + if prov == "youtube": + return ["https://www.youtube.com"] + if prov == "bandcamp": + return ["https://bandcamp.com"] + if prov == "libgen": + from Provider.libgen import MIRRORS - if provider.validate(): - _add_startup_check("ENABLED", "Matrix", provider="matrix", detail=target or "Connected") - else: - missing: list[str] = [] - if not homeserver: - missing.append("homeserver") - if not room_id: - missing.append("room_id") - if not (matrix_conf.get("access_token") or matrix_conf.get("password")): - missing.append("access_token/password") - detail = "Not configured" + (f" ({', '.join(missing)})" if missing else "") - _add_startup_check("DISABLED", "Matrix", provider="matrix", detail=detail) - except Exception as exc: - _add_startup_check("DISABLED", "Matrix", provider="matrix", detail=str(exc)) + mirrors = [str(x).rstrip("/") for x in (MIRRORS or []) if str(x).strip()] + return [m + "/json.php" for m in mirrors] + return [] - if _has_store_subtype(config, "folder"): - # Folder local scan/index is performed by Store.Folder.__init__. - store_cfg = config.get("store") - folder_cfg = store_cfg.get("folder", {}) if isinstance(store_cfg, dict) else {} - if isinstance(folder_cfg, dict) and folder_cfg: - for instance_name, instance_cfg in folder_cfg.items(): - if not isinstance(instance_cfg, dict): - continue - name_key = str(instance_cfg.get("NAME") or instance_name) - path_val = str(instance_cfg.get("PATH") or instance_cfg.get("path") or "").strip() + def _ping_first(urls: list[str]) -> tuple[bool, str]: + for u in urls: + ok, detail = _ping_url(u) + if ok: + return True, detail + if urls: + ok, detail = _ping_url(urls[0]) + return ok, detail + return False, "No ping target" - ok = bool(store_registry and store_registry.is_available(name_key)) - if ok and store_registry: - backend = store_registry[name_key] - scan_ok = bool(getattr(backend, "scan_ok", True)) - scan_detail = str(getattr(backend, "scan_detail", "") or "") - stats = getattr(backend, "scan_stats", None) - files = None - if isinstance(stats, dict): - try: - total_db = stats.get("files_total_db") - if isinstance(total_db, (int, float)): - files = int(total_db) - except Exception: - files = None - status = "SCANNED" if scan_ok else "ERROR" - detail = (path_val + (" - " if path_val else "")) + (scan_detail or "Up to date") - _add_startup_check(status, name_key, store="folder", files=files, detail=detail) - else: - err = None - if store_registry: - err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key) - detail = (path_val + (" - " if path_val else "")) + (err or "Unavailable") - _add_startup_check("ERROR", name_key, store="folder", detail=detail) - else: - _add_startup_check("SKIPPED", "Folder", store="folder", detail="No folder stores configured") + for provider_name in provider_cfg.keys(): + prov = str(provider_name or "").strip().lower() + if not prov or prov in already_checked: + continue + display = _provider_display_name(prov) - if _has_store_subtype(config, "debrid"): - # Debrid availability is validated by API.alldebrid.AllDebridClient.__init__. - try: - from config import get_debrid_api_key - - api_key = get_debrid_api_key(config) - if not api_key: - _add_startup_check("DISABLED", "Debrid", store="debrid", detail="Not configured") - else: + if prov == "alldebrid": + try: + from Provider.alldebrid import _get_debrid_api_key from API.alldebrid import AllDebridClient - client = AllDebridClient(api_key) - base_url = str(getattr(client, "base_url", "") or "").strip() - _add_startup_check("ENABLED", "Debrid", store="debrid", detail=base_url or "Connected") - except Exception as exc: - _add_startup_check("DISABLED", "Debrid", store="debrid", detail=str(exc)) + api_key = _get_debrid_api_key(config) + if not api_key: + _add_startup_check("DISABLED", display, provider=prov, detail="Not configured") + else: + client = AllDebridClient(api_key) + base_url = str(getattr(client, "base_url", "") or "").strip() + _add_startup_check("ENABLED", display, provider=prov, detail=base_url or "Connected") + except Exception as exc: + _add_startup_check("DISABLED", display, provider=prov, detail=str(exc)) + continue - # Cookies are used by yt-dlp; keep this centralized utility. - try: - from tool.ytdlp import YtDlpTool + is_known = False + ok_val: Optional[bool] = None + if prov in provider_availability: + is_known = True + ok_val = bool(provider_availability.get(prov)) + elif prov in search_availability: + is_known = True + ok_val = bool(search_availability.get(prov)) + elif prov in file_availability: + is_known = True + ok_val = bool(file_availability.get(prov)) + elif prov in meta_availability: + is_known = True + ok_val = bool(meta_availability.get(prov)) - cookiefile = YtDlpTool(config).resolve_cookiefile() - if cookiefile is not None: - _add_startup_check("FOUND", "Cookies", detail=str(cookiefile)) - else: - _add_startup_check("MISSING", "Cookies", detail="Not found") - except Exception as exc: - _add_startup_check("ERROR", "Cookies", detail=str(exc)) + if not is_known: + _add_startup_check("UNKNOWN", display, provider=prov, detail="Not registered") + else: + detail = "Configured" if ok_val else "Not configured" + ping_targets = _default_provider_ping_targets(prov) + if ping_targets: + ping_ok, ping_detail = _ping_first(ping_targets) + if ok_val: + detail = ping_detail + else: + detail = (detail + " | " + ping_detail) if ping_detail else detail + _add_startup_check("ENABLED" if ok_val else "DISABLED", display, provider=prov, detail=detail) - if startup_table is not None and startup_table.rows: - print() - print(startup_table.format_plain()) + already_checked.add(prov) - except Exception as e: - if config: - from SYS.logger import debug # local import to avoid failing when debug disabled - debug(f"⚠ Could not check service availability: {e}") - except Exception: - pass # Silently ignore if config loading fails - - if PROMPT_TOOLKIT_AVAILABLE and PromptSession is not None and CmdletCompleter is not None and Style is not None: - completer = CmdletCompleter() - - # Define style for syntax highlighting - style = Style.from_dict({ - 'cmdlet': '#ffffff', # white - 'argument': '#3b8eea', # blue-ish - 'value': "#9a3209", # red-ish - 'string': "#6d0d93", # purple - 'pipe': '#4caf50', # green - 'bottom-toolbar': 'noreverse', # Blend in with default background - }) - - # Toolbar state for background notifications - class ToolbarState: - text = "" - last_update_time: float = 0.0 - clear_timer: Optional[threading.Timer] = None - - toolbar_state = ToolbarState() - - def get_toolbar(): - # Only show toolbar if there's text AND it's within the 3-second window - if not toolbar_state.text or not toolbar_state.text.strip(): - return None # None completely hides the toolbar - elapsed = time.time() - toolbar_state.last_update_time - if elapsed > 3: + default_search_providers = ["openlibrary", "libgen", "youtube", "bandcamp"] + for prov in default_search_providers: + if prov in already_checked: + continue + display = _provider_display_name(prov) + ok_val = bool(search_availability.get(prov)) if prov in search_availability else False + ping_targets = _default_provider_ping_targets(prov) + ping_ok, ping_detail = _ping_first(ping_targets) if ping_targets else (False, "No ping target") + detail = ping_detail or ("Available" if ok_val else "Unavailable") + if not ok_val: + detail = ("Unavailable" + (f" | {ping_detail}" if ping_detail else "")) + _add_startup_check("ENABLED" if (ok_val and ping_ok) else "DISABLED", display, provider=prov, detail=detail) + already_checked.add(prov) + + if "0x0" not in already_checked: + ok_val = bool(file_availability.get("0x0")) if "0x0" in file_availability else False + ping_ok, ping_detail = _ping_url("https://0x0.st") + detail = ping_detail + if not ok_val: + detail = ("Unavailable" + (f" | {ping_detail}" if ping_detail else "")) + _add_startup_check("ENABLED" if (ok_val and ping_ok) else "DISABLED", "0x0", provider="0x0", detail=detail) + + if _has_provider(config, "matrix"): + try: + from Provider.matrix import Matrix + + provider = Matrix(config) + matrix_conf = config.get("provider", {}).get("matrix", {}) if isinstance(config, dict) else {} + homeserver = str(matrix_conf.get("homeserver") or "").strip() + room_id = str(matrix_conf.get("room_id") or "").strip() + if homeserver and not homeserver.startswith("http"): + homeserver = f"https://{homeserver}" + target = homeserver.rstrip("/") + if room_id: + target = (target + (" " if target else "")) + f"room:{room_id}" + _add_startup_check( + "ENABLED" if provider.validate() else "DISABLED", + "Matrix", + provider="matrix", + detail=target or ("Connected" if provider.validate() else "Not configured"), + ) + except Exception as exc: + _add_startup_check("DISABLED", "Matrix", provider="matrix", detail=str(exc)) + + if _has_store_subtype(config, "folder"): + store_cfg = config.get("store") + folder_cfg = store_cfg.get("folder", {}) if isinstance(store_cfg, dict) else {} + if isinstance(folder_cfg, dict) and folder_cfg: + for instance_name, instance_cfg in folder_cfg.items(): + if not isinstance(instance_cfg, dict): + continue + name_key = str(instance_cfg.get("NAME") or instance_name) + path_val = str(instance_cfg.get("PATH") or instance_cfg.get("path") or "").strip() + + ok = bool(store_registry and store_registry.is_available(name_key)) + if ok and store_registry: + backend = store_registry[name_key] + scan_ok = bool(getattr(backend, "scan_ok", True)) + scan_detail = str(getattr(backend, "scan_detail", "") or "") + stats = getattr(backend, "scan_stats", None) + files = None + if isinstance(stats, dict): + total_db = stats.get("files_total_db") + if isinstance(total_db, (int, float)): + files = int(total_db) + status = "SCANNED" if scan_ok else "ERROR" + detail = (path_val + (" - " if path_val else "")) + (scan_detail or "Up to date") + _add_startup_check(status, name_key, store="folder", files=files, detail=detail) + else: + err = None + if store_registry: + err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key) + detail = (path_val + (" - " if path_val else "")) + (err or "Unavailable") + _add_startup_check("ERROR", name_key, store="folder", detail=detail) + + if _has_store_subtype(config, "debrid"): + try: + from config import get_debrid_api_key + from API.alldebrid import AllDebridClient + + api_key = get_debrid_api_key(config) + if not api_key: + _add_startup_check("DISABLED", "Debrid", store="debrid", detail="Not configured") + else: + client = AllDebridClient(api_key) + base_url = str(getattr(client, "base_url", "") or "").strip() + _add_startup_check("ENABLED", "Debrid", store="debrid", detail=base_url or "Connected") + except Exception as exc: + _add_startup_check("DISABLED", "Debrid", store="debrid", detail=str(exc)) + + try: + from tool.ytdlp import YtDlpTool + + cookiefile = YtDlpTool(config).resolve_cookiefile() + if cookiefile is not None: + _add_startup_check("FOUND", "Cookies", detail=str(cookiefile)) + else: + _add_startup_check("MISSING", "Cookies", detail="Not found") + except Exception as exc: + _add_startup_check("ERROR", "Cookies", detail=str(exc)) + + if startup_table.rows: + print() + print(startup_table.format_plain()) + except Exception as exc: + if debug_enabled: + debug(f"⚠ Could not check service availability: {exc}") + + style = Style.from_dict( + { + "cmdlet": "#ffffff", + "argument": "#3b8eea", + "value": "#9a3209", + "string": "#6d0d93", + "pipe": "#4caf50", + "selection_at": "#f1c40f", + "selection_range": "#4caf50", + "bottom-toolbar": "noreverse", + } + ) + + class ToolbarState: + text: str = "" + last_update_time: float = 0.0 + clear_timer: Optional[threading.Timer] = None + + toolbar_state = ToolbarState() + session: Optional[PromptSession] = None + + def get_toolbar() -> Optional[str]: + if not toolbar_state.text or not toolbar_state.text.strip(): + return None + if time.time() - toolbar_state.last_update_time > 3: + toolbar_state.text = "" + return None + return toolbar_state.text + + def update_toolbar(text: str) -> None: + nonlocal session + text = text.strip() + toolbar_state.text = text + toolbar_state.last_update_time = time.time() + + if toolbar_state.clear_timer: + toolbar_state.clear_timer.cancel() + toolbar_state.clear_timer = None + + if text: + def clear_toolbar() -> None: toolbar_state.text = "" - return None - return toolbar_state.text - - def update_toolbar(text: str): - text = text.strip() - toolbar_state.text = text - toolbar_state.last_update_time = time.time() - - # Cancel any pending clear timer - if toolbar_state.clear_timer: - toolbar_state.clear_timer.cancel() toolbar_state.clear_timer = None - - # Schedule auto-clear in 3 seconds - if text: - def clear_toolbar(): - toolbar_state.text = "" - toolbar_state.clear_timer = None - if 'session' in locals() and session and hasattr(session, 'app') and session.app.is_running: - session.app.invalidate() - - toolbar_state.clear_timer = threading.Timer(3.0, clear_toolbar) - toolbar_state.clear_timer.daemon = True - toolbar_state.clear_timer.start() - - # Force redraw if the prompt is active - if 'session' in locals() and session and hasattr(session, 'app') and session.app.is_running: - session.app.invalidate() - - # Register global updater - global _TOOLBAR_UPDATER - _TOOLBAR_UPDATER = update_toolbar - - session = PromptSession( - completer=cast(Any, completer), - lexer=MedeiaLexer(), - style=style, - bottom_toolbar=get_toolbar, - refresh_interval=0.5, # Refresh periodically - ) + if session is not None and hasattr(session, "app") and session.app.is_running: + session.app.invalidate() - def get_input(prompt: str = prompt_text) -> str: - return session.prompt(prompt) + toolbar_state.clear_timer = threading.Timer(3.0, clear_toolbar) + toolbar_state.clear_timer.daemon = True + toolbar_state.clear_timer.start() + + if session is not None and hasattr(session, "app") and session.app.is_running: + session.app.invalidate() + + self._pipeline_executor.set_toolbar_output(update_toolbar) + + completer = CmdletCompleter(config_loader=self._config_loader) + session = PromptSession( + completer=cast(Any, completer), + lexer=MedeiaLexer(), + style=style, + bottom_toolbar=get_toolbar, + refresh_interval=0.5, + ) - else: - def get_input(prompt: str = prompt_text) -> str: - return input(prompt) - while True: - try: - user_input = get_input(prompt_text).strip() + try: + user_input = session.prompt(prompt_text).strip() except (EOFError, KeyboardInterrupt): print("He who is victorious through deceit is defeated by the truth.") break - + if not user_input: continue - + low = user_input.lower() if low in {"exit", "quit", "q"}: print("He who is victorious through deceit is defeated by the truth.") break - if low in {"help", "?"}: - _show_cmdlet_list() + CmdletHelp.show_cmdlet_list() continue - + pipeline_ctx_ref = None try: - import pipeline as ctx # noqa: F401 + import pipeline as ctx ctx.set_current_command_text(user_input) pipeline_ctx_ref = ctx except Exception: pipeline_ctx_ref = None - + try: from cli_syntax import validate_pipeline_text + syntax_error = validate_pipeline_text(user_input) if syntax_error: print(syntax_error.message, file=sys.stderr) continue except Exception: - # Best-effort only; if validator can't load, continue with shlex. pass try: - import shlex tokens = shlex.split(user_input) except ValueError as exc: print(f"Syntax error: {exc}", file=sys.stderr) continue - + if not tokens: continue - - # Handle special @,, selector to restore next result table (forward navigation) + if len(tokens) == 1 and tokens[0] == "@,,": try: import pipeline as ctx if ctx.restore_next_result_table(): - # Check for overlay table first - if hasattr(ctx, 'get_display_table'): - last_table = ctx.get_display_table() - else: - last_table = None - + last_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None if last_table is None: last_table = ctx.get_last_result_table() - - if last_table: print() - # Also update current stage table so @N expansion works correctly ctx.set_current_stage_table(last_table) print(last_table.format_plain()) else: - # Fallback to items if no table object items = ctx.get_last_result_items() if items: - # Clear current stage table if we only have items ctx.set_current_stage_table(None) print(f"Restored {len(items)} items (no table format available)") else: @@ -1521,1611 +2362,45 @@ def _create_cmdlet_cli(): except Exception as exc: print(f"Error restoring next table: {exc}", file=sys.stderr) continue - - # Handle special @.. selector to restore previous result table - if len(tokens) == 1 and tokens[0] == "@..": + if len(tokens) == 1 and tokens[0] == "@..": try: import pipeline as ctx if ctx.restore_previous_result_table(): - # Check for overlay table first - if hasattr(ctx, 'get_display_table'): - last_table = ctx.get_display_table() - else: - last_table = None - + last_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None if last_table is None: last_table = ctx.get_last_result_table() - - if last_table: print() - # Also update current stage table so @N expansion works correctly ctx.set_current_stage_table(last_table) print(last_table.format_plain()) else: - # Fallback to items if no table object items = ctx.get_last_result_items() if items: - # Clear current stage table if we only have items ctx.set_current_stage_table(None) print(f"Restored {len(items)} items (no table format available)") else: print("No previous result table in history") else: print("Result table history is empty") - except Exception as e: - print(f"Error restoring previous result table: {e}") + except Exception as exc: + print(f"Error restoring previous result table: {exc}") continue - - # Check for pipe operators to support chaining: cmd1 arg1 | cmd2 arg2 | cmd3 arg3 - # Also treat selection commands (@1, @*, etc) as pipelines so they can be expanded + try: - if '|' in tokens or (tokens and tokens[0].startswith('@')): - _execute_pipeline(tokens) + if "|" in tokens or (tokens and tokens[0].startswith("@")): + self._pipeline_executor.execute_tokens(tokens) else: cmd_name = tokens[0].replace("_", "-").lower() is_help = any(arg in {"-help", "--help", "-h"} for arg in tokens[1:]) - if is_help: - _show_cmdlet_help(cmd_name) + CmdletHelp.show_cmdlet_help(cmd_name) else: - # Execute the cmdlet - _execute_cmdlet(cmd_name, tokens[1:]) + self._cmdlet_executor.execute(cmd_name, tokens[1:]) finally: if pipeline_ctx_ref: pipeline_ctx_ref.clear_current_command_text() - @app.callback(invoke_without_command=True) - def main_callback(ctx: typer.Context): - """ - Medeia-Macina CLI entry point. - If no command is provided, starts the interactive REPL. - """ - # Check if a subcommand is invoked - # Note: ctx.invoked_subcommand is None if no command was passed - if ctx.invoked_subcommand is None: - repl() - - return app - - -def _execute_pipeline(tokens: list): - """Execute a pipeline of cmdlet separated by pipes (|). - - Example: cmd1 arg1 arg2 | cmd2 arg2 | cmd3 arg3 - """ - try: - from cmdlet import REGISTRY - import json - import pipeline as ctx - - def _resolve_items_for_selection(table_obj, items_list): - """Return items in the same order as the displayed table rows. - - When a user sees row #2 in the table and selects @2, they get row #2. - No mapping, no math - the displayed order IS the selection order. - - The table and items list should already be in sync after sorting. - """ - # Simply return items as-is - they should match the table row order - return items_list if items_list else [] - - def _debug_selection(label, selection_indices, table_obj, items_list, resolved_list=None): - """Print debug info for selection mapping when troubleshooting. - - Shows the correspondence between displayed row numbers, source indices, - and the actual items being selected to help diagnose reordering issues. - """ - try: - debug(f"[debug] {label}: sel={selection_indices} rows={len(table_obj.rows) if table_obj and hasattr(table_obj, 'rows') else 'n/a'} items={len(items_list) if items_list is not None else 'n/a'}") - if table_obj and hasattr(table_obj, 'rows') and items_list: - # Show correspondence: displayed row # -> source_index -> item hash/title - for i in selection_indices: - if 0 <= i < len(table_obj.rows): - row = table_obj.rows[i] - src_idx = getattr(row, 'source_index', None) - debug(f"[debug] @{i+1} -> row_index={i}, source_index={src_idx}", end='') - if src_idx is not None and 0 <= src_idx < len(items_list): - item = items_list[src_idx] - # Try to show hash/title for verification - if isinstance(item, dict): - hash_val = item.get('hash', item.get('hash_hex', 'N/A')) - title_val = item.get('title', 'N/A') - else: - hash_val = getattr(item, 'hash', getattr(item, 'hash_hex', 'N/A')) - title_val = getattr(item, 'title', 'N/A') - if hash_val != 'N/A': - hash_display = str(hash_val) - title_display = str(title_val) - debug(f" -> hash:{hash_display}, title:{title_display}") - else: - debug(f" -> title:{title_val}") - else: - debug(" -> [source_index out of range]") - if resolved_list is not None: - debug(f"[debug] resolved_len={len(resolved_list)}") - except Exception as e: - debug(f"[debug] error in _debug_selection: {e}") - - # Split tokens by pipe operator - stages = [] - current_stage = [] - - for token in tokens: - if token == '|': - if current_stage: - stages.append(current_stage) - current_stage = [] - else: - current_stage.append(token) - - if current_stage: - stages.append(current_stage) - - if not stages: - print("Invalid pipeline syntax\n") - return - - # If a previous stage paused for selection, attach its remaining stages when the user runs only @N - pending_tail = ctx.get_pending_pipeline_tail() if hasattr(ctx, 'get_pending_pipeline_tail') else [] - pending_source = ctx.get_pending_pipeline_source() if hasattr(ctx, 'get_pending_pipeline_source') else None - # Ensure current stage table is restored before checking source (helps selection-only resumes) - if hasattr(ctx, 'get_current_stage_table') and not ctx.get_current_stage_table(): - display_table = ctx.get_display_table() if hasattr(ctx, 'get_display_table') else None - if display_table: - ctx.set_current_stage_table(display_table) - else: - last_table = ctx.get_last_result_table() if hasattr(ctx, 'get_last_result_table') else None - if last_table: - ctx.set_current_stage_table(last_table) - - current_source = ctx.get_current_stage_table_source_command() if hasattr(ctx, 'get_current_stage_table_source_command') else None - effective_source = current_source or (ctx.get_last_result_table_source_command() if hasattr(ctx, 'get_last_result_table_source_command') else None) - selection_only = len(stages) == 1 and stages[0] and stages[0][0].startswith('@') - if pending_tail and selection_only: - if (pending_source is None) or (effective_source and pending_source == effective_source): - stages.extend(pending_tail) - if hasattr(ctx, 'clear_pending_pipeline_tail'): - ctx.clear_pending_pipeline_tail() - elif hasattr(ctx, 'clear_pending_pipeline_tail'): - ctx.clear_pending_pipeline_tail() - - # Load config relative to CLI root - config = _load_cli_config() - if isinstance(config, dict): - # Request terminal-only background updates for this pipeline session - config['_quiet_background_output'] = True - - def _maybe_run_class_selector(selected_items: list, *, stage_is_last: bool) -> bool: - """Allow providers/stores to override `@N` selection semantics.""" - if not stage_is_last: - return False - - # Gather potential keys from table + selected rows. - candidates: list[str] = [] - seen: set[str] = set() - - def _add(value) -> None: - try: - text = str(value or '').strip().lower() - except Exception: - return - if not text or text in seen: - return - seen.add(text) - candidates.append(text) - - try: - current_table = ctx.get_current_stage_table() or ctx.get_last_result_table() - _add(current_table.table if current_table and hasattr(current_table, 'table') else None) - except Exception: - pass - - for item in selected_items or []: - if isinstance(item, dict): - _add(item.get('provider')) - _add(item.get('store')) - _add(item.get('table')) - else: - _add(getattr(item, 'provider', None)) - _add(getattr(item, 'store', None)) - _add(getattr(item, 'table', None)) - - # Provider selector - try: - from ProviderCore.registry import get_provider as _get_provider - except Exception: - _get_provider = None - - if _get_provider is not None: - for key in candidates: - try: - provider = _get_provider(key, config) - except Exception: - continue - try: - handled = bool(provider.selector(selected_items, ctx=ctx, stage_is_last=True)) - except TypeError: - # Backwards-compat: selector(selected_items) - handled = bool(provider.selector(selected_items)) - except Exception as exc: - print(f"{key} selector failed: {exc}\n") - return True - if handled: - return True - - # Store selector - store_keys: list[str] = [] - for item in selected_items or []: - if isinstance(item, dict): - v = item.get('store') - else: - v = getattr(item, 'store', None) - try: - name = str(v or '').strip() - except Exception: - name = '' - if name: - store_keys.append(name) - - if store_keys: - try: - from Store.registry import Store as _StoreRegistry - store_registry = _StoreRegistry(config, suppress_debug=True) - try: - _backend_names = list(store_registry.list_backends()) - except Exception: - _backend_names = [] - _backend_by_lower = {str(n).lower(): str(n) for n in _backend_names if str(n).strip()} - for name in store_keys: - resolved_name = name - if not store_registry.is_available(resolved_name): - try: - resolved_name = _backend_by_lower.get(str(name).lower(), name) - except Exception: - resolved_name = name - if not store_registry.is_available(resolved_name): - continue - backend = store_registry[resolved_name] - selector = getattr(backend, 'selector', None) - if selector is None: - continue - try: - handled = bool(selector(selected_items, ctx=ctx, stage_is_last=True)) - except TypeError: - handled = bool(selector(selected_items)) - if handled: - return True - except Exception: - # Store init failure should not break normal selection. - pass - - return False - - # Check if the first stage has @ selection - if so, apply it before pipeline execution - first_stage_tokens = stages[0] if stages else [] - first_stage_selection_indices = [] - first_stage_had_extra_args = False - if first_stage_tokens: - # Look for @N, @N-M, @{N,M} in the first stage args - new_first_stage = [] - first_stage_select_all = False - for token in first_stage_tokens: - if token.startswith('@'): - selection = _parse_selection_syntax(token) - if selection is not None: - # This is a selection syntax - apply it to get initial piped_result - first_stage_selection_indices = sorted([i - 1 for i in selection]) - elif token == "@*": - # Special case: select all items - first_stage_select_all = True - else: - # Not a valid selection, keep as arg - new_first_stage.append(token) - else: - new_first_stage.append(token) - # Update first stage - if it's now empty (only had @N), keep the selection for later processing - if new_first_stage: - stages[0] = new_first_stage - # If we found selection indices but still have tokens, these are extra args - if first_stage_selection_indices or first_stage_select_all: - first_stage_had_extra_args = True - elif first_stage_selection_indices or first_stage_select_all: - # First stage was ONLY selection (@N or @*) - remove it and apply selection to next stage's input - stages.pop(0) - - # Handle @* expansion by selecting all available items - if first_stage_select_all: - last_items = ctx.get_last_result_items() - if last_items: - first_stage_selection_indices = list(range(len(last_items))) - - # Execute each stage, threading results to the next - piped_result = None - worker_manager = _ensure_worker_manager(config) - pipeline_text = " | ".join(" ".join(stage) for stage in stages) - pipeline_session = _begin_pipeline_worker(worker_manager, pipeline_text, config) - - # Update background notifier with session worker IDs so it only shows workers from this pipeline - if pipeline_session and worker_manager and isinstance(config, dict): - session_worker_ids = config.get('_session_worker_ids') - if session_worker_ids: - try: - # Use toolbar updater if available - output_fn = _TOOLBAR_UPDATER - - # If using toolbar, we want continuous updates, not just terminal completion - quiet_mode = bool(config.get('_quiet_background_output')) - terminal_only = quiet_mode and not _TOOLBAR_UPDATER - - kwargs = { - "session_worker_ids": session_worker_ids, - "only_terminal_updates": terminal_only, - "overlay_mode": bool(output_fn), - } - if output_fn: - kwargs["output"] = output_fn - - ensure_background_notifier(worker_manager, **kwargs) - except Exception: - pass - - pipeline_status = "completed" - pipeline_error = "" - - # Apply first-stage selection if present - if first_stage_selection_indices: - # Ensure we have a table context for expansion from previous command - if not ctx.get_current_stage_table_source_command(): - display_table = ctx.get_display_table() if hasattr(ctx, 'get_display_table') else None - table_for_stage = display_table or ctx.get_last_result_table() - if table_for_stage: - ctx.set_current_stage_table(table_for_stage) - - # Special check for table-specific behavior BEFORE command expansion. - # For some provider tables, we prefer item-based selection over command expansion, - # and may auto-append a sensible follow-up stage (e.g. YouTube -> download-media). - source_cmd = ctx.get_current_stage_table_source_command() - source_args = ctx.get_current_stage_table_source_args() - - # Check table property - current_table = ctx.get_current_stage_table() - table_type = current_table.table if current_table and hasattr(current_table, 'table') else None - - # Logic based on table type - if table_type == 'youtube' or table_type == 'soulseek': - # Force fallback to item-based selection so we can auto-append a follow-up stage - command_expanded = False - # Skip the command expansion block below - elif source_cmd == 'search-file' and source_args and 'youtube' in source_args: - # Legacy check for youtube - command_expanded = False - else: - # Try command-based expansion first if we have source command info - command_expanded = False - selected_row_args = [] - - skip_pipe_expansion = source_cmd == '.pipe' and len(stages) > 0 - - if source_cmd and not skip_pipe_expansion: - # Try to find row args for the selected indices - for idx in first_stage_selection_indices: - row_args = ctx.get_current_stage_table_row_selection_args(idx) - if row_args: - selected_row_args.extend(row_args) - break # For now, take first selected row's args - - if selected_row_args: - # Success: Reconstruct the command with selection args - # Handle case where source_cmd might be a list (though it should be a string) - cmd_list = source_cmd if isinstance(source_cmd, list) else [source_cmd] - expanded_stage = cmd_list + source_args + selected_row_args - - if first_stage_had_extra_args: - # Append extra args from the first stage (e.g. @3 arg1 arg2) - expanded_stage += stages[0] - stages[0] = expanded_stage - else: - # Insert expanded command as first stage (it was popped earlier if it was only @N) - stages.insert(0, expanded_stage) - - log_msg = f"@N expansion: {source_cmd} + {' '.join(str(x) for x in selected_row_args)}" - worker_manager.log_step(pipeline_session.worker_id, log_msg) if pipeline_session and worker_manager else None - - first_stage_selection_indices = [] # Clear, we've expanded it - command_expanded = True - - # If command-based expansion didn't work, fall back to item-based selection - if not command_expanded and first_stage_selection_indices: - # FALLBACK: Item-based selection (filter piped items directly) - last_piped_items = ctx.get_last_result_items() - # Align to the displayed row order so @N matches what the user sees - stage_table = ctx.get_current_stage_table() - if not stage_table and hasattr(ctx, 'get_display_table'): - stage_table = ctx.get_display_table() - if not stage_table: - stage_table = ctx.get_last_result_table() - resolved_items = _resolve_items_for_selection(stage_table, last_piped_items) - _debug_selection("first-stage", first_stage_selection_indices, stage_table, last_piped_items, resolved_items) - if last_piped_items: - try: - filtered = [resolved_items[i] for i in first_stage_selection_indices if 0 <= i < len(resolved_items)] - if filtered: - # Allow providers/stores to override selection behavior (e.g., Matrix room picker). - if _maybe_run_class_selector(filtered, stage_is_last=(not stages)): - return - - # Convert filtered items to PipeObjects for consistent pipeline handling - from cmdlet._shared import coerce_to_pipe_object - filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered] - piped_result = filtered_pipe_objs if len(filtered_pipe_objs) > 1 else filtered_pipe_objs[0] - # Build log message with proper string conversion - selection_parts = [] - for i in first_stage_selection_indices: - selection_parts.append(f'@{i+1}') - log_msg = f"Applied @N selection {' | '.join(selection_parts)}" - worker_manager.log_step(pipeline_session.worker_id, log_msg) if pipeline_session and worker_manager else None - - # Special case for table-specific auto-piping - # This handles the case where @N is the ONLY stage (e.g. user typed "@1") - # In this case, stages is [['@1']], but we are in the fallback block because command_expanded is False - - # Check table type - current_table = ctx.get_current_stage_table() - if not current_table: - current_table = ctx.get_last_result_table() - - table_type = current_table.table if current_table and hasattr(current_table, 'table') else None - - source_cmd = ctx.get_last_result_table_source_command() - source_args = ctx.get_last_result_table_source_args() - - if not stages: - if table_type == 'youtube': - print(f"Auto-running YouTube selection via download-media") - stages.append(['download-media']) - elif table_type == 'soulseek': - print(f"Auto-piping Soulseek selection to download-file") - stages.append(['download-file']) - elif table_type == 'openlibrary': - print(f"Auto-piping OpenLibrary selection to download-file") - stages.append(['download-file']) - elif table_type == 'libgen': - print(f"Auto-piping Libgen selection to download-file") - stages.append(['download-file']) - elif source_cmd == 'search-file' and source_args and 'youtube' in source_args: - # Legacy check - print(f"Auto-running YouTube selection via download-media") - stages.append(['download-media']) - else: - # If the user is piping a provider selection into additional stages (e.g. add-file), - # automatically insert the appropriate download stage so @N is "logical". - # This prevents add-file from receiving an unreachable provider path like "share\...". - first_cmd = stages[0][0] if stages and stages[0] else None - if table_type == 'soulseek' and first_cmd not in ('download-file', 'download-media', 'download_media', '.pipe'): - print(f"Auto-inserting download-file after Soulseek selection") - stages.insert(0, ['download-file']) - if table_type == 'youtube' and first_cmd not in ('download-media', 'download_media', 'download-file', '.pipe'): - print(f"Auto-inserting download-media after YouTube selection") - stages.insert(0, ['download-media']) - if table_type == 'libgen' and first_cmd not in ('download-file', 'download-media', 'download_media', '.pipe'): - print(f"Auto-inserting download-file after Libgen selection") - stages.insert(0, ['download-file']) - - else: - print(f"No items matched selection in pipeline\n") - return - except (TypeError, IndexError) as e: - print(f"Error applying selection in pipeline: {e}\n") - return - else: - print(f"No previous results to select from\n") - return - - try: - for stage_index, stage_tokens in enumerate(stages): - if not stage_tokens: - continue - - cmd_name = stage_tokens[0].replace("_", "-").lower() - stage_args = stage_tokens[1:] - - # Bare '@' means "use the subject for the current result table" (e.g., the file whose tags/url are shown) - if cmd_name == "@": - subject = ctx.get_last_result_subject() - if subject is None: - print("No current result context available for '@'\n") - pipeline_status = "failed" - pipeline_error = "No result subject for @" - return - # Normalize to list for downstream expectations - piped_result = subject - try: - subject_items = subject if isinstance(subject, list) else [subject] - ctx.set_last_items(subject_items) - except Exception: - pass - if pipeline_session and worker_manager: - try: - worker_manager.log_step(pipeline_session.worker_id, "@ used current table subject") - except Exception: - pass - continue - - # Check if this is a selection syntax (@N, @N-M, @{N,M,K}, @*, @3,5,7, @3-6,8) instead of a command - if cmd_name.startswith('@'): - selection = _parse_selection_syntax(cmd_name) - is_select_all = (cmd_name == "@*") - - if selection is not None or is_select_all: - # This is a selection stage - # Check if we should expand it to a full command instead of just filtering - should_expand_to_command = False - - # Check if piped_result contains format objects and we have expansion info - source_cmd = ctx.get_current_stage_table_source_command() - source_args = ctx.get_current_stage_table_source_args() - - # If selecting from a YouTube results table and this is the last stage, - # auto-run download-media instead of leaving a bare selection. - current_table = ctx.get_current_stage_table() - table_type = current_table.table if current_table and hasattr(current_table, 'table') else None - if table_type == 'youtube' and stage_index + 1 >= len(stages): - print(f"Auto-running YouTube selection via download-media") - stages.append(['download-media', *stage_args]) - should_expand_to_command = False - - if source_cmd == '.pipe' or source_cmd == '.adjective': - should_expand_to_command = True - if source_cmd == '.pipe' and (stage_index + 1 < len(stages) or stage_args): - # When piping playlist rows to another cmdlet, prefer item-based selection - should_expand_to_command = False - elif source_cmd == 'search-file' and source_args and 'youtube' in source_args: - # Legacy behavior: selection at end should run a sensible follow-up. - if stage_index + 1 >= len(stages): - # Only auto-pipe if this is the last stage - print(f"Auto-running YouTube selection via download-media") - stages.append(['download-media']) - # Force should_expand_to_command to False so we fall through to filtering - should_expand_to_command = False - - elif isinstance(piped_result, (list, tuple)): - first_item = piped_result[0] if piped_result else None - if isinstance(first_item, dict) and first_item.get('format_id') is not None: - # Format objects detected - check for source command - if source_cmd: - should_expand_to_command = True - elif isinstance(piped_result, dict) and piped_result.get('format_id') is not None: - # Single format object - if source_cmd: - should_expand_to_command = True - - # If we have a source command but no piped data (paused for selection), expand to command - if not should_expand_to_command and source_cmd and selection is not None and piped_result is None: - should_expand_to_command = True - - # If expanding to command, replace this stage and re-execute - if should_expand_to_command and selection is not None: - source_cmd = ctx.get_current_stage_table_source_command() - source_args = ctx.get_current_stage_table_source_args() - selection_indices = sorted([i - 1 for i in selection]) - - # Get row args for first selected index - selected_row_args = [] - for idx in selection_indices: - row_args = ctx.get_current_stage_table_row_selection_args(idx) - if row_args: - selected_row_args.extend(row_args) - break - - if selected_row_args: - # Expand to full command - # Include any arguments passed to the selection command (e.g. @3 arg1 arg2) - extra_args = stage_tokens[1:] - expanded_stage = [source_cmd] + source_args + selected_row_args + extra_args - print(f"Expanding {cmd_name} to: {' '.join(expanded_stage)}") - - # Replace current stage and re-execute it - stages[stage_index] = expanded_stage - stage_tokens = expanded_stage - cmd_name = expanded_stage[0].replace("_", "-").lower() - stage_args = expanded_stage[1:] - - # Clear piped_result so the expanded command doesn't receive the format objects - piped_result = None - - # Don't continue - fall through to execute the expanded command - - # If not expanding, use as filter - if not should_expand_to_command: - # This is a selection stage - filter piped results - # Prefer selecting from the active result context even when nothing is piped. - # Some cmdlets present a selectable table and rely on @N afterwards. - if piped_result is None: - piped_result_list = ctx.get_last_result_items() - if not piped_result_list: - print(f"No piped results to select from with {cmd_name}\n") - pipeline_status = "failed" - pipeline_error = f"Selection {cmd_name} without upstream results" - return - else: - # Normalize piped_result to always be a list for indexing - if isinstance(piped_result, dict) or not isinstance(piped_result, (list, tuple)): - piped_result_list = [piped_result] - else: - piped_result_list = piped_result - - # Get indices to select - if is_select_all: - # @* means select all items - selection_indices = list(range(len(piped_result_list))) - elif selection is not None: - # Convert to 0-based indices - selection_indices = sorted([i - 1 for i in selection]) - else: - selection_indices = [] - # Align indices to the displayed row order - stage_table = ctx.get_current_stage_table() - if not stage_table and hasattr(ctx, 'get_display_table'): - stage_table = ctx.get_display_table() - if not stage_table: - stage_table = ctx.get_last_result_table() - # Prefer selecting from the displayed table's items if available. - # This matters when a cmdlet shows a selectable overlay table but does not emit - # items downstream (e.g., add-file -provider matrix shows rooms, but the piped - # value is still the original file). - selection_base = list(piped_result_list) - try: - table_rows = len(stage_table.rows) if stage_table and hasattr(stage_table, 'rows') and stage_table.rows else None - last_items = ctx.get_last_result_items() - if last_items and table_rows is not None and len(last_items) == table_rows: - selection_base = list(last_items) - except Exception: - pass - - resolved_list = _resolve_items_for_selection(stage_table, selection_base) - _debug_selection("pipeline-stage", selection_indices, stage_table, selection_base, resolved_list) - - try: - filtered = [resolved_list[i] for i in selection_indices if 0 <= i < len(resolved_list)] - if filtered: - # Allow providers/stores to override selection behavior (e.g., Matrix room picker). - if _maybe_run_class_selector(filtered, stage_is_last=(stage_index + 1 >= len(stages))): - return - - # Convert filtered items to PipeObjects for consistent pipeline handling - from cmdlet._shared import coerce_to_pipe_object - filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered] - piped_result = filtered_pipe_objs if len(filtered_pipe_objs) > 1 else filtered_pipe_objs[0] - print(f"Selected {len(filtered)} item(s) using {cmd_name}") - - # If selecting YouTube results and there are downstream stages, - # insert download-media so subsequent cmdlets receive a local temp file. - try: - current_table = ctx.get_current_stage_table() - table_type = current_table.table if current_table and hasattr(current_table, 'table') else None - except Exception: - table_type = None - - if table_type == 'youtube' and stage_index + 1 < len(stages): - next_cmd = stages[stage_index + 1][0] if stages[stage_index + 1] else None - if next_cmd not in ('download-media', 'download_media', 'download-file', '.pipe'): - print("Auto-inserting download-media after YouTube selection") - stages.insert(stage_index + 1, ['download-media']) - - if table_type == 'libgen' and stage_index + 1 < len(stages): - next_cmd = stages[stage_index + 1][0] if stages[stage_index + 1] else None - if next_cmd not in ('download-file', 'download-media', 'download_media', '.pipe'): - print("Auto-inserting download-file after Libgen selection") - stages.insert(stage_index + 1, ['download-file']) - - # If selection is the last stage and looks like a provider result, - # auto-initiate the borrow/download flow. - if stage_index + 1 >= len(stages): - try: - from ProviderCore.registry import get_search_provider as _get_search_provider - except Exception: - _get_search_provider = None - - if _get_search_provider is not None: - selected_list = filtered_pipe_objs - provider_table: Optional[str] = None - try: - for obj in selected_list: - extra = getattr(obj, "extra", None) - if isinstance(extra, dict) and extra.get("table"): - provider_table = str(extra.get("table")) - break - except Exception: - provider_table = None - - if provider_table: - try: - provider = _get_search_provider(provider_table, config) - except Exception: - provider = None - if provider is not None: - print("Auto-downloading selection via download-file") - stages.append(["download-file"]) - else: - # Fallback: if we know the current table type, prefer a sensible default. - if table_type == 'libgen': - print("Auto-downloading Libgen selection via download-file") - stages.append(["download-file"]) - continue - else: - print(f"No items matched selection {cmd_name}\n") - pipeline_status = "failed" - pipeline_error = f"Selection {cmd_name} matched nothing" - return - except (TypeError, IndexError) as e: - print(f"Error applying selection {cmd_name}: {e}\n") - pipeline_status = "failed" - pipeline_error = f"Selection error: {e}" - return - # If parse failed, treat as regular command name (will fail below) - - # Get the cmdlet function - cmd_fn = REGISTRY.get(cmd_name) - if not cmd_fn: - print(f"Unknown command in pipeline: {cmd_name}\n") - pipeline_status = "failed" - pipeline_error = f"Unknown command {cmd_name}" - return - - # Prevent stale tables (e.g., a previous download-media format picker) - # from leaking into subsequent stages and being displayed again. - try: - ctx.set_current_stage_table(None) - except Exception: - pass - - debug(f"[pipeline] Stage {stage_index}: cmd_name={cmd_name}, cmd_fn type={type(cmd_fn)}, piped_result type={type(piped_result)}, stage_args={stage_args}") - - # Execute the cmdlet with piped input - stage_session: Optional[_WorkerStageSession] = None - stage_status = "completed" - stage_error = "" - stage_label = f"[Stage {stage_index + 1}/{len(stages)}] {cmd_name}" - if pipeline_session and worker_manager: - try: - worker_manager.log_step(pipeline_session.worker_id, f"{stage_label} started") - except Exception: - pass - else: - stage_session = _begin_worker_stage( - worker_manager=worker_manager, - cmd_name=cmd_name, - stage_tokens=stage_tokens, - config=config, - command_text=" ".join(stage_tokens), - ) - - # Create pipeline context for this stage with the worker ID - is_last_stage = (stage_index == len(stages) - 1) - stage_worker_id = stage_session.worker_id if stage_session else (pipeline_session.worker_id if pipeline_session else None) - pipeline_ctx = ctx.PipelineStageContext(stage_index=stage_index, total_stages=len(stages), worker_id=stage_worker_id) - ctx.set_stage_context(pipeline_ctx) - try: - if isinstance(config, dict): - config['_pipeline_remaining_after_current'] = stages[stage_index + 1:] - debug(f"[pipeline] Calling cmd_fn({type(piped_result).__name__}, {stage_args}, config)") - ret_code = cmd_fn(piped_result, stage_args, config) - debug(f"[pipeline] cmd_fn returned: {ret_code} (type: {type(ret_code)})") - - # Store emitted results for next stage (or display if last stage) - if pipeline_ctx.emits: - if is_last_stage: - # Last stage - display results - if RESULT_TABLE_AVAILABLE and ResultTable is not None and pipeline_ctx.emits: - table_title = _get_table_title_for_command(cmd_name, pipeline_ctx.emits, stage_args) - - # Only set source_command for search/filter commands (not display-only or action commands) - # This preserves context so @N refers to the original search, not intermediate results - selectable_commands = { - 'search-file', 'download-data', 'download-media', 'search_file', 'download_data', 'download_media', - '.config', '.worker' - } - # Display-only commands (just show data, don't modify or search) - display_only_commands = { - 'get-note', 'get_note', - 'get-relationship', 'get_relationship', 'get-file', 'get_file', - } - # Commands that manage their own table/history state (e.g. get-tag) - self_managing_commands = { - 'get-tag', 'get_tag', 'tags', - 'get-url', 'get_url', - 'search-file', 'search_file', - 'search-provider', 'search_provider', - 'search-store', 'search_store' - } - - overlay_table = ctx.get_display_table() if hasattr(ctx, 'get_display_table') else None - - if cmd_name in self_managing_commands: - # Command has already set the table and history - # Retrieve the table it set so we print the correct custom formatting - - # Check for overlay table first (e.g. get-tag) - if hasattr(ctx, 'get_display_table'): - table = ctx.get_display_table() - else: - table = None - - if table is None: - table = ctx.get_last_result_table() - - if table is None: - # Fallback if something went wrong - table = ResultTable(table_title) - for emitted in pipeline_ctx.emits: - table.add_result(emitted) - else: - if cmd_name in selectable_commands: - table = ResultTable(table_title) - - # Detect table type from items - first_table = None - consistent = True - - for emitted in pipeline_ctx.emits: - table.add_result(emitted) - - # Check for table property - item_table = None - if isinstance(emitted, dict): - item_table = emitted.get('table') - else: - item_table = getattr(emitted, 'table', None) - - if item_table: - if first_table is None: - first_table = item_table - elif first_table != item_table: - consistent = False - - if consistent and first_table: - table.set_table(first_table) - - table.set_source_command(cmd_name, stage_args) - ctx.set_last_result_table(table, pipeline_ctx.emits) - elif cmd_name in display_only_commands: - table = ResultTable(table_title) - for emitted in pipeline_ctx.emits: - table.add_result(emitted) - # Display-only: show table but preserve search context - ctx.set_last_result_items_only(pipeline_ctx.emits) - else: - # Action commands: avoid overwriting search history/table unless a display overlay exists - if overlay_table is not None: - table = overlay_table - else: - table = None - - if table is not None: - print() - print(table.format_plain()) - else: - for emitted in pipeline_ctx.emits: - if isinstance(emitted, dict): - print(json.dumps(emitted, indent=2)) - else: - print(emitted) - # For display-only results, also preserve context by not calling set_last_result_table - else: - # Intermediate stage - thread to next stage - piped_result = pipeline_ctx.emits - ctx.set_last_result_table(None, pipeline_ctx.emits) - else: - # No output from this stage. If it presented a selectable table (e.g., format list), pause - # and stash the remaining pipeline so @N can resume with the selection applied. - if is_last_stage: - # Last stage with no emitted items: only display a *current* selectable table set by - # the cmdlet (e.g., download-media format picker). Do NOT fall back to last_result_table, - # which may be stale from a previous command. - stage_table_source = ctx.get_current_stage_table_source_command() - row_has_selection = ctx.get_current_stage_table_row_selection_args(0) is not None - stage_table = ctx.get_current_stage_table() - if not stage_table and hasattr(ctx, 'get_display_table'): - stage_table = ctx.get_display_table() - - if RESULT_TABLE_AVAILABLE and stage_table is not None and stage_table_source and row_has_selection: - try: - print() - print(stage_table.format_plain()) - except Exception: - pass - continue - - if not is_last_stage: - stage_table_source = ctx.get_current_stage_table_source_command() - row_has_selection = ctx.get_current_stage_table_row_selection_args(0) is not None - stage_table = ctx.get_current_stage_table() - - # Check if next stage is @N selection - if so, don't pause, let it process - next_stage = stages[stage_index + 1] if stage_index + 1 < len(stages) else None - next_is_selection = next_stage and next_stage[0] and next_stage[0][0].startswith('@') - - debug(f"[pipeline] Stage {stage_index} pause check: source={stage_table_source}, has_selection={row_has_selection}, table={stage_table is not None}, next_is_selection={next_is_selection}") - - if stage_table_source and row_has_selection and not next_is_selection: - # Display the table before pausing - if RESULT_TABLE_AVAILABLE and stage_table is not None: - debug(f"[pipeline] Displaying stage table with {len(stage_table.rows) if hasattr(stage_table, 'rows') else 0} rows") - print() - print(stage_table.format_plain()) - print() - - pending_tail = stages[stage_index + 1:] - if pending_tail and pending_tail[0] and pending_tail[0][0].startswith('@'): - pending_tail = pending_tail[1:] - if hasattr(ctx, 'set_pending_pipeline_tail') and pending_tail: - ctx.set_pending_pipeline_tail(pending_tail, stage_table_source) - elif hasattr(ctx, 'clear_pending_pipeline_tail'): - ctx.clear_pending_pipeline_tail() - if pipeline_session and worker_manager: - try: - worker_manager.log_step(pipeline_session.worker_id, "Pipeline paused for @N selection") - except Exception: - pass - print("Pipeline paused: select a format with @N to continue remaining stages") - return - - # If the stage requested pipeline abort (e.g., queued async work), stop processing further stages - if getattr(pipeline_ctx, "abort_remaining", False): - if pipeline_session and worker_manager: - try: - worker_manager.log_step( - pipeline_session.worker_id, - f"{stage_label} queued background work; skipping remaining stages", - ) - except Exception: - pass - return - - if ret_code != 0: - stage_status = "failed" - stage_error = f"exit code {ret_code}" - # Only print exit code if it's an integer (not the cmdlet object) - if isinstance(ret_code, int): - print(f"[stage {stage_index} exit code: {ret_code}]\n") - else: - print(f"[stage {stage_index} failed]\n") - if pipeline_session: - pipeline_status = "failed" - pipeline_error = f"{stage_label} failed ({stage_error})" - return - - except Exception as e: - stage_status = "failed" - stage_error = f"{type(e).__name__}: {e}" - print(f"[error in stage {stage_index} ({cmd_name})]: {type(e).__name__}: {e}\n") - import traceback - traceback.print_exc() - if pipeline_session: - pipeline_status = "failed" - pipeline_error = f"{stage_label} error: {e}" - return - finally: - if stage_session: - stage_session.close(status=stage_status, error_msg=stage_error) - elif pipeline_session and worker_manager: - try: - worker_manager.log_step( - pipeline_session.worker_id, - f"{stage_label} {'completed' if stage_status == 'completed' else 'failed'}", - ) - except Exception: - pass - - # If we have a result but no stages left (e.g. pure selection @3 that didn't expand to a command), display it - if not stages and piped_result is not None: - if RESULT_TABLE_AVAILABLE and ResultTable is not None: - # Create a simple table for the result - table = ResultTable("Selection Result") - - # Normalize to list - items = piped_result if isinstance(piped_result, list) else [piped_result] - - for item in items: - table.add_result(item) - - # Preserve context for further selection - ctx.set_last_result_items_only(items) - - print() - print(table.format_plain()) - else: - print(piped_result) - - except Exception as e: - pipeline_status = "failed" - pipeline_error = str(e) - print(f"[error] Failed to execute pipeline: {e}\n") - import traceback - traceback.print_exc() - finally: - if pipeline_session: - pipeline_session.close(status=pipeline_status, error_msg=pipeline_error) - - except Exception as e: - print(f"[error] Failed to execute pipeline: {e}\n") - import traceback - traceback.print_exc() - - -def _execute_cmdlet(cmd_name: str, args: list): - """Execute a cmdlet with the given arguments. - - Supports @ selection syntax for filtering results from previous commands: - - @2 - select row 2 - - @2-5 - select rows 2-5 - - @{1,3,5} - select rows 1, 3, 5 - """ - try: - from cmdlet import REGISTRY - import json - import pipeline as ctx - - # Ensure native commands (cmdnat) are loaded - try: - from cmdlet_catalog import ensure_registry_loaded as _ensure_registry_loaded - _ensure_registry_loaded() - except Exception: - pass - - # Get the cmdlet function - cmd_fn = REGISTRY.get(cmd_name) - if not cmd_fn: - # Attempt lazy import of the module and retry - from cmdlet_catalog import import_cmd_module as _catalog_import - try: - mod = _catalog_import(cmd_name) - data = getattr(mod, "CMDLET", None) if mod else None - if data and hasattr(data, "exec") and callable(getattr(data, "exec")): - run_fn = getattr(data, "exec") - REGISTRY[cmd_name] = run_fn - cmd_fn = run_fn - except Exception: - pass - if not cmd_fn: - print(f"Unknown command: {cmd_name}\n") - return - - # Load config relative to CLI root - config = _load_cli_config() - - # Check for @ selection syntax in arguments. - # IMPORTANT: support using @N as a VALUE for a value-taking flag (e.g. add-relationship -king @1). - # Only treat @ tokens as selection when they are NOT in a value position. - filtered_args: list[str] = [] - selected_indices: list[int] = [] - select_all = False - - # Build a set of flag tokens that consume a value for this cmdlet. - # We use cmdlet metadata so we don't break patterns like: get-tag -raw @1 (where -raw is a flag). - value_flags: set[str] = set() - try: - meta = _catalog_get_cmdlet_metadata(cmd_name) - raw = meta.get("raw") if isinstance(meta, dict) else None - arg_specs = getattr(raw, "arg", None) if raw is not None else None - if isinstance(arg_specs, list): - for spec in arg_specs: - try: - spec_type = str(getattr(spec, "type", "string") or "string").strip().lower() - if spec_type == "flag": - continue - spec_name = str(getattr(spec, "name", "") or "") - canonical = spec_name.lstrip("-").strip() - if not canonical: - continue - value_flags.add(f"-{canonical}".lower()) - value_flags.add(f"--{canonical}".lower()) - alias = str(getattr(spec, "alias", "") or "").strip() - if alias: - value_flags.add(f"-{alias}".lower()) - except Exception: - continue - except Exception: - value_flags = set() - - for i, arg in enumerate(args): - if isinstance(arg, str) and arg.startswith('@'): - prev = str(args[i - 1]).lower() if i > 0 else "" - # If this @ token is the value for a value-taking flag, keep it. - if prev in value_flags: - filtered_args.append(arg) - continue - - # Special case: @"string" should be treated as "string" (stripping @) - # This allows adding new items via @"New Item" syntax - if len(arg) >= 2 and (arg[1] == '"' or arg[1] == "'"): - filtered_args.append(arg[1:].strip('"\'')) - continue - - # Parse selection: @2, @2-5, @{1,3,5}, @3,5,7, @3-6,8, @* - if arg.strip() == "@*": - select_all = True - continue - - selection = _parse_selection_syntax(arg) - if selection is not None: - zero_based = sorted(i - 1 for i in selection if isinstance(i, int) and i > 0) - selected_indices.extend([idx for idx in zero_based if idx not in selected_indices]) - continue - - # Not a valid selection, treat as regular arg - filtered_args.append(arg) - else: - filtered_args.append(str(arg)) - - # Get piped items from previous command results - piped_items = ctx.get_last_result_items() - - # Create result object - pass full list (or filtered list if @ selection used) to cmdlet - result = None - if piped_items: - if select_all: - result = piped_items - elif selected_indices: - # Filter to selected indices only - result = [piped_items[idx] for idx in selected_indices if 0 <= idx < len(piped_items)] - else: - # No selection specified, pass all items (cmdlet handle lists via normalize_result_input) - result = piped_items - - worker_manager = _ensure_worker_manager(config) - stage_session = _begin_worker_stage( - worker_manager=worker_manager, - cmd_name=cmd_name, - stage_tokens=[cmd_name, *filtered_args], - config=config, - command_text=" ".join([cmd_name, *filtered_args]).strip() or cmd_name, - ) - - # Create pipeline context with the worker ID - stage_worker_id = stage_session.worker_id if stage_session else None - pipeline_ctx = ctx.PipelineStageContext(stage_index=0, total_stages=1, worker_id=stage_worker_id) - ctx.set_stage_context(pipeline_ctx) - stage_status = "completed" - stage_error = "" - - # Execute the cmdlet - ctx.set_last_selection(selected_indices) - try: - ret_code = cmd_fn(result, filtered_args, config) - - # Print emitted results using ResultTable for structured output - if pipeline_ctx.emits: - if RESULT_TABLE_AVAILABLE and ResultTable is not None and pipeline_ctx.emits: - # Check if these are format objects (from download-data format selection) - # Format objects have format_id and should not be displayed as a table - is_format_selection = False - if pipeline_ctx.emits and len(pipeline_ctx.emits) > 0: - first_emit = pipeline_ctx.emits[0] - if isinstance(first_emit, dict) and 'format_id' in first_emit: - is_format_selection = True - - # Skip table display for format selection - user will use @N to select - if is_format_selection: - # Store items for @N selection but don't display table - ctx.set_last_result_items_only(pipeline_ctx.emits) - else: - # Try to format as a table if we have search results - table_title = _get_table_title_for_command(cmd_name, pipeline_ctx.emits, filtered_args) - - # Only set source_command for search/filter commands (not display-only or action commands) - # This preserves context so @N refers to the original search, not intermediate results - selectable_commands = { - 'search-file', 'download-data', 'download-media', 'search_file', 'download_data', 'download_media', - '.config', '.worker' - } - # Display-only commands (excluding get-tag which manages its own table) - display_only_commands = { - 'get-url', 'get_url', 'get-note', 'get_note', - 'get-relationship', 'get_relationship', 'get-file', 'get_file', - } - # Commands that manage their own table/history state (e.g. get-tag) - self_managing_commands = { - 'get-tag', 'get_tag', 'tags', - 'search-file', 'search_file', - 'search-provider', 'search_provider', - 'search-store', 'search_store' - } - - if cmd_name in self_managing_commands: - # Command has already set the table and history - # Retrieve the table it set so we print the correct custom formatting - table = ctx.get_last_result_table() - if table is None: - # Fallback if something went wrong - table = ResultTable(table_title) - for emitted in pipeline_ctx.emits: - table.add_result(emitted) - else: - table = ResultTable(table_title) - for emitted in pipeline_ctx.emits: - table.add_result(emitted) - - if cmd_name in selectable_commands: - table.set_source_command(cmd_name, filtered_args) - ctx.set_last_result_table(table, pipeline_ctx.emits) - # Clear any stale current_stage_table (e.g. from previous download-data formats) - # This ensures @N refers to these new results, not old format selections - ctx.set_current_stage_table(None) - elif cmd_name in display_only_commands: - # Display-only: show table but preserve search context - ctx.set_last_result_items_only(pipeline_ctx.emits) - else: - # Action commands: update items only without changing current table or history - ctx.set_last_result_items_only(pipeline_ctx.emits) - - print() - print(table.format_plain()) - - # Special case: if this was a youtube search, print a hint about auto-piping - if cmd_name == 'search-file' and filtered_args and 'youtube' in filtered_args: - # print("\n[Hint] Type @N to play a video in MPV (e.g. @1)") - pass - else: - # Fallback to raw output if ResultTable not available - for emitted in pipeline_ctx.emits: - if isinstance(emitted, dict): - print(json.dumps(emitted, indent=2)) - else: - print(emitted) - - # Store emitted items for @ selection - selectable_commands = { - 'search-file', 'download-data', 'download-media', 'search_file', 'download_data', 'download_media', - '.config', '.worker' - } - display_only_commands = { - 'get-url', 'get_url', 'get-note', 'get_note', - 'get-relationship', 'get_relationship', 'get-file', 'get_file', - } - self_managing_commands = { - 'get-tag', 'get_tag', 'tags', - 'search-file', 'search_file' - } - - if cmd_name in self_managing_commands: - pass # Already handled by cmdlet - elif cmd_name in selectable_commands: - ctx.set_last_result_table(None, pipeline_ctx.emits) - elif cmd_name in display_only_commands: - ctx.set_last_result_items_only(pipeline_ctx.emits) - else: - # Action commands: items only, don't change table/history - ctx.set_last_result_items_only(pipeline_ctx.emits) - - if ret_code != 0: - stage_status = "failed" - stage_error = f"exit code {ret_code}" - print(f"[exit code: {ret_code}]\n") - except Exception as e: - stage_status = "failed" - stage_error = f"{type(e).__name__}: {e}" - print(f"[error] {type(e).__name__}: {e}\n") - finally: - ctx.clear_last_selection() - if stage_session: - stage_session.close(status=stage_status, error_msg=stage_error) - except Exception as e: - print(f"[error] Failed to execute cmdlet: {e}\n") - - -def _show_cmdlet_list(): - """Display available cmdlet with full metadata: cmd:name alias:aliases args:args.""" - try: - metadata = _catalog_list_cmdlet_metadata() - print("\nAvailable cmdlet:") - for cmd_name in sorted(metadata.keys()): - info = metadata[cmd_name] - aliases = info.get("aliases", []) - args = info.get("args", []) - - display = f" cmd:{cmd_name}" - if aliases: - display += f" alias:{', '.join(aliases)}" - if args: - arg_names = [a.get("name") for a in args if a.get("name")] - if arg_names: - display += f" args:{', '.join(arg_names)}" - summary = info.get("summary") - if summary: - display += f" - {summary}" - print(display) - - print() - except Exception as e: - print(f"Error: {e}\n") - - -def _show_cmdlet_help(cmd_name: str): - """Display help for a cmdlet.""" - try: - meta = _catalog_get_cmdlet_metadata(cmd_name) - if meta: - _print_metadata(cmd_name, meta) - return - print(f"Unknown command: {cmd_name}\n") - except Exception as e: - print(f"Error: {e}\n") - - -def _print_metadata(cmd_name: str, data): - """Print cmdlet metadata in PowerShell-style format.""" - d = data.to_dict() if hasattr(data, "to_dict") else data - if not isinstance(d, dict): - print(f"Invalid metadata for {cmd_name}\n") - return - - name = d.get('name', cmd_name) - summary = d.get("summary", "") - usage = d.get("usage", "") - description = d.get("description", "") - args = d.get("args", []) - details = d.get("details", []) - - # NAME section - print(f"\nNAME") - print(f" {name}") - - # SYNOPSIS section - print(f"\nSYNOPSIS") - if usage: - # Format usage similar to PowerShell syntax - print(f" {usage}") - else: - print(f" {name}") - - # DESCRIPTION section - if summary or description: - print(f"\nDESCRIPTION") - if summary: - print(f" {summary}") - if description: - print(f" {description}") - - # PARAMETERS section - if args and isinstance(args, list): - print(f"\nPARAMETERS") - for arg in args: - if isinstance(arg, dict): - name_str = arg.get("name", "?") - typ = arg.get("type", "string") - required = arg.get("required", False) - desc = arg.get("description", "") - else: - name_str = getattr(arg, "name", "?") - typ = getattr(arg, "type", "string") - required = getattr(arg, "required", False) - desc = getattr(arg, "description", "") - - # Format: -Name [required flag] - req_marker = "[required]" if required else "[optional]" - print(f" -{name_str} <{typ}>") - if desc: - print(f" {desc}") - print(f" {req_marker}") - print() - - # REMARKS/DETAILS section - if details: - print(f"REMARKS") - for detail in details: - print(f" {detail}") - print() - - -# ============================================================================ -# SELECTION UTILITIES - Consolidated from selection_syntax.py and select_utils.py -# ============================================================================ - -def _parse_selection_syntax(token: str) -> Optional[Set[int]]: - """Parse @ selection syntax into a set of 1-based indices. - - Args: - token: Token starting with @ (e.g., "@2", "@2-5", "@{1,3,5}", "@*", "@3,5,7", "@3-6,8") - - - Returns: - Set of 1-based indices (for concrete selections like @1, @2-5, @3,5,7) - None for special cases: @* (all), @.. (restore previous), @,, (restore next) - None for invalid format - - Special handling: - - @* returns None and should be handled as "select all current items" - - @.. returns None and is handled as "restore previous table" (backward navigation) - - @,, returns None and is handled as "restore next table" (forward navigation) - - Invalid selections like @-1 or @a return None and are treated as invalid args - - Examples: - "@2" → {2} - "@2-5" → {2, 3, 4, 5} - "@{2,5,6}" → {2, 5, 6} - "@2,5,6" → {2, 5, 6} - "@2-5,8,10-12" → {2, 3, 4, 5, 8, 10, 11, 12} - "@*" → None (caller checks token=="@*" to handle as "all") - "@.." → None (backward navigation) - "@,," → None (forward navigation) - """ - if not token.startswith("@"): - return None - - selector = token[1:].strip() - - # Special case: @.. means restore previous result table (handled separately) - # Special case: @,, means restore next result table (handled separately) - # Special case: @* means all items (should be converted to actual list by caller) - if selector in (".", ",", "*"): - return None - - indices = set() - - # Handle set notation: @{2,5,6,7} (convert to standard format) - if selector.startswith("{") and selector.endswith("}"): - selector = selector[1:-1] - - # Handle mixed comma and range notation: @2,5,7-9,10 or @2-5,8,10-12 - parts = selector.split(",") - - for part in parts: - part = part.strip() - if not part: - continue - - try: - if "-" in part: - # Range notation: 2-5 or 7-9 - range_parts = part.split("-", 1) # Split on first - only (in case of negative numbers) - if len(range_parts) == 2: - start_str = range_parts[0].strip() - end_str = range_parts[1].strip() - - # Make sure both are valid positive integers - if start_str and end_str: - start = int(start_str) - end = int(end_str) - if start > 0 and end > 0 and start <= end: - indices.update(range(start, end + 1)) - else: - return None # Invalid range - else: - return None - else: - return None - else: - # Single number - num = int(part) - if num > 0: - indices.add(num) - else: - return None - except (ValueError, AttributeError): - return None - - return indices if indices else None - - -def _filter_items_by_selection(items: List, selection: Optional[Set[int]]) -> List: - """Filter items by 1-based selection indices. - - Args: - items: List of items to filter - selection: Set of 1-based indices, or None for all items - - Returns: - Filtered list of items in original order - - Examples: - _filter_items_by_selection([a, b, c, d], {2, 4}) → [b, d] - _filter_items_by_selection([a, b, c, d], None) → [a, b, c, d] - """ - if selection is None or len(selection) == 0: - return items - - filtered = [] - for i, item in enumerate(items, start=1): - if i in selection: - filtered.append(item) - - return filtered - - -def _parse_line_selection(args: Sequence[str]) -> Set[int]: - """Parse selection arguments to indices. - - Args: - args: Line numbers and ranges (1-indexed) - Examples: ["3"], ["1", "3", "5"], ["1-3"] - - Returns: - Set of 0-indexed line numbers to select - - Raises: - ValueError: If selection is invalid - """ - selected_indices: Set[int] = set() - - for arg in args: - arg = str(arg).strip() - - # Check if it's a range (e.g., "1-3") - if '-' in arg and not arg.startswith('-'): - try: - parts = arg.split('-') - if len(parts) == 2: - start = int(parts[0]) - 1 # Convert to 0-indexed - end = int(parts[1]) # End is exclusive in range - for i in range(start, end): - selected_indices.add(i) - else: - raise ValueError(f"Invalid range format: {arg}") - except ValueError as e: - raise ValueError(f"Invalid range: {arg}") from e - else: - # Single line number (1-indexed) - try: - line_num = int(arg) - idx = line_num - 1 # Convert to 0-indexed - selected_indices.add(idx) - except ValueError: - raise ValueError(f"Invalid line number: {arg}") - - return selected_indices - - -def _validate_indices(selected_indices: Set[int], total_lines: int) -> List[str]: - """Validate indices are within bounds. - - Args: - selected_indices: Set of 0-indexed line numbers - total_lines: Total number of available lines - - Returns: - List of error messages (empty if all valid) - """ - errors = [] - for idx in selected_indices: - if idx < 0 or idx >= total_lines: - errors.append(f"Line {idx + 1} out of range (1-{total_lines})") - return errors - - -def _select_lines(lines: List[str], selected_indices: Set[int]) -> List[str]: - """Select specific lines from input. - - Args: - lines: List of input lines - selected_indices: Set of 0-indexed line numbers to select - - Returns: - List of selected lines in order - """ - selected_indices_sorted = sorted(selected_indices) - return [lines[idx] for idx in selected_indices_sorted] - - -# Keep helper references so static analyzers treat them as used in this module. -_SELECTION_HELPERS = ( - _filter_items_by_selection, - _parse_line_selection, - _validate_indices, - _select_lines, -) - - -def main(): - """Entry point for the CLI.""" - app = _create_cmdlet_cli() - if app: - app() - else: - print("Typer not available") - if __name__ == "__main__": - main() + MedeiaCLI().run() diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index d5b47fb..21f6621 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -523,7 +523,7 @@ local function _refresh_store_cache(timeout_seconds) if not resp or not resp.success or type(resp.choices) ~= 'table' then _lua_log('stores: failed to load store choices via helper; stderr=' .. tostring(resp and resp.stderr or '') .. ' error=' .. tostring(resp and resp.error or '')) - -- Fallback: directly call Python to import CLI.get_store_choices(). + -- Fallback: directly call Python to import MedeiaCLI.get_store_choices(). -- This avoids helper IPC issues and still stays in sync with the REPL. local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python' local cli_path = (opts and opts.cli_path) and tostring(opts.cli_path) or nil @@ -537,7 +537,7 @@ local function _refresh_store_cache(timeout_seconds) if cli_path and cli_path ~= '' then local root = tostring(cli_path):match('(.*)[/\\]') or '' if root ~= '' then - local code = "import json, sys; sys.path.insert(0, r'" .. root .. "'); from CLI import get_store_choices; print(json.dumps(get_store_choices()))" + local code = "import json, sys; sys.path.insert(0, r'" .. root .. "'); from CLI import MedeiaCLI; print(json.dumps(MedeiaCLI.get_store_choices()))" local res = utils.subprocess({ args = { python, '-c', code }, cancellable = false, @@ -1027,7 +1027,7 @@ local function _start_download_flow_for_current() return end ensure_mpv_ipc_server() - M.run_pipeline('get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -hash ' .. store_hash.hash .. ' -path ' .. quote_pipeline_arg(folder)) + M.run_pipeline('get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder)) mp.osd_message('Download started', 2) return end diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index 90aed05..7c3e8d5 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -136,33 +136,10 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]: # Provide store backend choices using the same source as CLI/Typer autocomplete. if op_name in {"store-choices", "store_choices", "get-store-choices", "get_store_choices"}: - # Preferred: call the same choice function used by the CLI completer. - try: - from CLI import get_store_choices # noqa: WPS433 + from CLI import MedeiaCLI # noqa: WPS433 - backends = get_store_choices() - choices = sorted({str(n) for n in (backends or []) if str(n).strip()}) - except Exception: - # Fallback: direct Store registry enumeration using loaded config. - try: - cfg = load_config() or {} - except Exception: - cfg = {} - try: - from Store import Store # noqa: WPS433 - - storage = Store(cfg, suppress_debug=True) - backends = storage.list_backends() or [] - choices = sorted({str(n) for n in backends if str(n).strip()}) - except Exception as exc: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"{type(exc).__name__}: {exc}", - "table": None, - "choices": [], - } + backends = MedeiaCLI.get_store_choices() + choices = sorted({str(n) for n in (backends or []) if str(n).strip()}) return { "success": True, diff --git a/Provider/soulseek.py b/Provider/soulseek.py index aa9d069..fb340bc 100644 --- a/Provider/soulseek.py +++ b/Provider/soulseek.py @@ -23,6 +23,57 @@ _SOULSEEK_NOISE_SUBSTRINGS = ( ) +@contextlib.asynccontextmanager +async def _suppress_aioslsk_asyncio_task_noise() -> Any: + """Suppress non-fatal aioslsk task exceptions emitted via asyncio's loop handler. + + aioslsk may spawn background tasks (e.g. direct peer connection attempts) that + can fail with ConnectionFailedError. These are often expected and should not + end a successful download with a scary "Task exception was never retrieved" + traceback. + + We only swallow those specific cases and delegate everything else to the + previous/default handler. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # Not in an event loop. + yield + return + + previous_handler = loop.get_exception_handler() + + def _handler(loop: asyncio.AbstractEventLoop, context: Dict[str, Any]) -> None: + try: + exc = context.get("exception") + msg = str(context.get("message") or "") + # Only suppress un-retrieved task exceptions from aioslsk connection failures. + if msg == "Task exception was never retrieved" and exc is not None: + cls = getattr(exc, "__class__", None) + name = getattr(cls, "__name__", "") + mod = getattr(cls, "__module__", "") + if name == "ConnectionFailedError" and str(mod).startswith("aioslsk"): + return + except Exception: + # If our filter logic fails, fall through to default handling. + pass + + if previous_handler is not None: + previous_handler(loop, context) + else: + loop.default_exception_handler(context) + + loop.set_exception_handler(_handler) + try: + yield + finally: + try: + loop.set_exception_handler(previous_handler) + except Exception: + pass + + def _configure_aioslsk_logging() -> None: """Reduce aioslsk internal log noise. @@ -508,7 +559,8 @@ async def download_soulseek_file( client = SoulSeekClient(settings) with _suppress_aioslsk_noise(): try: - await client.start() + async with _suppress_aioslsk_asyncio_task_noise(): + await client.start() await client.login() debug(f"[soulseek] Logged in as {login_user}") diff --git a/SYS/worker_manager.py b/SYS/worker_manager.py index 6549374..2242b0f 100644 --- a/SYS/worker_manager.py +++ b/SYS/worker_manager.py @@ -11,7 +11,7 @@ from datetime import datetime from threading import Thread, Lock import time -from ..API.folder import API_folder_store +from API.folder import API_folder_store from SYS.logger import log logger = logging.getLogger(__name__) diff --git a/TUI/pipeline_runner.py b/TUI/pipeline_runner.py index be67459..e58aca5 100644 --- a/TUI/pipeline_runner.py +++ b/TUI/pipeline_runner.py @@ -27,10 +27,7 @@ from cmdlet import REGISTRY from config import get_local_storage_path, load_config from SYS.worker_manager import WorkerManager -try: # Reuse the CLI selection parser instead of reimplementing it. - from CLI import _parse_selection_syntax -except ImportError: # pragma: no cover - fallback for atypical environments - _parse_selection_syntax = None # type: ignore +from CLI import MedeiaCLI @dataclass(slots=True) @@ -368,11 +365,8 @@ class PipelineExecutor: @staticmethod def _parse_selection(token: str) -> Optional[Sequence[int]]: - if _parse_selection_syntax: - parsed = _parse_selection_syntax(token) - if parsed: - return sorted(parsed) - return None + parsed = MedeiaCLI.parse_selection_syntax(token) + return sorted(parsed) if parsed else None class _WorkerSession: diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index c9f7370..8696ea2 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -109,19 +109,15 @@ class SharedArgs: summary="Does something", usage="my-cmdlet", args=[ - SharedArgs.HASH, # Use predefined shared arg + SharedArgs.QUERY, # Use predefined shared arg (e.g., -query "hash:") SharedArgs.LOCATION, # Use another shared arg CmdletArg(...), # Mix with custom args ] ) """ - # File/Hash arguments - HASH = CmdletArg( - name="hash", - type="string", - description="File hash (SHA256, 64-char hex string)", - ) + # NOTE: This project no longer exposes a dedicated -hash flag. + # Use SharedArgs.QUERY with `hash:` syntax instead (e.g., -query "hash:"). STORE = CmdletArg( name="store", @@ -248,7 +244,7 @@ class SharedArgs: QUERY = CmdletArg( "query", type="string", - description="Search query string." + description="Unified query string (e.g., hash:, hash:{

,

})." ) REASON = CmdletArg( @@ -321,7 +317,7 @@ class SharedArgs: CmdletArg if found, None otherwise Example: - arg = SharedArgs.get('HASH') # Returns SharedArgs.HASH + arg = SharedArgs.get('QUERY') # Returns SharedArgs.QUERY """ try: return getattr(cls, name.upper()) @@ -527,6 +523,16 @@ def parse_cmdlet_args(args: Sequence[str], cmdlet_spec: Dict[str, Any] | Cmdlet) while i < len(args): token = str(args[i]) token_lower = token.lower() + + # Legacy guidance: -hash/--hash was removed in favor of -query "hash:...". + # We don't error hard here because some cmdlets also accept free-form args. + if token_lower in {"-hash", "--hash"}: + try: + log("Legacy flag -hash is no longer supported. Use: -query \"hash:\"", file=sys.stderr) + except Exception: + pass + i += 1 + continue # Check if this token is a known flagged argument if token_lower in arg_spec_map: @@ -608,6 +614,53 @@ def normalize_hash(hash_hex: Optional[str]) -> Optional[str]: return text +def parse_hash_query(query: Optional[str]) -> List[str]: + """Parse a unified query string for `hash:` into normalized SHA256 hashes. + + Supported examples: + - hash:

+ - hash:

,

,

+ - Hash:

+ - hash:{

,

} + + Returns: + List of unique normalized 64-hex SHA256 hashes. + """ + import re + + q = str(query or "").strip() + if not q: + return [] + + m = re.match(r"^hash(?:es)?\s*:\s*(.+)$", q, flags=re.IGNORECASE) + if not m: + return [] + + rest = (m.group(1) or "").strip() + if rest.startswith("{") and rest.endswith("}"): + rest = rest[1:-1].strip() + if rest.startswith("[") and rest.endswith("]"): + rest = rest[1:-1].strip() + + raw_parts = [p.strip() for p in re.split(r"[\s,]+", rest) if p.strip()] + out: List[str] = [] + for part in raw_parts: + h = normalize_hash(part) + if not h: + continue + if h not in out: + out.append(h) + return out + + +def parse_single_hash_query(query: Optional[str]) -> Optional[str]: + """Parse `hash:` query and require exactly one hash.""" + hashes = parse_hash_query(query) + if len(hashes) != 1: + return None + return hashes[0] + + def get_hash_for_operation(override_hash: Optional[str], result: Any, field_name: str = "hash") -> Optional[str]: """Get normalized hash from override or result object, consolidating common pattern. diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index 0f75b53..91bfd55 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -48,7 +48,6 @@ class Add_File(Cmdlet): arg=[ SharedArgs.PATH, SharedArgs.STORE, - SharedArgs.HASH, CmdletArg(name="provider", type="string", required=False, description="File hosting provider (e.g., 0x0)", alias="prov"), CmdletArg( name="room", @@ -1746,6 +1745,62 @@ class Add_File(Cmdlet): # Prepare metadata from pipe_obj and sidecars tags, url, title, f_hash = Add_File._prepare_metadata(result, media_path, pipe_obj, config) + # If we're moving/copying from one store to another, also copy the source store's + # existing associated URLs so they aren't lost. + try: + from metadata import normalize_urls + + source_store = None + source_hash = None + if isinstance(result, dict): + source_store = result.get("store") + source_hash = result.get("hash") + if not source_store: + source_store = getattr(pipe_obj, "store", None) + if not source_hash: + source_hash = getattr(pipe_obj, "hash", None) + if (not source_hash) and isinstance(pipe_obj.extra, dict): + source_hash = pipe_obj.extra.get("hash") + + source_store = str(source_store or "").strip() + source_hash = str(source_hash or "").strip().lower() + if ( + source_store + and source_hash + and len(source_hash) == 64 + and source_store.lower() != str(backend_name or "").strip().lower() + ): + source_backend = None + try: + if source_store in store.list_backends(): + source_backend = store[source_store] + except Exception: + source_backend = None + + if source_backend is not None: + try: + src_urls = normalize_urls(source_backend.get_url(source_hash) or []) + except Exception: + src_urls = [] + + try: + dst_urls = normalize_urls(url or []) + except Exception: + dst_urls = [] + + merged: list[str] = [] + seen: set[str] = set() + for u in list(dst_urls or []) + list(src_urls or []): + if not u: + continue + if u in seen: + continue + seen.add(u) + merged.append(u) + url = merged + except Exception: + pass + # Collect relationship pairs for post-ingest DB/API persistence. if collect_relationship_pairs is not None: rels = Add_File._get_relationships(result, pipe_obj) diff --git a/cmdlet/add_note.py b/cmdlet/add_note.py index 6dd3b47..ddbf0a1 100644 --- a/cmdlet/add_note.py +++ b/cmdlet/add_note.py @@ -25,11 +25,11 @@ class Add_Note(Cmdlet): super().__init__( name="add-note", summary="Add file store note", - usage="add-note -store [-hash ] ", + usage="add-note -store [-query \"hash:\"] ", alias=[""], arg=[ SharedArgs.STORE, - SharedArgs.HASH, + SharedArgs.QUERY, CmdletArg("name", type="string", required=True, description="The note name/key to set (e.g. 'comment', 'lyric')."), CmdletArg("text", type="string", required=True, description="Note text/content to store.", variadic=True), ], @@ -72,7 +72,10 @@ class Add_Note(Cmdlet): parsed = parse_cmdlet_args(args, self) store_override = parsed.get("store") - hash_override = parsed.get("hash") + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("[add_note] Error: -query must be of the form hash:", file=sys.stderr) + return 1 note_name = str(parsed.get("name") or "").strip() text_parts = parsed.get("text") @@ -91,10 +94,10 @@ class Add_Note(Cmdlet): results = normalize_result_input(result) if not results: - if store_override and normalize_hash(hash_override): - results = [{"store": str(store_override), "hash": normalize_hash(hash_override)}] + if store_override and query_hash: + results = [{"store": str(store_override), "hash": query_hash}] else: - log("[add_note] Error: Requires piped item(s) or -store and -hash", file=sys.stderr) + log("[add_note] Error: Requires piped item(s) or -store and -query \"hash:\"", file=sys.stderr) return 1 store_registry = Store(config) @@ -161,7 +164,7 @@ class Add_Note(Cmdlet): resolved_hash = self._resolve_hash( raw_hash=str(raw_hash) if raw_hash else None, raw_path=str(raw_path) if raw_path else None, - override_hash=str(hash_override) if hash_override else None, + override_hash=str(query_hash) if query_hash else None, ) if not resolved_hash: log("[add_note] Warning: Item missing usable hash; skipping", file=sys.stderr) diff --git a/cmdlet/add_relationship.py b/cmdlet/add_relationship.py index f70759f..68f15f5 100644 --- a/cmdlet/add_relationship.py +++ b/cmdlet/add_relationship.py @@ -31,7 +31,7 @@ CMDLET = Cmdlet( arg=[ CmdletArg("path", type="string", description="Specify the local file path (if not piping a result)."), SharedArgs.STORE, - SharedArgs.HASH, + SharedArgs.QUERY, CmdletArg("-king", type="string", description="Explicitly set the king hash/file for relationships (e.g., -king @4 or -king hash)"), CmdletArg("-alt", type="string", description="Explicitly select alt item(s) by @ selection or hash list (e.g., -alt @3-5 or -alt ,)"), CmdletArg("-type", type="string", description="Relationship type for piped items (default: 'alt', options: 'king', 'alt', 'related')"), @@ -372,7 +372,7 @@ def _refresh_relationship_view_if_current(target_hash: Optional[str], target_pat refresh_args: list[str] = [] if target_hash: - refresh_args.extend(["-hash", target_hash]) + refresh_args.extend(["-query", f"hash:{target_hash}"]) get_relationship(subject, refresh_args, config) except Exception: pass @@ -396,7 +396,10 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: parsed = parse_cmdlet_args(_args, CMDLET) arg_path: Optional[Path] = None override_store = parsed.get("store") - override_hash = parsed.get("hash") + override_hashes = sh.parse_hash_query(parsed.get("query")) + if parsed.get("query") and not override_hashes: + log("Invalid -query value (expected hash:)", file=sys.stderr) + return 1 king_arg = parsed.get("king") alt_arg = parsed.get("alt") rel_type = parsed.get("type", "alt") @@ -436,20 +439,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: resolved_alt_items = [{"hash": h, "store": str(override_store)} for h in hashes] items_to_process = normalize_result_input(resolved_alt_items) - # Allow explicit -hash operation (store/hash-first) - if (not items_to_process) and override_hash: - # Support comma-separated hashes - raw = str(override_hash) - parts = [p.strip() for p in raw.replace(";", ",").split(",")] - hashes = [h for h in (_normalise_hash_hex(p) for p in parts) if h] - if not hashes: - log("Invalid -hash value (expected 64-hex sha256)", file=sys.stderr) - return 1 - # Use the selected/override store; required in this mode + # Allow explicit store/hash-first operation via -query "hash:" (supports multiple hash: tokens) + if (not items_to_process) and override_hashes: if not override_store: - log("-store is required when using -hash without piped items", file=sys.stderr) + log("-store is required when using -query without piped items", file=sys.stderr) return 1 - items_to_process = [{"hash": h, "store": str(override_store)} for h in hashes] + items_to_process = [{"hash": h, "store": str(override_store)} for h in override_hashes] if not items_to_process and not arg_path: log("No items provided to add-relationship (no piped result and no -path)", file=sys.stderr) diff --git a/cmdlet/add_tag.py b/cmdlet/add_tag.py index d63e8fa..d08c08f 100644 --- a/cmdlet/add_tag.py +++ b/cmdlet/add_tag.py @@ -205,7 +205,7 @@ def _refresh_tag_view(res: Any, target_hash: Optional[str], store_name: Optional if not target_hash or not store_name: return - refresh_args: List[str] = ["-hash", target_hash, "-store", store_name] + refresh_args: List[str] = ["-query", f"hash:{target_hash}", "-store", store_name] get_tag = None try: @@ -237,10 +237,10 @@ class Add_Tag(Cmdlet): super().__init__( name="add-tag", summary="Add tag to a file in a store.", - usage="add-tag -store [-hash ] [-duplicate ] [-list [,...]] [--all] [,...]", + usage="add-tag -store [-query \"hash:\"] [-duplicate ] [-list [,...]] [--all] [,...]", arg=[ CmdletArg("tag", type="string", required=False, description="One or more tag to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tag from pipeline payload.", variadic=True), - SharedArgs.HASH, + SharedArgs.QUERY, SharedArgs.STORE, CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"), CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."), @@ -249,7 +249,7 @@ class Add_Tag(Cmdlet): detail=[ "- By default, only tag non-temporary files (from pipelines). Use --all to tag everything.", "- Requires a store backend: use -store or pipe items that include store.", - "- If -hash is not provided, uses the piped item's hash (or derives from its path when possible).", + "- If -query is not provided, uses the piped item's hash (or derives from its path when possible).", "- Multiple tag can be comma-separated or space-separated.", "- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult", "- tag can also reference lists with curly braces: add-tag {philosophy} \"other:tag\"", @@ -258,7 +258,6 @@ class Add_Tag(Cmdlet): " Inferred format: -duplicate title,album,artist (first is source, rest are targets)", "- The source namespace must already exist in the file being tagged.", "- Target namespaces that already have a value are skipped (not overwritten).", - "- You can also pass the target hash as a tag token: hash:. This overrides -hash and is removed from the tag list.", ], exec=self.run, ) @@ -273,6 +272,11 @@ class Add_Tag(Cmdlet): # Parse arguments parsed = parse_cmdlet_args(args, self) + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("[add_tag] Error: -query must be of the form hash:", file=sys.stderr) + return 1 + # If add-tag is in the middle of a pipeline (has downstream stages), default to # including temp files. This enables common flows like: # @N | download-media | add-tag ... | add-file ... @@ -337,24 +341,12 @@ class Add_Tag(Cmdlet): tag_to_add = parse_tag_arguments(raw_tag) tag_to_add = expand_tag_groups(tag_to_add) - # Allow hash override via namespaced token (e.g., "hash:abcdef...") - extracted_hash = None - filtered_tag: List[str] = [] - for tag in tag_to_add: - if isinstance(tag, str) and tag.lower().startswith("hash:"): - _, _, hash_val = tag.partition(":") - if hash_val: - extracted_hash = normalize_hash(hash_val.strip()) - continue - filtered_tag.append(tag) - tag_to_add = filtered_tag - if not tag_to_add: log("No tag provided to add", file=sys.stderr) return 1 - # Get other flags (hash override can come from -hash or hash: token) - hash_override = normalize_hash(parsed.get("hash")) or extracted_hash + # Get other flags + hash_override = normalize_hash(query_hash) if query_hash else None duplicate_arg = parsed.get("duplicate") # tag ARE provided - apply them to each store-backed result diff --git a/cmdlet/add_url.py b/cmdlet/add_url.py index f2d5bd5..b1bfc04 100644 --- a/cmdlet/add_url.py +++ b/cmdlet/add_url.py @@ -18,7 +18,7 @@ class Add_Url(sh.Cmdlet): summary="Associate a URL with a file", usage="@1 | add-url ", arg=[ - sh.SharedArgs.HASH, + sh.SharedArgs.QUERY, sh.SharedArgs.STORE, sh.CmdletArg("url", required=True, description="URL to associate"), ], @@ -33,14 +33,19 @@ class Add_Url(sh.Cmdlet): def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Add URL to file via hash+store backend.""" parsed = sh.parse_cmdlet_args(args, self) + + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("Error: -query must be of the form hash:") + return 1 # Extract hash and store from result or args - file_hash = parsed.get("hash") or sh.get_field(result, "hash") + file_hash = query_hash or sh.get_field(result, "hash") store_name = parsed.get("store") or sh.get_field(result, "store") url_arg = parsed.get("url") if not file_hash: - log("Error: No file hash provided") + log("Error: No file hash provided (pipe an item or use -query \"hash:\")") return 1 if not store_name: diff --git a/cmdlet/delete_file.py b/cmdlet/delete_file.py index b4e6a95..7679962 100644 --- a/cmdlet/delete_file.py +++ b/cmdlet/delete_file.py @@ -20,10 +20,10 @@ class Delete_File(sh.Cmdlet): super().__init__( name="delete-file", summary="Delete a file locally and/or from Hydrus, including database entries.", - usage="delete-file [-hash ] [-conserve ] [-lib-root ] [reason]", + usage="delete-file [-query \"hash:\"] [-conserve ] [-lib-root ] [reason]", alias=["del-file"], arg=[ - sh.CmdletArg("hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."), + sh.SharedArgs.QUERY, sh.CmdletArg("conserve", description="Choose which copy to keep: 'local' or 'hydrus'."), sh.CmdletArg("lib-root", description="Path to local library root for database cleanup."), sh.CmdletArg("reason", description="Optional reason for deletion (free text)."), @@ -196,6 +196,7 @@ class Delete_File(sh.Cmdlet): return 0 # Parse arguments + override_query: str | None = None override_hash: str | None = None conserve: str | None = None lib_root: str | None = None @@ -205,8 +206,8 @@ class Delete_File(sh.Cmdlet): while i < len(args): token = args[i] low = str(token).lower() - if low in {"-hash", "--hash", "hash"} and i + 1 < len(args): - override_hash = str(args[i + 1]).strip() + if low in {"-query", "--query", "query"} and i + 1 < len(args): + override_query = str(args[i + 1]).strip() i += 2 continue if low in {"-conserve", "--conserve"} and i + 1 < len(args): @@ -222,6 +223,11 @@ class Delete_File(sh.Cmdlet): reason_tokens.append(token) i += 1 + override_hash = sh.parse_single_hash_query(override_query) if override_query else None + if override_query and not override_hash: + log("Invalid -query value (expected hash:)", file=sys.stderr) + return 1 + # If no lib_root provided, try to get the first folder store from config if not lib_root: try: diff --git a/cmdlet/delete_note.py b/cmdlet/delete_note.py index df81b3c..a965fdc 100644 --- a/cmdlet/delete_note.py +++ b/cmdlet/delete_note.py @@ -26,11 +26,11 @@ class Delete_Note(Cmdlet): super().__init__( name="delete-note", summary="Delete a named note from a file in a store.", - usage="delete-note -store [-hash ] ", + usage="delete-note -store [-query \"hash:\"] ", alias=["del-note"], arg=[ SharedArgs.STORE, - SharedArgs.HASH, + SharedArgs.QUERY, CmdletArg("name", type="string", required=True, description="The note name/key to delete."), ], detail=[ @@ -68,7 +68,10 @@ class Delete_Note(Cmdlet): parsed = parse_cmdlet_args(args, self) store_override = parsed.get("store") - hash_override = parsed.get("hash") + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("[delete_note] Error: -query must be of the form hash:", file=sys.stderr) + return 1 note_name_override = str(parsed.get("name") or "").strip() # Allow piping note rows from get-note: the selected item carries note_name. inferred_note_name = str(get_field(result, "note_name") or "").strip() @@ -78,10 +81,10 @@ class Delete_Note(Cmdlet): results = normalize_result_input(result) if not results: - if store_override and normalize_hash(hash_override): - results = [{"store": str(store_override), "hash": normalize_hash(hash_override)}] + if store_override and query_hash: + results = [{"store": str(store_override), "hash": query_hash}] else: - log("[delete_note] Error: Requires piped item(s) or -store and -hash", file=sys.stderr) + log("[delete_note] Error: Requires piped item(s) or -store and -query \"hash:\"", file=sys.stderr) return 1 store_registry = Store(config) @@ -109,7 +112,7 @@ class Delete_Note(Cmdlet): resolved_hash = self._resolve_hash( raw_hash=str(raw_hash) if raw_hash else None, raw_path=str(raw_path) if raw_path else None, - override_hash=str(hash_override) if hash_override else None, + override_hash=str(query_hash) if query_hash else None, ) if not resolved_hash: ctx.emit(res) diff --git a/cmdlet/delete_relationship.py b/cmdlet/delete_relationship.py index cf2ba1e..ffb27b9 100644 --- a/cmdlet/delete_relationship.py +++ b/cmdlet/delete_relationship.py @@ -117,7 +117,7 @@ def _refresh_relationship_view_if_current(target_hash: Optional[str], target_pat refresh_args: list[str] = [] if target_hash: - refresh_args.extend(["-hash", target_hash]) + refresh_args.extend(["-query", f"hash:{target_hash}"]) cmd = get_cmdlet("get-relationship") if not cmd: @@ -148,24 +148,21 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: delete_all_flag = parsed_args.get("all", False) rel_type_filter = parsed_args.get("type") override_store = parsed_args.get("store") - override_hash = parsed_args.get("hash") + override_hashes = sh.parse_hash_query(parsed_args.get("query")) + if parsed_args.get("query") and not override_hashes: + log("Invalid -query value (expected hash:)", file=sys.stderr) + return 1 raw_path = parsed_args.get("path") # Normalize input results = normalize_result_input(result) # Allow store/hash-first usage when no pipeline items were provided - if (not results) and override_hash: - raw = str(override_hash) - parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()] - hashes = [h for h in (normalize_hash(p) for p in parts) if h] - if not hashes: - log("Invalid -hash value (expected 64-hex sha256)", file=sys.stderr) - return 1 + if (not results) and override_hashes: if not override_store: - log("-store is required when using -hash without piped items", file=sys.stderr) + log("-store is required when using -query without piped items", file=sys.stderr) return 1 - results = [{"hash": h, "store": str(override_store)} for h in hashes] + results = [{"hash": h, "store": str(override_store)} for h in override_hashes] if not results: # Legacy -path mode below may still apply @@ -228,7 +225,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: except Exception: file_hash = None if not file_hash: - log("Could not extract file hash for deletion (use -hash or ensure pipeline includes hash)", file=sys.stderr) + log("Could not extract file hash for deletion (use -query \"hash:\" or ensure pipeline includes hash)", file=sys.stderr) return 1 meta = db.get_metadata(file_hash) or {} @@ -380,7 +377,7 @@ CMDLET = Cmdlet( arg=[ CmdletArg("path", type="string", description="Specify the local file path (legacy mode, if not piping a result)."), SharedArgs.STORE, - SharedArgs.HASH, + SharedArgs.QUERY, CmdletArg("all", type="flag", description="Delete all relationships for the file(s)."), CmdletArg("type", type="string", description="Delete specific relationship type ('alt', 'king', 'related'). Default: delete all types."), ], diff --git a/cmdlet/delete_tag.py b/cmdlet/delete_tag.py index 0141627..600497d 100644 --- a/cmdlet/delete_tag.py +++ b/cmdlet/delete_tag.py @@ -65,7 +65,7 @@ def _refresh_tag_view_if_current(file_hash: str | None, store_name: str | None, refresh_args: list[str] = [] if file_hash: - refresh_args.extend(["-hash", file_hash]) + refresh_args.extend(["-query", f"hash:{file_hash}"]) if store_name: refresh_args.extend(["-store", store_name]) get_tag(subject, refresh_args, config) @@ -76,14 +76,14 @@ def _refresh_tag_view_if_current(file_hash: str | None, store_name: str | None, CMDLET = Cmdlet( name="delete-tag", summary="Remove tags from a file in a store.", - usage="delete-tag -store [-hash ] [,...]", + usage="delete-tag -store [-query \"hash:\"] [,...]", arg=[ - SharedArgs.HASH, + SharedArgs.QUERY, SharedArgs.STORE, CmdletArg("[,...]", required=True, description="One or more tags to remove. Comma- or space-separated."), ], detail=[ - "- Requires a Hydrus file (hash present) or explicit -hash override.", + "- Requires a Hydrus file (hash present) or explicit -query override.", "- Multiple tags can be comma-separated or space-separated.", ], ) @@ -111,11 +111,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: has_piped_tag = _looks_like_tag_row(result) has_piped_tag_list = isinstance(result, list) and bool(result) and _looks_like_tag_row(result[0]) - if not args and not has_piped_tag and not has_piped_tag_list: - log("Requires at least one tag argument") - return 1 - - # Parse -hash override and collect tags from remaining args + # Parse -query/-store overrides and collect remaining args. + override_query: str | None = None override_hash: str | None = None override_store: str | None = None rest: list[str] = [] @@ -123,8 +120,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: while i < len(args): a = args[i] low = str(a).lower() - if low in {"-hash", "--hash", "hash"} and i + 1 < len(args): - override_hash = str(args[i + 1]).strip() + if low in {"-query", "--query", "query"} and i + 1 < len(args): + override_query = str(args[i + 1]).strip() i += 2 continue if low in {"-store", "--store", "store"} and i + 1 < len(args): @@ -133,64 +130,37 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: continue rest.append(a) i += 1 - - # Check if first argument is @ syntax (result table selection) - # @5 or @{2,5,8} to delete tags from ResultTable by index - tags_from_at_syntax = [] - hash_from_at_syntax = None - path_from_at_syntax = None - store_from_at_syntax = None - - if rest and str(rest[0]).startswith("@"): - selector_arg = str(rest[0]) - pipe_selector = selector_arg[1:].strip() - # Parse @N or @{N,M,K} syntax - if pipe_selector.startswith("{") and pipe_selector.endswith("}"): - # @{2,5,8} - pipe_selector = pipe_selector[1:-1] - try: - indices = [int(tok.strip()) for tok in pipe_selector.split(',') if tok.strip()] - except ValueError: - log("Invalid selection syntax. Use @2 or @{2,5,8}") - return 1 - - # Get the last ResultTable from pipeline context - try: - last_table = ctx._LAST_RESULT_TABLE - if last_table: - # Extract tags from selected rows - for idx in indices: - if 1 <= idx <= len(last_table.rows): - # Look for a TagItem in _LAST_RESULT_ITEMS by index - if idx - 1 < len(ctx._LAST_RESULT_ITEMS): - item = ctx._LAST_RESULT_ITEMS[idx - 1] - if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem': - tag_name = get_field(item, 'tag_name') - if tag_name: - log(f"[delete_tag] Extracted tag from @{idx}: {tag_name}") - tags_from_at_syntax.append(tag_name) - # Also get hash from first item for consistency - if not hash_from_at_syntax: - hash_from_at_syntax = get_field(item, 'hash') - if not path_from_at_syntax: - path_from_at_syntax = get_field(item, 'path') - if not store_from_at_syntax: - store_from_at_syntax = get_field(item, 'store') - - if not tags_from_at_syntax: - log(f"No tags found at indices: {indices}") - return 1 - else: - log("No ResultTable in pipeline (use @ after running get-tag)") - return 1 - except Exception as exc: - log(f"Error processing @ selection: {exc}", file=__import__('sys').stderr) - return 1 - - # Handle @N selection which creates a list - extract the first item - # If we have a list of TagItems, we want to process ALL of them if no args provided - # This handles: delete-tag @1 (where @1 expands to a list containing one TagItem) - # Also handles: delete-tag @1,2 (where we want to delete tags from multiple files) + + override_hash = sh.parse_single_hash_query(override_query) if override_query else None + if override_query and not override_hash: + log("Invalid -query value (expected hash:)", file=sys.stderr) + return 1 + + # Selection syntax (@...) is handled by the pipeline runner, not by this cmdlet. + # If @ reaches here as a literal argument, it's almost certainly user error. + if rest and str(rest[0]).startswith("@") and not (has_piped_tag or has_piped_tag_list): + log("Selection syntax is only supported via piping. Use: @N | delete-tag") + return 1 + + # Special case: grouped tag selection created by the pipeline runner. + # This represents "delete these selected tags" (not "delete tags from this file"). + grouped_table = "" + try: + grouped_table = str(get_field(result, "table") or "").strip().lower() + except Exception: + grouped_table = "" + grouped_tags = get_field(result, "tag") if result is not None else None + tags_arg = parse_tag_arguments(rest) + if grouped_table == "tag.selection" and isinstance(grouped_tags, list) and grouped_tags and not tags_arg: + file_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(result, "hash")) + store_name = override_store or get_field(result, "store") + path = get_field(result, "path") or get_field(result, "target") + tags = [str(t) for t in grouped_tags if t] + return 0 if _process_deletion(tags, file_hash, path, store_name, config) else 1 + + if not tags_arg and not has_piped_tag and not has_piped_tag_list: + log("Requires at least one tag argument") + return 1 # Normalize result to a list for processing items_to_process = [] @@ -198,6 +168,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: items_to_process = result elif result: items_to_process = [result] + + # Process each item + success_count = 0 # If we have TagItems and no args, we are deleting the tags themselves # If we have Files (or other objects) and args, we are deleting tags FROM those files @@ -206,81 +179,66 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: is_tag_item_mode = bool(items_to_process) and _looks_like_tag_row(items_to_process[0]) if is_tag_item_mode: - # Collect all tags to delete from the TagItems - # Group by hash/file_path to batch operations if needed, or just process one by one - # For simplicity, we'll process one by one or group by file - pass + # Collect all tags to delete from the TagItems and batch per file. + # This keeps delete-tag efficient (one backend call per file). + groups: Dict[tuple[str, str, str], list[str]] = {} + for item in items_to_process: + tag_name = get_field(item, "tag_name") + if not tag_name: + continue + item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(item, "hash")) + item_store = override_store or get_field(item, "store") + item_path = get_field(item, "path") or get_field(item, "target") + key = (str(item_hash or ""), str(item_store or ""), str(item_path or "")) + groups.setdefault(key, []).append(str(tag_name)) + + for (h, s, p), tag_list in groups.items(): + if not tag_list: + continue + if _process_deletion(tag_list, h or None, p or None, s or None, config): + success_count += 1 + return 0 if success_count > 0 else 1 else: # "Delete tags from files" mode # We need args (tags to delete) - if not args and not tags_from_at_syntax: + if not tags_arg: log("Requires at least one tag argument when deleting from files") return 1 # Process each item - success_count = 0 # If we have tags from @ syntax (e.g. delete-tag @{1,2}), we ignore the piped result for tag selection # but we might need the piped result for the file context if @ selection was from a Tag table # Actually, the @ selection logic above already extracted tags. - if tags_from_at_syntax: - # Special case: @ selection of tags. - # We already extracted tags and hash/path. - # Just run the deletion once using the extracted info. - # This preserves the existing logic for @ selection. - - tags = tags_from_at_syntax - file_hash = normalize_hash(override_hash) if override_hash else normalize_hash(hash_from_at_syntax) - path = path_from_at_syntax - store_name = override_store or store_from_at_syntax - - if _process_deletion(tags, file_hash, path, store_name, config): - success_count += 1 - - else: - # Process items from pipe (or single result) - # If args are provided, they are the tags to delete from EACH item - # If items are TagItems and no args, the tag to delete is the item itself - - tags_arg = parse_tag_arguments(rest) - - for item in items_to_process: - tags_to_delete = [] - item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(item, "hash")) - item_path = ( - get_field(item, "path") - or get_field(item, "target") - ) - item_store = override_store or get_field(item, "store") - - if _looks_like_tag_row(item): - # It's a tag row (TagItem or PipeObject/dict with tag_name) - if tags_arg: - # User provided tags to delete FROM this file (ignoring the tag name in the item?) - # Or maybe they want to delete the tag in the item AND the args? - # Usually if piping TagItems, we delete THOSE tags. - # If args are present, maybe we should warn? - # For now, if args are present, assume they override or add to the tag item? - # Let's assume if args are present, we use args. If not, we use the tag name. - tags_to_delete = tags_arg - else: - tag_name = get_field(item, 'tag_name') - if tag_name: - tags_to_delete = [tag_name] + # Process items from pipe (or single result) + # If args are provided, they are the tags to delete from EACH item + # If items are TagItems and no args, the tag to delete is the item itself + for item in items_to_process: + tags_to_delete: list[str] = [] + item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(item, "hash")) + item_path = ( + get_field(item, "path") + or get_field(item, "target") + ) + item_store = override_store or get_field(item, "store") + + if _looks_like_tag_row(item): + if tags_arg: + tags_to_delete = tags_arg else: - # It's a File or other object - if tags_arg: - tags_to_delete = tags_arg - else: - # No tags provided for a file object - skip or error? - # We already logged an error if no args and not TagItem mode globally, - # but inside the loop we might have mixed items? Unlikely. - continue - - if tags_to_delete: - if _process_deletion(tags_to_delete, item_hash, item_path, item_store, config): - success_count += 1 + tag_name = get_field(item, 'tag_name') + if tag_name: + tags_to_delete = [str(tag_name)] + else: + if tags_arg: + tags_to_delete = tags_arg + else: + continue + + if tags_to_delete: + if _process_deletion(tags_to_delete, item_hash, item_path, item_store, config): + success_count += 1 if success_count > 0: return 0 diff --git a/cmdlet/delete_url.py b/cmdlet/delete_url.py index f66c1c4..ac652be 100644 --- a/cmdlet/delete_url.py +++ b/cmdlet/delete_url.py @@ -27,7 +27,7 @@ class Delete_Url(Cmdlet): summary="Remove a URL association from a file", usage="@1 | delete-url ", arg=[ - SharedArgs.HASH, + SharedArgs.QUERY, SharedArgs.STORE, CmdletArg("url", required=True, description="URL to remove"), ], @@ -42,14 +42,19 @@ class Delete_Url(Cmdlet): def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Delete URL from file via hash+store backend.""" parsed = parse_cmdlet_args(args, self) + + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("Error: -query must be of the form hash:") + return 1 # Extract hash and store from result or args - file_hash = parsed.get("hash") or get_field(result, "hash") + file_hash = query_hash or get_field(result, "hash") store_name = parsed.get("store") or get_field(result, "store") url_arg = parsed.get("url") if not file_hash: - log("Error: No file hash provided") + log("Error: No file hash provided (pipe an item or use -query \"hash:\")") return 1 if not store_name: diff --git a/cmdlet/download_media.py b/cmdlet/download_media.py index b2fc013..99388a9 100644 --- a/cmdlet/download_media.py +++ b/cmdlet/download_media.py @@ -1124,7 +1124,8 @@ def download_media( # If we downloaded sections, look for files with the session_id pattern if opts.clip_sections and session_id: # Pattern: "{session_id}_1.ext", "{session_id}_2.ext", etc. - section_pattern = re.compile(rf'^{re.escape(session_id)}_(\d+)\.') + # Also includes sidecars like "{session_id}_1.en.vtt". + section_pattern = re.compile(rf'^{re.escape(session_id)}_(\d+)') matching_files = [f for f in files if section_pattern.search(f.name)] if matching_files: @@ -1136,38 +1137,116 @@ def download_media( matching_files.sort(key=extract_section_num) debug(f"Found {len(matching_files)} section file(s) matching pattern") - # Now rename section files to use hash-based names - # This ensures unique filenames for each section content - renamed_files = [] - - for idx, section_file in enumerate(matching_files, 1): + # Now rename section *media* files to use hash-based names. + # Sidecars (subtitles) are renamed to match the media hash so they can be + # attached as notes later (and not emitted as separate pipeline items). + by_index: Dict[int, List[Path]] = {} + for f in matching_files: + m = section_pattern.search(f.name) + if not m: + continue try: - # Calculate hash for the file - file_hash = sha256_file(section_file) - ext = section_file.suffix - new_name = f"{file_hash}{ext}" - new_path = opts.output_dir / new_name - - if new_path.exists() and new_path != section_file: - # If file with same hash exists, use it and delete the temp one - debug(f"File with hash {file_hash} already exists, using existing file.") + n = int(m.group(1)) + except Exception: + continue + by_index.setdefault(n, []).append(f) + + renamed_media_files: List[Path] = [] + + for sec_num in sorted(by_index.keys()): + group = by_index.get(sec_num) or [] + if not group: + continue + + def _is_subtitle(p: Path) -> bool: + try: + return p.suffix.lower() in _SUBTITLE_EXTS + except Exception: + return False + + media_candidates = [p for p in group if not _is_subtitle(p)] + subtitle_candidates = [p for p in group if _is_subtitle(p)] + + # Pick the primary media file for this section. + # Prefer non-json, non-info sidecars. + media_file: Optional[Path] = None + for cand in media_candidates: + try: + if cand.suffix.lower() in {".json", ".info.json"}: + continue + except Exception: + pass + media_file = cand + break + if media_file is None and media_candidates: + media_file = media_candidates[0] + if media_file is None: + # No media file found for this section; skip. + continue + + try: + media_hash = sha256_file(media_file) + except Exception as e: + debug(f"Failed to hash section media file {media_file.name}: {e}") + renamed_media_files.append(media_file) + continue + + # Preserve any suffix tail after the section index so language tags survive. + # Example: _1.en.vtt -> .en.vtt + prefix = f"{session_id}_{sec_num}" + + def _tail(name: str) -> str: + try: + if name.startswith(prefix): + return name[len(prefix):] + except Exception: + pass + # Fallback: keep just the last suffix. + try: + return Path(name).suffix + except Exception: + return "" + + # Rename media file to (tail typically like .mkv). + try: + new_media_name = f"{media_hash}{_tail(media_file.name)}" + new_media_path = opts.output_dir / new_media_name + if new_media_path.exists() and new_media_path != media_file: + debug(f"File with hash {media_hash} already exists, using existing file.") try: - section_file.unlink() + media_file.unlink() except OSError: pass - renamed_files.append(new_path) else: - section_file.rename(new_path) - debug(f"Renamed section file: {section_file.name} → {new_name}") - renamed_files.append(new_path) + media_file.rename(new_media_path) + debug(f"Renamed section file: {media_file.name} -> {new_media_name}") + renamed_media_files.append(new_media_path) except Exception as e: - debug(f"Failed to process section file {section_file.name}: {e}") - renamed_files.append(section_file) - - media_path = renamed_files[0] - media_paths = renamed_files + debug(f"Failed to rename section media file {media_file.name}: {e}") + renamed_media_files.append(media_file) + new_media_path = media_file + + # Rename subtitle sidecars to match media hash for later note attachment. + for sub_file in subtitle_candidates: + try: + new_sub_name = f"{media_hash}{_tail(sub_file.name)}" + new_sub_path = opts.output_dir / new_sub_name + if new_sub_path.exists() and new_sub_path != sub_file: + try: + sub_file.unlink() + except OSError: + pass + else: + sub_file.rename(new_sub_path) + debug(f"Renamed section file: {sub_file.name} -> {new_sub_name}") + except Exception as e: + debug(f"Failed to rename section subtitle file {sub_file.name}: {e}") + + media_path = renamed_media_files[0] if renamed_media_files else matching_files[0] + media_paths = renamed_media_files if renamed_media_files else None if not opts.quiet: - debug(f"✓ Downloaded {len(media_paths)} section file(s) (session: {session_id})") + count = len(media_paths) if isinstance(media_paths, list) else 1 + debug(f"✓ Downloaded {count} section media file(s) (session: {session_id})") else: # Fallback to most recent file if pattern not found media_path = files[0] @@ -1398,9 +1477,14 @@ class Download_Media(Cmdlet): alias=[""], arg=[ SharedArgs.URL, + SharedArgs.QUERY, CmdletArg(name="audio", type="flag", alias="a", description="Download audio only"), CmdletArg(name="format", type="string", alias="fmt", description="Explicit yt-dlp format selector"), - CmdletArg(name="clip", type="string", description="Extract time range: MM:SS-MM:SS"), + CmdletArg( + name="clip", + type="string", + description="Extract time range(s) or keyed spec (e.g., clip:3m4s-3m14s,item:2-3)", + ), CmdletArg(name="item", type="string", description="Item selection for playlists/formats"), SharedArgs.PATH ], @@ -1483,6 +1567,34 @@ class Download_Media(Cmdlet): # Get other options clip_spec = parsed.get("clip") + query_spec = parsed.get("query") + + # download-media supports a small keyed spec language inside -query. + # Examples: + # -query "hash:" + # -query "clip:1m-1m15s,2m1s-2m11s" + # -query "hash:,clip:1m-1m15s,item:2-3" + query_keyed: Dict[str, List[str]] = {} + if query_spec: + try: + query_keyed = self._parse_keyed_csv_spec(str(query_spec), default_key="hash") + except Exception: + query_keyed = {} + + # Optional: allow an explicit hash via -query "hash:". + # This is used as the preferred king hash for multi-clip relationships. + query_hash_override: Optional[str] = None + try: + hash_values = query_keyed.get("hash", []) if isinstance(query_keyed, dict) else [] + hash_candidate = (hash_values[-1] if hash_values else None) + if hash_candidate: + # Re-wrap for the shared parser which expects the `hash:` prefix. + query_hash_override = sh.parse_single_hash_query(f"hash:{hash_candidate}") + else: + # Backwards-compatible: treat a non-keyed query as a hash query. + query_hash_override = sh.parse_single_hash_query(str(query_spec)) if query_spec else None + except Exception: + query_hash_override = None # Always enable chapters + subtitles so downstream pipes (e.g. mpv) can consume them. embed_chapters = True @@ -1492,12 +1604,38 @@ class Download_Media(Cmdlet): # Parse clip range(s) if specified clip_ranges: Optional[List[tuple[int, int]]] = None + clip_values: List[str] = [] + item_values: List[str] = [] + if clip_spec: - clip_ranges = self._parse_time_ranges(str(clip_spec)) + # Support keyed clip syntax: + # -clip "clip:3m4s-3m14s,1h22m-1h33m,item:2-3" + keyed = self._parse_keyed_csv_spec(str(clip_spec), default_key="clip") + clip_values.extend(keyed.get("clip", []) or []) + item_values.extend(keyed.get("item", []) or []) + + # Allow the same keyed spec language inside -query so users can do: + # download-media -query "clip:1m-1m15s,2m1s-2m11s" + if query_keyed: + clip_values.extend(query_keyed.get("clip", []) or []) + item_values.extend(query_keyed.get("item", []) or []) + + if item_values and not parsed.get("item"): + parsed["item"] = ",".join([v for v in item_values if v]) + + if clip_values: + clip_ranges = self._parse_time_ranges(",".join([v for v in clip_values if v])) if not clip_ranges: - log(f"Invalid clip format: {clip_spec}", file=sys.stderr) + bad_spec = clip_spec or query_spec + log(f"Invalid clip format: {bad_spec}", file=sys.stderr) return 1 + if clip_ranges: + try: + debug(f"Clip ranges: {clip_ranges}") + except Exception: + pass + quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False storage = None @@ -1886,56 +2024,37 @@ class Download_Media(Cmdlet): formats = list_formats(url, no_playlist=False) if formats and len(formats) > 1: - # Filter formats: multiple videos (640x+, one per resolution tier) + 1 best audio - video_formats = [] - audio_formats = [] - - for fmt in formats: - width = fmt.get("width") or 0 - height = fmt.get("height") or 0 - vcodec = fmt.get("vcodec", "none") - acodec = fmt.get("acodec", "none") - - # Classify as video or audio - if vcodec != "none" and acodec == "none" and width >= 640: - video_formats.append(fmt) - elif acodec != "none" and vcodec == "none": - audio_formats.append(fmt) - - # Group videos by resolution and select best format per resolution - filtered_formats = [] - if video_formats: - # Group by height (resolution tier) - from collections import defaultdict - by_resolution = defaultdict(list) - for f in video_formats: - height = f.get("height") or 0 - by_resolution[height].append(f) - - # For each resolution, prefer AV1, then highest bitrate - for height in sorted(by_resolution.keys(), reverse=True): - candidates = by_resolution[height] - av1_formats = [f for f in candidates if "av01" in f.get("vcodec", "")] - if av1_formats: - best = max(av1_formats, key=lambda f: f.get("tbr") or 0) - else: - best = max(candidates, key=lambda f: f.get("tbr") or 0) - filtered_formats.append(best) - - # Select best audio: highest bitrate (any format) - if audio_formats: - best_audio = max(audio_formats, key=lambda f: f.get("tbr") or f.get("abr") or 0) - filtered_formats.append(best_audio) - - if not filtered_formats: - # Fallback to all formats if filtering resulted in nothing - filtered_formats = formats - - debug(f"Filtered to {len(filtered_formats)} formats from {len(formats)} total") - - # Show format selection table - log(f"Available formats for {url}:", file=sys.stderr) - log("", file=sys.stderr) + # Formatlist filtering + # + # Goal: + # - Keep the list useful (hide non-media entries like storyboards) + # - But NEVER filter down so far that the user can't browse/pick formats. + # + # The old filtering was too aggressive (e.g. width>=640, one per resolution), + # which often hid most YouTube formats. + def _is_browseable_format(fmt: Any) -> bool: + if not isinstance(fmt, dict): + return False + format_id = str(fmt.get("format_id") or "").strip() + if not format_id: + return False + ext = str(fmt.get("ext") or "").strip().lower() + if ext in {"mhtml", "json"}: + return False + note = str(fmt.get("format_note") or "").lower() + if "storyboard" in note: + return False + if format_id.lower().startswith("sb"): + return False + vcodec = str(fmt.get("vcodec", "none")) + acodec = str(fmt.get("acodec", "none")) + # Keep anything with at least one stream. + return not (vcodec == "none" and acodec == "none") + + candidate_formats = [f for f in formats if _is_browseable_format(f)] + filtered_formats = candidate_formats if candidate_formats else list(formats) + + debug(f"Formatlist: showing {len(filtered_formats)} formats (raw={len(formats)})") # Build the base command that will be replayed with @N selection # Include any additional args from the original command @@ -1946,8 +2065,10 @@ class Download_Media(Cmdlet): base_cmd += ' ' + ' '.join(remaining_args) # Create result table for display - table = ResultTable() - table.title = f"Available formats for {url}" + # NOTE: ResultTable defaults to max_columns=5; for formatlist we want more columns + # (including Size) so the user can compare formats. + table = ResultTable(title=f"Available formats for {url}", max_columns=10, preserve_order=True) + table.set_table("ytdlp.formatlist") table.set_source_command("download-media", [url]) # Collect results for table @@ -1960,6 +2081,7 @@ class Download_Media(Cmdlet): vcodec = fmt.get("vcodec", "none") acodec = fmt.get("acodec", "none") filesize = fmt.get("filesize") + filesize_approx = fmt.get("filesize_approx") format_id = fmt.get("format_id", "") # If the chosen format is video-only (no audio stream), automatically @@ -1971,11 +2093,20 @@ class Download_Media(Cmdlet): except Exception: selection_format_id = format_id - # Format size + # Format size (prefer exact filesize; fall back to filesize_approx) size_str = "" - if filesize: - size_mb = filesize / (1024 * 1024) - size_str = f"{size_mb:.1f}MB" + size_prefix = "" + size_bytes = filesize + if not size_bytes: + size_bytes = filesize_approx + if size_bytes: + size_prefix = "~" + try: + if isinstance(size_bytes, (int, float)) and size_bytes > 0: + size_mb = float(size_bytes) / (1024 * 1024) + size_str = f"{size_prefix}{size_mb:.1f}MB" + except Exception: + size_str = "" # Build format description desc_parts = [] @@ -2002,42 +2133,67 @@ class Download_Media(Cmdlet): "annotations": [ext, resolution] if resolution else [ext], "media_kind": "format", "cmd": base_cmd, + # Put Size early so it's visible even with smaller column caps. "columns": [ - ("#", str(idx)), ("ID", format_id), ("Resolution", resolution or "N/A"), ("Ext", ext), + ("Size", size_str or ""), ("Video", vcodec), ("Audio", acodec), - ("Size", size_str or "N/A"), ], "full_metadata": { "format_id": format_id, "url": url, "item_selector": selection_format_id, }, - "_selection_args": ["-format", selection_format_id] + "_selection_args": None, } + + # Preserve clip settings across @N selection. + # Some runners only append row selection args; make sure clip intent + # survives even when it was provided via -query "clip:...". + selection_args: List[str] = ["-format", selection_format_id] + try: + if (not clip_spec) and clip_values: + selection_args.extend(["-clip", ",".join([v for v in clip_values if v])]) + except Exception: + pass + format_dict["_selection_args"] = selection_args # Add to results list and table (don't emit - formats should wait for @N selection) results_list.append(format_dict) table.add_result(format_dict) # Render and display the table - # Table is displayed by pipeline runner via set_current_stage_table + # Some runners (e.g. cmdnat) do not automatically render stage tables. + # Since this branch is explicitly interactive (user must pick @N), always + # print the table here and mark it as already rendered to avoid duplicates + # in runners that also print tables (e.g. CLI.py). + try: + sys.stderr.write(table.format_plain() + "\n") + setattr(table, "_rendered_by_cmdlet", True) + except Exception: + pass # Set the result table so it displays and is available for @N selection pipeline_context.set_current_stage_table(table) pipeline_context.set_last_result_table(table, results_list) log(f"", file=sys.stderr) - log(f"Use: @N | download-media to select and download format", file=sys.stderr) + log(f"Use: @N to select and download format", file=sys.stderr) return 0 # Download each URL downloaded_count = 0 clip_sections_spec = self._build_clip_sections_spec(clip_ranges) + if clip_sections_spec: + try: + debug(f"Clip sections spec: {clip_sections_spec}") + except Exception: + pass + for url in supported_url: try: debug(f"Processing: {url}") @@ -2136,6 +2292,13 @@ class Download_Media(Cmdlet): p_path = Path(p) except Exception: continue + # Sidecars (subtitles) should never be piped as standalone items. + # They are handled separately and attached to notes. + try: + if p_path.suffix.lower() in _SUBTITLE_EXTS: + continue + except Exception: + pass if not p_path.exists() or p_path.is_dir(): continue try: @@ -2189,6 +2352,12 @@ class Download_Media(Cmdlet): notes = {} notes["sub"] = sub_text po["notes"] = notes + # We keep subtitles as notes; do not leave a sidecar that later stages + # might try to ingest as a file. + try: + sub_path.unlink() + except Exception: + pass pipe_objects.append(po) @@ -2196,7 +2365,7 @@ class Download_Media(Cmdlet): # Relationship tags are only added when multiple clips exist. try: if clip_ranges and len(pipe_objects) == len(clip_ranges): - source_hash = self._find_existing_hash_for_url(storage, canonical_url, hydrus_available=hydrus_available) + source_hash = query_hash_override or self._find_existing_hash_for_url(storage, canonical_url, hydrus_available=hydrus_available) self._apply_clip_decorations(pipe_objects, clip_ranges, source_king_hash=source_hash) except Exception: pass @@ -2234,8 +2403,8 @@ class Download_Media(Cmdlet): if formats: formats_to_show = formats - table = ResultTable() - table.title = f"Available formats for {url}" + table = ResultTable(title=f"Available formats for {url}", max_columns=10, preserve_order=True) + table.set_table("ytdlp.formatlist") table.set_source_command("download-media", [str(a) for a in (args or [])]) results_list: List[Dict[str, Any]] = [] @@ -2245,6 +2414,7 @@ class Download_Media(Cmdlet): vcodec = fmt.get("vcodec", "none") acodec = fmt.get("acodec", "none") filesize = fmt.get("filesize") + filesize_approx = fmt.get("filesize_approx") format_id = fmt.get("format_id", "") selection_format_id = format_id @@ -2255,12 +2425,18 @@ class Download_Media(Cmdlet): selection_format_id = format_id size_str = "" - if filesize: - try: - size_mb = float(filesize) / (1024 * 1024) - size_str = f"{size_mb:.1f}MB" - except Exception: - size_str = "" + size_prefix = "" + size_bytes = filesize + if not size_bytes: + size_bytes = filesize_approx + if size_bytes: + size_prefix = "~" + try: + if isinstance(size_bytes, (int, float)) and size_bytes > 0: + size_mb = float(size_bytes) / (1024 * 1024) + size_str = f"{size_prefix}{size_mb:.1f}MB" + except Exception: + size_str = "" desc_parts: List[str] = [] if resolution and resolution != "audio only": @@ -2283,13 +2459,12 @@ class Download_Media(Cmdlet): "detail": format_desc, "media_kind": "format", "columns": [ - ("#", str(idx)), ("ID", format_id), ("Resolution", resolution or "N/A"), ("Ext", ext), + ("Size", size_str or ""), ("Video", vcodec), ("Audio", acodec), - ("Size", size_str or "N/A"), ], "full_metadata": { "format_id": format_id, @@ -2305,6 +2480,13 @@ class Download_Media(Cmdlet): pipeline_context.set_current_stage_table(table) pipeline_context.set_last_result_table(table, results_list) + # See comment in the main formatlist path: always print for interactive selection. + try: + sys.stderr.write(table.format_plain() + "\n") + setattr(table, "_rendered_by_cmdlet", True) + except Exception: + pass + # Returning 0 with no emits lets the CLI pause the pipeline for @N selection. log("Requested format is not available; select a working format with @N", file=sys.stderr) return 0 @@ -2387,6 +2569,25 @@ class Download_Media(Cmdlet): if not ts: return None + # Support compact units like 3m4s, 1h22m, 1h2m3s + # (case-insensitive; seconds may be fractional but are truncated to int) + try: + unit_match = re.fullmatch( + r"(?i)\s*(?:(?P\d+)h)?\s*(?:(?P\d+)m)?\s*(?:(?P\d+(?:\.\d+)?)s)?\s*", + ts, + ) + except Exception: + unit_match = None + if unit_match and unit_match.group(0).strip() and any(unit_match.group(g) for g in ("h", "m", "s")): + try: + hours = int(unit_match.group("h") or 0) + minutes = int(unit_match.group("m") or 0) + seconds = float(unit_match.group("s") or 0) + total = (hours * 3600) + (minutes * 60) + seconds + return int(total) + except Exception: + return None + if ":" in ts: parts = [p.strip() for p in ts.split(":")] if len(parts) == 2: @@ -2430,6 +2631,46 @@ class Download_Media(Cmdlet): return ranges + @staticmethod + def _parse_keyed_csv_spec(spec: str, *, default_key: str) -> Dict[str, List[str]]: + """Parse comma-separated values with optional sticky `key:` prefixes. + + Example: + clip:3m4s-3m14s,1h22m-1h33m,item:2-3 + + Rules: + - Items are split on commas. + - If an item begins with `key:` then key becomes active for subsequent items. + - If an item has no `key:` prefix, it belongs to the last active key. + - If no key has been set yet, values belong to default_key. + """ + out: Dict[str, List[str]] = {} + if not isinstance(spec, str): + spec = str(spec) + text = spec.strip() + if not text: + return out + + active = (default_key or "").strip().lower() or "clip" + key_pattern = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$") + + for raw_piece in text.split(","): + piece = raw_piece.strip() + if not piece: + continue + + m = key_pattern.match(piece) + if m: + active = (m.group(1) or "").strip().lower() or active + value = (m.group(2) or "").strip() + if value: + out.setdefault(active, []).append(value) + continue + + out.setdefault(active, []).append(piece) + + return out + def _build_clip_sections_spec( self, clip_ranges: Optional[List[tuple[int, int]]], diff --git a/cmdlet/get_file.py b/cmdlet/get_file.py index ddfcce2..8028901 100644 --- a/cmdlet/get_file.py +++ b/cmdlet/get_file.py @@ -25,14 +25,14 @@ class Get_File(sh.Cmdlet): summary="Export file to local path", usage="@1 | get-file -path C:\\Downloads", arg=[ - sh.SharedArgs.HASH, + sh.SharedArgs.QUERY, sh.SharedArgs.STORE, sh.SharedArgs.PATH, sh.CmdletArg("name", description="Output filename (default: from metadata title)"), ], detail=[ "- Exports file from storage backend to local path", - "- Uses hash+store to retrieve file", + "- Uses selected item's hash, or -query \"hash:\"", "- Preserves file extension and metadata", ], exec=self.run, @@ -44,9 +44,14 @@ class Get_File(sh.Cmdlet): debug(f"[get-file] run() called with result type: {type(result)}") parsed = sh.parse_cmdlet_args(args, self) debug(f"[get-file] parsed args: {parsed}") + + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("Error: -query must be of the form hash:") + return 1 # Extract hash and store from result or args - file_hash = parsed.get("hash") or sh.get_field(result, "hash") + file_hash = query_hash or sh.get_field(result, "hash") store_name = parsed.get("store") or sh.get_field(result, "store") output_path = parsed.get("path") output_name = parsed.get("name") @@ -54,7 +59,7 @@ class Get_File(sh.Cmdlet): debug(f"[get-file] file_hash={file_hash[:12] if file_hash else None}... store_name={store_name}") if not file_hash: - log("Error: No file hash provided") + log("Error: No file hash provided (pipe an item or use -query \"hash:\")") return 1 if not store_name: diff --git a/cmdlet/get_metadata.py b/cmdlet/get_metadata.py index 617e6cb..9ce5e08 100644 --- a/cmdlet/get_metadata.py +++ b/cmdlet/get_metadata.py @@ -26,16 +26,16 @@ class Get_Metadata(Cmdlet): super().__init__( name="get-metadata", summary="Print metadata for files by hash and storage backend.", - usage="get-metadata [-hash ] [-store ]", + usage="get-metadata [-query \"hash:\"] [-store ]", alias=["meta"], arg=[ - SharedArgs.HASH, + SharedArgs.QUERY, SharedArgs.STORE, ], detail=[ "- Retrieves metadata from storage backend using file hash as identifier.", "- Shows hash, MIME type, size, duration/pages, known url, and import timestamp.", - "- Hash and store are taken from piped result or can be overridden with -hash/-store flags.", + "- Hash and store are taken from piped result or can be overridden with -query/-store flags.", "- All metadata is retrieved from the storage backend's database (single source of truth).", ], exec=self.run, @@ -153,13 +153,18 @@ class Get_Metadata(Cmdlet): """Main execution entry point.""" # Parse arguments parsed = parse_cmdlet_args(args, self) + + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("No hash available - use -query \"hash:\"", file=sys.stderr) + return 1 # Get hash and store from parsed args or result - file_hash = parsed.get("hash") or get_field(result, "hash") + file_hash = query_hash or get_field(result, "hash") storage_source = parsed.get("store") or get_field(result, "store") if not file_hash: - log("No hash available - use -hash to specify", file=sys.stderr) + log("No hash available - use -query \"hash:\"", file=sys.stderr) return 1 if not storage_source: diff --git a/cmdlet/get_note.py b/cmdlet/get_note.py index 4d85164..2b1b223 100644 --- a/cmdlet/get_note.py +++ b/cmdlet/get_note.py @@ -25,11 +25,11 @@ class Get_Note(Cmdlet): super().__init__( name="get-note", summary="List notes on a file in a store.", - usage="get-note -store [-hash ]", + usage="get-note -store [-query \"hash:\"]", alias=["get-notes", "get_note"], arg=[ SharedArgs.STORE, - SharedArgs.HASH, + SharedArgs.QUERY, ], detail=[ "- Notes are retrieved via the selected store backend.", @@ -66,14 +66,17 @@ class Get_Note(Cmdlet): parsed = parse_cmdlet_args(args, self) store_override = parsed.get("store") - hash_override = parsed.get("hash") + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("[get_note] Error: -query must be of the form hash:", file=sys.stderr) + return 1 results = normalize_result_input(result) if not results: - if store_override and normalize_hash(hash_override): - results = [{"store": str(store_override), "hash": normalize_hash(hash_override)}] + if store_override and query_hash: + results = [{"store": str(store_override), "hash": query_hash}] else: - log("[get_note] Error: Requires piped item(s) or -store and -hash", file=sys.stderr) + log("[get_note] Error: Requires piped item(s) or -store and -query \"hash:\"", file=sys.stderr) return 1 store_registry = Store(config) @@ -94,7 +97,7 @@ class Get_Note(Cmdlet): resolved_hash = self._resolve_hash( raw_hash=str(raw_hash) if raw_hash else None, raw_path=str(raw_path) if raw_path else None, - override_hash=str(hash_override) if hash_override else None, + override_hash=str(query_hash) if query_hash else None, ) if not resolved_hash: continue diff --git a/cmdlet/get_relationship.py b/cmdlet/get_relationship.py index ec097c8..f1e3676 100644 --- a/cmdlet/get_relationship.py +++ b/cmdlet/get_relationship.py @@ -29,12 +29,12 @@ from Store import Store CMDLET = Cmdlet( name="get-relationship", summary="Print relationships for the selected file (Hydrus or Local).", - usage="get-relationship [-hash ]", + usage="get-relationship [-query \"hash:\"]", alias=[ "get-rel", ], arg=[ - SharedArgs.HASH, + SharedArgs.QUERY, SharedArgs.STORE, ], detail=[ @@ -48,20 +48,28 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}") return 0 - # Parse -hash and -store override - override_hash: str | None = None + # Parse -query and -store override + override_query: str | None = None override_store: str | None = None args_list = list(_args) i = 0 while i < len(args_list): a = args_list[i] low = str(a).lower() - if low in {"-hash", "--hash", "hash"} and i + 1 < len(args_list): - override_hash = str(args_list[i + 1]).strip() - break + if low in {"-query", "--query", "query"} and i + 1 < len(args_list): + override_query = str(args_list[i + 1]).strip() + i += 2 + continue if low in {"-store", "--store", "store"} and i + 1 < len(args_list): override_store = str(args_list[i + 1]).strip() + i += 2 + continue i += 1 + + override_hash: str | None = sh.parse_single_hash_query(override_query) if override_query else None + if override_query and not override_hash: + log("get-relationship requires -query \"hash:\"", file=sys.stderr) + return 1 # Handle @N selection which creates a list # This cmdlet is single-subject; require disambiguation when multiple items are provided. @@ -69,7 +77,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: if len(result) == 0: result = None elif len(result) > 1 and not override_hash: - log("get-relationship expects a single item; select one row (e.g. @1) or pass -hash", file=sys.stderr) + log("get-relationship expects a single item; select one row (e.g. @1) or pass -query \"hash:\"", file=sys.stderr) return 1 else: result = result[0] @@ -439,8 +447,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: pipeline_results.append(res_obj) # Set selection args - # If it has a path, we can use it directly. If hash, maybe get-file -hash? - table.set_row_selection_args(i, ["-store", str(item['store']), "-hash", item['hash']]) + table.set_row_selection_args(i, ["-store", str(item['store']), "-query", f"hash:{item['hash']}"]) ctx.set_last_result_table(table, pipeline_results) print(table) diff --git a/cmdlet/get_tag.py b/cmdlet/get_tag.py index 73f9910..9b77758 100644 --- a/cmdlet/get_tag.py +++ b/cmdlet/get_tag.py @@ -801,11 +801,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Get tags from Hydrus, local sidecar, or URL metadata. Usage: - get-tag [-hash ] [--store ] [--emit] + get-tag [-query "hash:"] [--store ] [--emit] get-tag -scrape Options: - -hash : Override hash to use instead of result's hash + -query "hash:": Override hash to use instead of result's hash --store : Store result to this key for pipeline --emit: Emit result without interactive prompt (quiet mode) -scrape : Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks) @@ -843,22 +843,16 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: scrape_flag_present = any(str(arg).lower() in {"-scrape", "--scrape"} for arg in args_list) # Extract values - hash_override_raw = parsed_args.get("hash") - hash_override = normalize_hash(hash_override_raw) + query_raw = parsed_args.get("query") + hash_override = sh.parse_single_hash_query(query_raw) + if query_raw and not hash_override: + log("Invalid -query value (expected hash:)", file=sys.stderr) + return 1 store_key = parsed_args.get("store") emit_requested = parsed_args.get("emit", False) scrape_url = parsed_args.get("scrape") scrape_requested = scrape_flag_present or scrape_url is not None - explicit_hash_flag = any(str(arg).lower() in {"-hash", "--hash"} for arg in raw_args) - if hash_override_raw is not None: - if not hash_override or not looks_like_hash(hash_override): - debug(f"[get_tag] Ignoring invalid hash override '{hash_override_raw}' (explicit_flag={explicit_hash_flag})") - if explicit_hash_flag: - log("Invalid hash format: expected 64 hex characters", file=sys.stderr) - return 1 - hash_override = None - if scrape_requested and (not scrape_url or str(scrape_url).strip() == ""): log("-scrape requires a URL or provider name", file=sys.stderr) return 1 @@ -1182,10 +1176,10 @@ class Get_Tag(Cmdlet): super().__init__( name="get-tag", summary="Get tag values from Hydrus or local sidecar metadata", - usage="get-tag [-hash ] [--store ] [--emit] [-scrape ]", + usage="get-tag [-query \"hash:\"] [--store ] [--emit] [-scrape ]", alias=[], arg=[ - SharedArgs.HASH, + SharedArgs.QUERY, CmdletArg( name="-store", type="string", @@ -1211,7 +1205,7 @@ class Get_Tag(Cmdlet): " Hydrus: Using file hash if available", " Local: From sidecar files or local library database", "- Options:", - " -hash: Override hash to look up in Hydrus", + " -query: Override hash to look up in Hydrus (use: -query \"hash:\")", " -store: Store result to key for downstream pipeline", " -emit: Quiet mode (no interactive selection)", " -scrape: Scrape metadata from URL or metadata provider", diff --git a/cmdlet/get_url.py b/cmdlet/get_url.py index c3fd857..3558efe 100644 --- a/cmdlet/get_url.py +++ b/cmdlet/get_url.py @@ -34,7 +34,7 @@ class Get_Url(Cmdlet): summary="List url associated with a file", usage="@1 | get-url", arg=[ - SharedArgs.HASH, + SharedArgs.QUERY, SharedArgs.STORE, ], detail=[ @@ -47,13 +47,18 @@ class Get_Url(Cmdlet): def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Get url for file via hash+store backend.""" parsed = parse_cmdlet_args(args, self) + + query_hash = sh.parse_single_hash_query(parsed.get("query")) + if parsed.get("query") and not query_hash: + log("Error: -query must be of the form hash:") + return 1 # Extract hash and store from result or args - file_hash = parsed.get("hash") or get_field(result, "hash") + file_hash = query_hash or get_field(result, "hash") store_name = parsed.get("store") or get_field(result, "store") if not file_hash: - log("Error: No file hash provided") + log("Error: No file hash provided (pipe an item or use -query \"hash:\")") return 1 if not store_name: diff --git a/cmdlet/search_store.py b/cmdlet/search_store.py index 66c536e..ce406f4 100644 --- a/cmdlet/search_store.py +++ b/cmdlet/search_store.py @@ -12,7 +12,7 @@ from SYS.logger import log, debug from . import _shared as sh -Cmdlet, CmdletArg, SharedArgs, get_field, should_show_help, normalize_hash, first_title_tag = ( +Cmdlet, CmdletArg, SharedArgs, get_field, should_show_help, normalize_hash, first_title_tag, parse_hash_query = ( sh.Cmdlet, sh.CmdletArg, sh.SharedArgs, @@ -20,6 +20,7 @@ Cmdlet, CmdletArg, SharedArgs, get_field, should_show_help, normalize_hash, firs sh.should_show_help, sh.normalize_hash, sh.first_title_tag, + sh.parse_hash_query, ) import pipeline as ctx @@ -34,7 +35,7 @@ class Search_Store(Cmdlet): super().__init__( name="search-store", summary="Search storage backends (Folder, Hydrus) for files.", - usage="search-store [query] [-store BACKEND] [-limit N]", + usage="search-store [-query ] [-store BACKEND] [-limit N]", arg=[ CmdletArg("query", description="Search query string"), CmdletArg("limit", type="integer", description="Limit results (default: 100)"), @@ -46,51 +47,18 @@ class Search_Store(Cmdlet): "URL search: url:* (any URL) or url: (URL substring)", "Results include hash for downstream commands (get-file, add-tag, etc.)", "Examples:", - "search-store foo # Search all storage backends", - "search-store -store home '*' # Search 'home' Hydrus instance", - "search-store -store test 'video' # Search 'test' folder store", - "search-store 'url:*' # Files that have any URL", - "search-store 'url:youtube.com' # Files whose URL contains substring", + "search-store -query foo # Search all storage backends", + "search-store -store home -query '*' # Search 'home' Hydrus instance", + "search-store -store test -query 'video' # Search 'test' folder store", + "search-store -query 'hash:deadbeef...' # Search by SHA256 hash", + "search-store -query 'url:*' # Files that have any URL", + "search-store -query 'url:youtube.com' # Files whose URL contains substring", ], exec=self.run, ) self.register() # --- Helper methods ------------------------------------------------- - @staticmethod - def _parse_hash_query(query: str) -> List[str]: - """Parse a `hash:` query into a list of normalized 64-hex SHA256 hashes. - - Supported examples: - - hash:

,

,

- - Hash:

- - hash:{

,

} - """ - q = str(query or "").strip() - if not q: - return [] - - m = re.match(r"^hash(?:es)?\s*:\s*(.+)$", q, flags=re.IGNORECASE) - if not m: - return [] - - rest = (m.group(1) or "").strip() - if rest.startswith("{") and rest.endswith("}"): - rest = rest[1:-1].strip() - if rest.startswith("[") and rest.endswith("]"): - rest = rest[1:-1].strip() - - # Split on commas and whitespace. - raw_parts = [p.strip() for p in re.split(r"[\s,]+", rest) if p.strip()] - out: List[str] = [] - for part in raw_parts: - h = normalize_hash(part) - if not h: - continue - if h not in out: - out.append(h) - return out - @staticmethod def _normalize_extension(ext_value: Any) -> str: """Sanitize extension strings to alphanumerics and cap at 5 chars.""" @@ -142,6 +110,7 @@ class Search_Store(Cmdlet): # Build dynamic flag variants from cmdlet arg definitions. # This avoids hardcoding flag spellings in parsing loops. flag_registry = self.build_flag_registry() + query_flags = {f.lower() for f in (flag_registry.get("query") or {"-query", "--query"})} store_flags = {f.lower() for f in (flag_registry.get("store") or {"-store", "--store"})} limit_flags = {f.lower() for f in (flag_registry.get("limit") or {"-limit", "--limit"})} @@ -155,6 +124,11 @@ class Search_Store(Cmdlet): while i < len(args_list): arg = args_list[i] low = arg.lower() + if low in query_flags and i + 1 < len(args_list): + chunk = args_list[i + 1] + query = f"{query} {chunk}".strip() if query else chunk + i += 2 + continue if low in store_flags and i + 1 < len(args_list): storage_backend = args_list[i + 1] i += 2 @@ -182,7 +156,7 @@ class Search_Store(Cmdlet): if store_filter and not storage_backend: storage_backend = store_filter - hash_query = self._parse_hash_query(query) + hash_query = parse_hash_query(query) if not query: log("Provide a search query", file=sys.stderr) diff --git a/medeia_entry.py b/medeia_entry.py index 7e9df76..ec5ea80 100644 --- a/medeia_entry.py +++ b/medeia_entry.py @@ -7,7 +7,7 @@ root_dir = Path(__file__).parent if str(root_dir) not in sys.path: sys.path.insert(0, str(root_dir)) -from CLI import main +from CLI import MedeiaCLI if __name__ == "__main__": - main() + MedeiaCLI().run() diff --git a/result_table.py b/result_table.py index e68f9a7..0e64f63 100644 --- a/result_table.py +++ b/result_table.py @@ -9,22 +9,38 @@ Features: - Interactive selection with user input - Input options for cmdlet arguments (location, source selection, etc) """ +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Union, Callable, Tuple +from typing import Any, Dict, List, Optional, Callable from pathlib import Path import json import shutil # Optional Textual imports - graceful fallback if not available try: - from textual.widgets import Tree, DataTable - from textual.containers import Horizontal, Vertical - from textual.widgets import Static, Button + from textual.widgets import Tree TEXTUAL_AVAILABLE = True except ImportError: TEXTUAL_AVAILABLE = False +def _sanitize_cell_text(value: Any) -> str: + """Coerce to a single-line, tab-free string suitable for ASCII tables.""" + if value is None: + return "" + text = str(value) + if not text: + return "" + return ( + text + .replace("\r\n", " ") + .replace("\n", " ") + .replace("\r", " ") + .replace("\t", " ") + ) + + @dataclass class InputOption: """Represents an interactive input option (cmdlet argument) in a table. @@ -120,18 +136,7 @@ class ResultRow: def add_column(self, name: str, value: Any) -> None: """Add a column to this row.""" - str_value = str(value) if value is not None else "" - - # Tables are single-line per row: normalize hard line breaks inside cells - # so values (e.g., long descriptions) don't break the ASCII box shape. - if str_value: - str_value = ( - str_value - .replace("\r\n", " ") - .replace("\n", " ") - .replace("\r", " ") - .replace("\t", " ") - ) + str_value = _sanitize_cell_text(value) # Normalize extension columns globally and cap to 5 characters if str(name).strip().lower() == "ext": @@ -180,7 +185,7 @@ class ResultTable: >>> print(result_table) """ - def __init__(self, title: str = "", title_width: int = 80, max_columns: int = None, preserve_order: bool = False): + def __init__(self, title: str = "", title_width: int = 80, max_columns: Optional[int] = None, preserve_order: bool = False): """Initialize a result table. Args: @@ -290,6 +295,8 @@ class ResultTable: new_table.source_args = list(self.source_args) if self.source_args else [] new_table.input_options = dict(self.input_options) if self.input_options else {} new_table.no_choice = self.no_choice + new_table.table = self.table + new_table.header_lines = list(self.header_lines) if self.header_lines else [] return new_table def set_row_selection_args(self, row_index: int, selection_args: List[str]) -> None: @@ -339,8 +346,8 @@ class ResultTable: Looks for columns named 'Title', 'Name', or 'Tag' (in that order). Case-insensitive sort. Returns self for chaining. - IMPORTANT: Updates source_index to match new sorted positions so that - @N selections continue to work correctly after sorting. + NOTE: This only affects display order. Each row keeps its original + `source_index` (insertion order) for callers that need stable mapping. """ if getattr(self, "preserve_order", False): return self @@ -508,7 +515,7 @@ class ResultTable: Shows the Tag column with the tag name and Source column to identify which storage backend the tag values come from (Hydrus, local, etc.). All data preserved in TagItem for piping and operations. - Use @1 to select a tag, @{1,3,5} to select multiple. + Tag row selection is handled by the CLI pipeline (e.g. `@N | ...`). """ # Tag name (truncate if too long) if hasattr(item, 'tag_name') and item.tag_name: @@ -566,21 +573,18 @@ class ResultTable: instead of treating it as a regular field. This allows dynamic column definitions from search providers. - Priority field groups (uses first match within each group): + Priority field groups (first match per group): - title | name | filename + - ext + - size | size_bytes - store | table | source - - type | media_kind | kind - - target | path | url - - hash | hash_hex | file_hash - - tag | tag_summary - - detail | description """ # Helper to determine if a field should be hidden from display def is_hidden_field(field_name: Any) -> bool: # Hide internal/metadata fields hidden_fields = { '__', 'id', 'action', 'parent_id', 'is_temp', 'path', 'extra', - 'target', 'hash', 'hash_hex', 'file_hash', 'tag', 'tag_summary', 'name' + 'target', 'hash', 'hash_hex', 'file_hash', 'tag', 'tag_summary' } if isinstance(field_name, str): if field_name.startswith('__'): @@ -665,7 +669,7 @@ class ResultTable: if column_count == 0: # Explicitly set which columns to display in order priority_groups = [ - ('title', ['title']), + ('title', ['title', 'name', 'filename']), ('ext', ['ext']), ('size', ['size', 'size_bytes']), ('store', ['store', 'table', 'source']), @@ -691,6 +695,8 @@ class ResultTable: col_name = "Store" elif field in ['size', 'size_bytes']: col_name = "Size (Mb)" + elif field in ['title', 'name', 'filename']: + col_name = "Title" else: col_name = field.replace('_', ' ').title() @@ -794,25 +800,13 @@ class ResultTable: # Title block if self.title: lines.append("|" + "=" * (table_width - 2) + "|") - safe_title = ( - str(self.title) - .replace("\r\n", " ") - .replace("\n", " ") - .replace("\r", " ") - .replace("\t", " ") - ) + safe_title = _sanitize_cell_text(self.title) lines.append(wrap(safe_title.ljust(table_width - 2))) lines.append("|" + "=" * (table_width - 2) + "|") # Optional header metadata lines for meta in self.header_lines: - safe_meta = ( - str(meta) - .replace("\r\n", " ") - .replace("\n", " ") - .replace("\r", " ") - .replace("\t", " ") - ) + safe_meta = _sanitize_cell_text(meta) lines.append(wrap(safe_meta)) # Add header with # column @@ -832,14 +826,7 @@ class ResultTable: for col_name in column_names: width = capped_width(col_name) col_value = row.get_column(col_name) or "" - if col_value: - col_value = ( - col_value - .replace("\r\n", " ") - .replace("\n", " ") - .replace("\r", " ") - .replace("\t", " ") - ) + col_value = _sanitize_cell_text(col_value) if len(col_value) > width: col_value = col_value[: width - 3] + "..." row_parts.append(col_value.ljust(width)) @@ -1190,7 +1177,7 @@ class ResultTable: Dictionary mapping option names to selected values """ result = {} - for name, option in self.input_options.items(): + for name, _option in self.input_options.items(): value = self.select_option(name) if value is not None: result[name] = value @@ -1310,7 +1297,7 @@ class ResultTable: if not TEXTUAL_AVAILABLE: raise ImportError("Textual not available for tree building") - tree_widget.reset() + tree_widget.reset(self.title or "Results") root = tree_widget.root # Add each row as a top-level node @@ -1325,43 +1312,6 @@ class ResultTable: row_node.add_leaf(f"[cyan]{col.name}[/cyan]: {value_str}") -def _format_duration(duration: Any) -> str: - """Format duration value as human-readable string. - - Args: - duration: Duration in seconds, milliseconds, or already formatted string - - Returns: - Formatted duration string (e.g., "2h 18m 5s", "5m 30s") - """ - if isinstance(duration, str): - return duration if duration else "" - - try: - # Convert to seconds if needed - if isinstance(duration, (int, float)): - seconds = int(duration) - if seconds < 1000: # Likely already in seconds - pass - else: # Likely in milliseconds - seconds = seconds // 1000 - else: - return "" - - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - secs = seconds % 60 - - if hours > 0: - return f"{hours}h {minutes}m {secs}s" - elif minutes > 0: - return f"{minutes}m {secs}s" - else: - return f"{secs}s" - except (ValueError, TypeError): - return "" - - def _format_size(size: Any, integer_only: bool = False) -> str: """Format file size as human-readable string.