d
This commit is contained in:
537
CLI.py
537
CLI.py
@@ -207,16 +207,70 @@ def _load_cli_config() -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_table_title_for_command(cmd_name: str, emitted_items: Optional[List[Any]] = None) -> str:
|
||||
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 <provider> <query>
|
||||
# and flag form:
|
||||
# search-provider -provider <provider> <query>
|
||||
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 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',
|
||||
@@ -518,6 +572,24 @@ def _get_arg_choices(cmd_name: str, arg_name: str) -> List[str]:
|
||||
# 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]
|
||||
@@ -570,6 +642,7 @@ if (
|
||||
"""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
|
||||
@@ -586,6 +659,31 @@ if (
|
||||
# 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))
|
||||
@@ -596,8 +694,12 @@ if (
|
||||
|
||||
# Otherwise treat first token of stage as command and complete its args
|
||||
cmd_name = stage_tokens[0].replace("_", "-").lower()
|
||||
current_token = stage_tokens[-1].lower()
|
||||
prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else ""
|
||||
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:
|
||||
@@ -611,22 +713,29 @@ if (
|
||||
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 current_token == "-" and arg_low.startswith("--"):
|
||||
if prefer_single_dash and arg_low.startswith("--"):
|
||||
continue
|
||||
|
||||
logical = arg.lstrip("-").lower()
|
||||
if current_token == "-" and logical in logical_seen:
|
||||
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 current_token == "-":
|
||||
if prefer_single_dash:
|
||||
logical_seen.add(logical)
|
||||
|
||||
if "--help".startswith(current_token):
|
||||
yield CompletionType("--help", start_position=-len(current_token))
|
||||
# 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):
|
||||
@@ -681,6 +790,45 @@ def _create_cmdlet_cli():
|
||||
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."""
|
||||
try:
|
||||
import click
|
||||
from click.shell_completion import CompletionItem
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
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())
|
||||
except Exception:
|
||||
choices = []
|
||||
|
||||
inc = (incomplete or "").lower()
|
||||
out = []
|
||||
for name in choices:
|
||||
if not name:
|
||||
continue
|
||||
if name.lower().startswith(inc):
|
||||
out.append(CompletionItem(name))
|
||||
return out
|
||||
|
||||
@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(50, "--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(
|
||||
@@ -804,6 +952,18 @@ def _create_cmdlet_cli():
|
||||
block = provider_cfg.get(str(name).strip().lower())
|
||||
return isinstance(block, dict) and bool(block)
|
||||
|
||||
def _ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]:
|
||||
try:
|
||||
from API.HTTP import HTTPClient
|
||||
|
||||
with HTTPClient(timeout=timeout, retries=1) as client:
|
||||
resp = client.get(url, allow_redirects=True)
|
||||
code = int(getattr(resp, "status_code", 0) or 0)
|
||||
ok = 200 <= code < 500
|
||||
return ok, f"{url} (HTTP {code})"
|
||||
except Exception as exc:
|
||||
return False, f"{url} ({type(exc).__name__})"
|
||||
|
||||
# Load config and initialize debug logging
|
||||
config = {}
|
||||
try:
|
||||
@@ -894,6 +1054,169 @@ def _create_cmdlet_cli():
|
||||
detail = (url_val + (" - " if url_val else "")) + (err or "Unavailable")
|
||||
_add_startup_check(status, name_key, "hydrusnetwork", 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_search_providers,
|
||||
list_file_providers,
|
||||
)
|
||||
except Exception:
|
||||
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 = {}
|
||||
|
||||
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":
|
||||
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]
|
||||
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, prov, "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, prov, base_url or "Connected")
|
||||
except Exception as exc:
|
||||
_add_startup_check("DISABLED", display, prov, str(exc))
|
||||
continue
|
||||
|
||||
is_known = False
|
||||
ok = None
|
||||
|
||||
if 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, prov, "Not registered")
|
||||
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, prov, detail)
|
||||
|
||||
already_checked.add(prov)
|
||||
|
||||
# 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, prov, detail)
|
||||
already_checked.add(prov)
|
||||
|
||||
# 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", "0x0", detail)
|
||||
already_checked.add("0x0")
|
||||
|
||||
if _has_provider(config, "matrix"):
|
||||
# Matrix availability is validated by Provider.matrix.Matrix.__init__.
|
||||
try:
|
||||
@@ -1397,9 +1720,9 @@ def _execute_pipeline(tokens: list):
|
||||
if table_for_stage:
|
||||
ctx.set_current_stage_table(table_for_stage)
|
||||
|
||||
# Special check for table-specific behavior BEFORE command expansion
|
||||
# If we are selecting from a YouTube or Soulseek search, we want to force auto-piping to .pipe
|
||||
# instead of trying to expand to a command (which search-file doesn't support well for re-execution)
|
||||
# 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()
|
||||
|
||||
@@ -1409,7 +1732,7 @@ def _execute_pipeline(tokens: list):
|
||||
|
||||
# Logic based on table type
|
||||
if table_type == 'youtube' or table_type == 'soulseek':
|
||||
# Force fallback to item-based selection so we can auto-pipe
|
||||
# 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:
|
||||
@@ -1493,18 +1816,21 @@ def _execute_pipeline(tokens: list):
|
||||
|
||||
if not stages:
|
||||
if table_type == 'youtube':
|
||||
print(f"Auto-piping YouTube selection to .pipe")
|
||||
stages.append(['.pipe'])
|
||||
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-piping YouTube selection to .pipe")
|
||||
stages.append(['.pipe'])
|
||||
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".
|
||||
@@ -1513,6 +1839,12 @@ def _execute_pipeline(tokens: list):
|
||||
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")
|
||||
@@ -1567,6 +1899,15 @@ def _execute_pipeline(tokens: list):
|
||||
# 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
|
||||
@@ -1574,11 +1915,11 @@ def _execute_pipeline(tokens: list):
|
||||
# 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:
|
||||
# Special case for youtube search results: @N expands to .pipe
|
||||
# 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-piping YouTube selection to .pipe")
|
||||
stages.append(['.pipe'])
|
||||
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
|
||||
|
||||
@@ -1671,6 +2012,26 @@ def _execute_pipeline(tokens: list):
|
||||
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):
|
||||
@@ -1699,6 +2060,11 @@ def _execute_pipeline(tokens: list):
|
||||
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")
|
||||
@@ -1719,6 +2085,13 @@ def _execute_pipeline(tokens: list):
|
||||
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}")
|
||||
|
||||
@@ -1758,7 +2131,7 @@ def _execute_pipeline(tokens: list):
|
||||
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)
|
||||
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
|
||||
@@ -1776,7 +2149,8 @@ def _execute_pipeline(tokens: list):
|
||||
self_managing_commands = {
|
||||
'get-tag', 'get_tag', 'tags',
|
||||
'get-url', 'get_url',
|
||||
'search-file', 'search_file'
|
||||
'search-file', 'search_file',
|
||||
'search-provider', 'search_provider'
|
||||
}
|
||||
|
||||
overlay_table = ctx.get_display_table() if hasattr(ctx, 'get_display_table') else None
|
||||
@@ -1858,6 +2232,24 @@ def _execute_pipeline(tokens: list):
|
||||
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
|
||||
@@ -2016,39 +2408,69 @@ def _execute_cmdlet(cmd_name: str, args: list):
|
||||
# Load config relative to CLI root
|
||||
config = _load_cli_config()
|
||||
|
||||
# Check for @ selection syntax in arguments
|
||||
# Extract @N, @N-M, @{N,M,P} syntax and remove from args
|
||||
filtered_args = []
|
||||
selected_indices = []
|
||||
|
||||
for arg in args:
|
||||
if arg.startswith('@'):
|
||||
# Parse selection: @2, @2-5, @{1,3,5}
|
||||
selection_str = arg[1:] # Remove @
|
||||
try:
|
||||
if '{' in selection_str and '}' in selection_str:
|
||||
# @{1,3,5} format
|
||||
selection_str = selection_str.strip('{}')
|
||||
selected_indices = [int(x.strip()) - 1 for x in selection_str.split(',')]
|
||||
elif '-' in selection_str:
|
||||
# @2-5 format
|
||||
parts = selection_str.split('-')
|
||||
start = int(parts[0]) - 1
|
||||
end = int(parts[1])
|
||||
selected_indices = list(range(start, end))
|
||||
else:
|
||||
# @2 format
|
||||
selected_indices = [int(selection_str) - 1]
|
||||
except (ValueError, IndexError):
|
||||
# Invalid format, treat as regular arg
|
||||
# Special case: @"string" should be treated as "string" (stripping @)
|
||||
# This allows adding new items via @"New Item" syntax
|
||||
if selection_str.startswith('"') or selection_str.startswith("'"):
|
||||
filtered_args.append(selection_str.strip('"\''))
|
||||
else:
|
||||
filtered_args.append(arg)
|
||||
else:
|
||||
# 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()
|
||||
@@ -2056,7 +2478,9 @@ def _execute_cmdlet(cmd_name: str, args: list):
|
||||
# Create result object - pass full list (or filtered list if @ selection used) to cmdlet
|
||||
result = None
|
||||
if piped_items:
|
||||
if selected_indices:
|
||||
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:
|
||||
@@ -2101,7 +2525,7 @@ def _execute_cmdlet(cmd_name: str, args: list):
|
||||
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)
|
||||
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
|
||||
@@ -2118,7 +2542,8 @@ def _execute_cmdlet(cmd_name: str, args: list):
|
||||
# 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-file', 'search_file',
|
||||
'search-provider', 'search_provider'
|
||||
}
|
||||
|
||||
if cmd_name in self_managing_commands:
|
||||
|
||||
Reference in New Issue
Block a user