hj
This commit is contained in:
462
CLI.py
462
CLI.py
@@ -18,7 +18,7 @@ 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 typing import Any, Callable, Dict, List, Optional, Sequence, Set, TextIO, Tuple, cast
|
||||
|
||||
import typer
|
||||
from prompt_toolkit import PromptSession
|
||||
@@ -135,6 +135,266 @@ class SelectionSyntax:
|
||||
return indices if indices else None
|
||||
|
||||
|
||||
class SelectionFilterSyntax:
|
||||
"""Parses and applies @"COL:filter" selection filters.
|
||||
|
||||
Notes:
|
||||
- CLI tokenization (shlex) strips quotes, so a user input of `@"TITLE:foo"`
|
||||
arrives as `@TITLE:foo`. We support both forms.
|
||||
- Filters apply to the *current selectable table items* (in-memory), not to
|
||||
provider searches.
|
||||
"""
|
||||
|
||||
_OP_RE = re.compile(r"^(>=|<=|!=|==|>|<|=)\s*(.+)$")
|
||||
_DUR_TOKEN_RE = re.compile(r"(?i)(\d+)\s*([hms])")
|
||||
|
||||
@staticmethod
|
||||
def parse(token: str) -> Optional[List[Tuple[str, str]]]:
|
||||
"""Return list of (column, raw_expression) or None when not a filter token."""
|
||||
|
||||
if not token or not str(token).startswith("@"):
|
||||
return None
|
||||
|
||||
if token.strip() == "@*":
|
||||
return None
|
||||
|
||||
# If this is a concrete numeric selection (@2, @1-3, @{1,3}), do not treat it as a filter.
|
||||
try:
|
||||
if SelectionSyntax.parse(str(token)) is not None:
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raw = str(token)[1:].strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# If quotes survived tokenization, strip a single symmetric wrapper.
|
||||
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ('"', "'"):
|
||||
raw = raw[1:-1].strip()
|
||||
|
||||
# Shorthand: @"foo" means Title contains "foo".
|
||||
if ":" not in raw:
|
||||
if raw:
|
||||
return [("Title", raw)]
|
||||
return None
|
||||
|
||||
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
||||
conditions: List[Tuple[str, str]] = []
|
||||
for part in parts:
|
||||
if ":" not in part:
|
||||
return None
|
||||
col, expr = part.split(":", 1)
|
||||
col = str(col or "").strip()
|
||||
expr = str(expr or "").strip()
|
||||
if not col:
|
||||
return None
|
||||
conditions.append((col, expr))
|
||||
|
||||
return conditions if conditions else None
|
||||
|
||||
@staticmethod
|
||||
def _norm_key(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", str(text or "").strip().lower())
|
||||
|
||||
@staticmethod
|
||||
def _item_column_map(item: Any) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
|
||||
def _set(k: Any, v: Any) -> None:
|
||||
key = SelectionFilterSyntax._norm_key(str(k or ""))
|
||||
if not key:
|
||||
return
|
||||
if v is None:
|
||||
return
|
||||
try:
|
||||
if isinstance(v, (list, tuple, set)):
|
||||
text = ", ".join(str(x) for x in v if x is not None)
|
||||
else:
|
||||
text = str(v)
|
||||
except Exception:
|
||||
return
|
||||
out[key] = text
|
||||
|
||||
if isinstance(item, dict):
|
||||
# Display columns (primary UX surface)
|
||||
cols = item.get("columns")
|
||||
if isinstance(cols, list):
|
||||
for pair in cols:
|
||||
try:
|
||||
if isinstance(pair, (list, tuple)) and len(pair) == 2:
|
||||
_set(pair[0], pair[1])
|
||||
except Exception:
|
||||
continue
|
||||
# Direct keys as fallback
|
||||
for k, v in item.items():
|
||||
if k == "columns":
|
||||
continue
|
||||
_set(k, v)
|
||||
else:
|
||||
cols = getattr(item, "columns", None)
|
||||
if isinstance(cols, list):
|
||||
for pair in cols:
|
||||
try:
|
||||
if isinstance(pair, (list, tuple)) and len(pair) == 2:
|
||||
_set(pair[0], pair[1])
|
||||
except Exception:
|
||||
continue
|
||||
for k in ("title", "path", "detail", "provider", "store", "table"):
|
||||
try:
|
||||
_set(k, getattr(item, k, None))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _parse_duration_seconds(text: str) -> Optional[int]:
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
|
||||
if s.isdigit():
|
||||
try:
|
||||
return max(0, int(s))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# clock format: M:SS or H:MM:SS
|
||||
if ":" in s:
|
||||
parts = [p.strip() for p in s.split(":")]
|
||||
if len(parts) == 2 and all(p.isdigit() for p in parts):
|
||||
m, sec = parts
|
||||
return max(0, int(m) * 60 + int(sec))
|
||||
if len(parts) == 3 and all(p.isdigit() for p in parts):
|
||||
h, m, sec = parts
|
||||
return max(0, int(h) * 3600 + int(m) * 60 + int(sec))
|
||||
|
||||
# token format: 1h2m3s (tokens can appear in any combination)
|
||||
total = 0
|
||||
found = False
|
||||
for m in SelectionFilterSyntax._DUR_TOKEN_RE.finditer(s):
|
||||
found = True
|
||||
n = int(m.group(1))
|
||||
unit = m.group(2).lower()
|
||||
if unit == "h":
|
||||
total += n * 3600
|
||||
elif unit == "m":
|
||||
total += n * 60
|
||||
elif unit == "s":
|
||||
total += n
|
||||
if found:
|
||||
return max(0, int(total))
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_float(text: str) -> Optional[float]:
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
s = s.replace(",", "")
|
||||
try:
|
||||
return float(s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_op(expr: str) -> tuple[Optional[str], str]:
|
||||
text = str(expr or "").strip()
|
||||
if not text:
|
||||
return None, ""
|
||||
m = SelectionFilterSyntax._OP_RE.match(text)
|
||||
if not m:
|
||||
return None, text
|
||||
return m.group(1), str(m.group(2) or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def matches(item: Any, conditions: List[Tuple[str, str]]) -> bool:
|
||||
colmap = SelectionFilterSyntax._item_column_map(item)
|
||||
|
||||
for col, expr in conditions:
|
||||
key = SelectionFilterSyntax._norm_key(col)
|
||||
actual = colmap.get(key)
|
||||
|
||||
# Convenience aliases for common UX names.
|
||||
if actual is None:
|
||||
if key == "duration":
|
||||
actual = colmap.get("duration")
|
||||
elif key == "title":
|
||||
actual = colmap.get("title")
|
||||
|
||||
if actual is None:
|
||||
return False
|
||||
|
||||
op, rhs = SelectionFilterSyntax._parse_op(expr)
|
||||
left_text = str(actual or "").strip()
|
||||
right_text = str(rhs or "").strip()
|
||||
|
||||
if op is None:
|
||||
if not right_text:
|
||||
return False
|
||||
if right_text.lower() not in left_text.lower():
|
||||
return False
|
||||
continue
|
||||
|
||||
# Comparator: try duration parsing first when it looks time-like.
|
||||
prefer_duration = (
|
||||
key == "duration"
|
||||
or any(ch in right_text for ch in (":", "h", "m", "s"))
|
||||
or any(ch in left_text for ch in (":", "h", "m", "s"))
|
||||
)
|
||||
|
||||
left_num: Optional[float] = None
|
||||
right_num: Optional[float] = None
|
||||
|
||||
if prefer_duration:
|
||||
ldur = SelectionFilterSyntax._parse_duration_seconds(left_text)
|
||||
rdur = SelectionFilterSyntax._parse_duration_seconds(right_text)
|
||||
if ldur is not None and rdur is not None:
|
||||
left_num = float(ldur)
|
||||
right_num = float(rdur)
|
||||
|
||||
if left_num is None or right_num is None:
|
||||
left_num = SelectionFilterSyntax._parse_float(left_text)
|
||||
right_num = SelectionFilterSyntax._parse_float(right_text)
|
||||
|
||||
if left_num is not None and right_num is not None:
|
||||
if op in ("=", "=="):
|
||||
if not (left_num == right_num):
|
||||
return False
|
||||
elif op == "!=":
|
||||
if not (left_num != right_num):
|
||||
return False
|
||||
elif op == ">":
|
||||
if not (left_num > right_num):
|
||||
return False
|
||||
elif op == ">=":
|
||||
if not (left_num >= right_num):
|
||||
return False
|
||||
elif op == "<":
|
||||
if not (left_num < right_num):
|
||||
return False
|
||||
elif op == "<=":
|
||||
if not (left_num <= right_num):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
continue
|
||||
|
||||
# Fallback to string equality for =/!= when numeric parsing fails.
|
||||
if op in ("=", "=="):
|
||||
if left_text.lower() != right_text.lower():
|
||||
return False
|
||||
elif op == "!=":
|
||||
if left_text.lower() == right_text.lower():
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class WorkerOutputMirror(io.TextIOBase):
|
||||
"""Mirror stdout/stderr to worker manager while preserving console output."""
|
||||
|
||||
@@ -1325,6 +1585,7 @@ class CmdletExecutor:
|
||||
filtered_args: List[str] = []
|
||||
selected_indices: List[int] = []
|
||||
select_all = False
|
||||
selection_filters: List[List[Tuple[str, str]]] = []
|
||||
|
||||
value_flags: Set[str] = set()
|
||||
try:
|
||||
@@ -1357,9 +1618,10 @@ class CmdletExecutor:
|
||||
filtered_args.append(arg)
|
||||
continue
|
||||
|
||||
if len(arg) >= 2 and arg[1] in {'"',
|
||||
"'"}:
|
||||
filtered_args.append(arg[1:].strip("\"'"))
|
||||
# Universal selection filter: @"COL:expr" (quotes may be stripped by tokenization)
|
||||
filter_spec = SelectionFilterSyntax.parse(arg)
|
||||
if filter_spec is not None:
|
||||
selection_filters.append(filter_spec)
|
||||
continue
|
||||
|
||||
if arg.strip() == "@*":
|
||||
@@ -1384,15 +1646,27 @@ class CmdletExecutor:
|
||||
# Piping should require `|` (or an explicit @ selection).
|
||||
piped_items = ctx.get_last_result_items()
|
||||
result: Any = None
|
||||
if piped_items and (select_all or selected_indices):
|
||||
if select_all:
|
||||
result = piped_items
|
||||
else:
|
||||
result = [
|
||||
piped_items[idx] for idx in selected_indices
|
||||
if 0 <= idx < len(piped_items)
|
||||
effective_selected_indices: List[int] = []
|
||||
if piped_items and (select_all or selected_indices or selection_filters):
|
||||
candidate_idxs = list(range(len(piped_items)))
|
||||
for spec in selection_filters:
|
||||
candidate_idxs = [
|
||||
i for i in candidate_idxs
|
||||
if SelectionFilterSyntax.matches(piped_items[i], spec)
|
||||
]
|
||||
|
||||
if select_all:
|
||||
effective_selected_indices = list(candidate_idxs)
|
||||
elif selected_indices:
|
||||
effective_selected_indices = [
|
||||
candidate_idxs[i] for i in selected_indices
|
||||
if 0 <= i < len(candidate_idxs)
|
||||
]
|
||||
else:
|
||||
effective_selected_indices = list(candidate_idxs)
|
||||
|
||||
result = [piped_items[i] for i in effective_selected_indices]
|
||||
|
||||
worker_manager = WorkerManagerRegistry.ensure(config)
|
||||
stage_session = WorkerStages.begin_stage(
|
||||
worker_manager,
|
||||
@@ -1438,7 +1712,7 @@ class CmdletExecutor:
|
||||
stage_status = "completed"
|
||||
stage_error = ""
|
||||
|
||||
ctx.set_last_selection(selected_indices)
|
||||
ctx.set_last_selection(effective_selected_indices)
|
||||
try:
|
||||
try:
|
||||
if hasattr(ctx, "set_current_cmdlet_name"):
|
||||
@@ -2356,6 +2630,9 @@ class PipelineExecutor:
|
||||
elif table_type == "internetarchive":
|
||||
print("Auto-loading Internet Archive item via download-file")
|
||||
stages.append(["download-file"])
|
||||
elif table_type == "podcastindex.episodes":
|
||||
print("Auto-piping selection to download-file")
|
||||
stages.append(["download-file"])
|
||||
elif table_type in {"soulseek",
|
||||
"openlibrary",
|
||||
"libgen"}:
|
||||
@@ -2397,6 +2674,14 @@ class PipelineExecutor:
|
||||
"Auto-inserting download-file after Internet Archive selection"
|
||||
)
|
||||
stages.insert(0, ["download-file"])
|
||||
if table_type == "podcastindex.episodes" and first_cmd not in (
|
||||
"download-file",
|
||||
"download-media",
|
||||
"download_media",
|
||||
".pipe",
|
||||
):
|
||||
print("Auto-inserting download-file after PodcastIndex episode selection")
|
||||
stages.insert(0, ["download-file"])
|
||||
if table_type == "libgen" and first_cmd not in (
|
||||
"download-file",
|
||||
"download-media",
|
||||
@@ -2614,7 +2899,8 @@ class PipelineExecutor:
|
||||
if not stage_tokens:
|
||||
continue
|
||||
|
||||
cmd_name = stage_tokens[0].replace("_", "-").lower()
|
||||
raw_stage_name = str(stage_tokens[0])
|
||||
cmd_name = raw_stage_name.replace("_", "-").lower()
|
||||
stage_args = stage_tokens[1:]
|
||||
|
||||
if cmd_name == "@":
|
||||
@@ -2676,12 +2962,14 @@ class PipelineExecutor:
|
||||
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")
|
||||
selection_token = raw_stage_name
|
||||
selection = SelectionSyntax.parse(selection_token)
|
||||
filter_spec = SelectionFilterSyntax.parse(selection_token)
|
||||
is_select_all = selection_token.strip() == "@*"
|
||||
if selection is None and filter_spec is None and not is_select_all:
|
||||
print(f"Invalid selection: {selection_token}\n")
|
||||
pipeline_status = "failed"
|
||||
pipeline_error = f"Invalid selection {cmd_name}"
|
||||
pipeline_error = f"Invalid selection {selection_token}"
|
||||
return
|
||||
|
||||
selected_indices = []
|
||||
@@ -2715,6 +3003,11 @@ class PipelineExecutor:
|
||||
|
||||
if is_select_all:
|
||||
selected_indices = list(range(len(items_list)))
|
||||
elif filter_spec is not None:
|
||||
selected_indices = [
|
||||
i for i, item in enumerate(items_list)
|
||||
if SelectionFilterSyntax.matches(item, filter_spec)
|
||||
]
|
||||
else:
|
||||
selected_indices = sorted(
|
||||
[i - 1 for i in selection]
|
||||
@@ -2731,6 +3024,52 @@ class PipelineExecutor:
|
||||
pipeline_error = "Empty selection"
|
||||
return
|
||||
|
||||
# Filter UX: if the stage token is a filter and it's terminal,
|
||||
# render a filtered table overlay rather than selecting/auto-downloading.
|
||||
stage_is_last = (stage_index + 1 >= len(stages))
|
||||
if filter_spec is not None and stage_is_last:
|
||||
try:
|
||||
from SYS.result_table import ResultTable
|
||||
|
||||
base_table = stage_table
|
||||
if base_table is None:
|
||||
base_table = ctx.get_last_result_table()
|
||||
|
||||
if base_table is not None and hasattr(base_table, "copy_with_title"):
|
||||
new_table = base_table.copy_with_title(getattr(base_table, "title", "") or "Results")
|
||||
else:
|
||||
new_table = ResultTable(getattr(base_table, "title", "") if base_table is not None else "Results")
|
||||
|
||||
try:
|
||||
if base_table is not None and getattr(base_table, "table", None):
|
||||
new_table.set_table(str(getattr(base_table, "table")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Attach a one-line header so users see the active filter.
|
||||
safe = str(selection_token)[1:].strip()
|
||||
new_table.set_header_line(f'filter: "{safe}"')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for item in filtered:
|
||||
new_table.add_result(item)
|
||||
|
||||
try:
|
||||
ctx.set_last_result_table_overlay(new_table, items=list(filtered), subject=ctx.get_last_result_subject())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
stdout_console().print()
|
||||
stdout_console().print(new_table)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# UX: selecting a single URL row from get-url tables should open it.
|
||||
# Only do this when the selection stage is terminal to avoid surprising
|
||||
# side-effects in pipelines like `@1 | download-file`.
|
||||
@@ -2747,10 +3086,10 @@ class PipelineExecutor:
|
||||
pass
|
||||
|
||||
if PipelineExecutor._maybe_run_class_selector(
|
||||
ctx,
|
||||
config,
|
||||
filtered,
|
||||
stage_is_last=(stage_index + 1 >= len(stages))):
|
||||
ctx,
|
||||
config,
|
||||
filtered,
|
||||
stage_is_last=(stage_index + 1 >= len(stages))):
|
||||
return
|
||||
|
||||
# Special case: selecting multiple tags from get-tag and piping into delete-tag
|
||||
@@ -2835,9 +3174,82 @@ class PipelineExecutor:
|
||||
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])
|
||||
|
||||
def _norm_stage_cmd(name: Any) -> str:
|
||||
return str(name or "").replace("_", "-").strip().lower()
|
||||
|
||||
next_cmd = None
|
||||
if stage_index + 1 < len(stages) and stages[stage_index + 1]:
|
||||
next_cmd = _norm_stage_cmd(stages[stage_index + 1][0])
|
||||
|
||||
# Auto-insert downloader stages for provider tables.
|
||||
# IMPORTANT: do not auto-download for filter selections; they may match many rows.
|
||||
if filter_spec is None:
|
||||
if stage_index + 1 >= len(stages):
|
||||
if table_type == "youtube":
|
||||
print("Auto-running YouTube selection via download-media")
|
||||
stages.append(["download-media", *stage_args])
|
||||
elif table_type == "bandcamp":
|
||||
print("Auto-running Bandcamp selection via download-media")
|
||||
stages.append(["download-media"])
|
||||
elif table_type == "internetarchive":
|
||||
print("Auto-loading Internet Archive item via download-file")
|
||||
stages.append(["download-file"])
|
||||
elif table_type == "podcastindex.episodes":
|
||||
print("Auto-piping selection to download-file")
|
||||
stages.append(["download-file"])
|
||||
elif table_type in {"soulseek", "openlibrary", "libgen"}:
|
||||
print("Auto-piping selection to download-file")
|
||||
stages.append(["download-file"])
|
||||
else:
|
||||
if table_type == "soulseek" and next_cmd not in (
|
||||
"download-file",
|
||||
"download-media",
|
||||
"download_media",
|
||||
".pipe",
|
||||
):
|
||||
debug("Auto-inserting download-file after Soulseek selection")
|
||||
stages.insert(stage_index + 1, ["download-file"])
|
||||
if table_type == "youtube" and next_cmd not in (
|
||||
"download-media",
|
||||
"download_media",
|
||||
"download-file",
|
||||
".pipe",
|
||||
):
|
||||
debug("Auto-inserting download-media after YouTube selection")
|
||||
stages.insert(stage_index + 1, ["download-media"])
|
||||
if table_type == "bandcamp" and next_cmd not in (
|
||||
"download-media",
|
||||
"download_media",
|
||||
"download-file",
|
||||
".pipe",
|
||||
):
|
||||
print("Auto-inserting download-media after Bandcamp selection")
|
||||
stages.insert(stage_index + 1, ["download-media"])
|
||||
if table_type == "internetarchive" and next_cmd not in (
|
||||
"download-file",
|
||||
"download-media",
|
||||
"download_media",
|
||||
".pipe",
|
||||
):
|
||||
debug("Auto-inserting download-file after Internet Archive selection")
|
||||
stages.insert(stage_index + 1, ["download-file"])
|
||||
if table_type == "podcastindex.episodes" and next_cmd not in (
|
||||
"download-file",
|
||||
"download-media",
|
||||
"download_media",
|
||||
".pipe",
|
||||
):
|
||||
print("Auto-inserting download-file after PodcastIndex episode selection")
|
||||
stages.insert(stage_index + 1, ["download-file"])
|
||||
if table_type == "libgen" and 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"])
|
||||
continue
|
||||
|
||||
ensure_registry_loaded()
|
||||
|
||||
Reference in New Issue
Block a user