This commit is contained in:
2026-01-03 03:37:48 -08:00
parent 6e9a0c28ff
commit 73f3005393
23 changed files with 1791 additions and 442 deletions

378
CLI.py
View File

@@ -68,6 +68,27 @@ from SYS.cmdlet_catalog import (
from SYS.config import get_local_storage_path, load_config
from SYS.result_table import ResultTable
HELP_EXAMPLE_SOURCE_COMMANDS = {
".help-example",
"help-example",
}
def _split_pipeline_tokens(tokens: Sequence[str]) -> List[List[str]]:
"""Split example tokens into per-stage command sequences using pipe separators."""
stages: List[List[str]] = []
current: List[str] = []
for token in tokens:
if token == "|":
if current:
stages.append(current)
current = []
continue
current.append(str(token))
if current:
stages.append(current)
return [stage for stage in stages if stage]
class SelectionSyntax:
"""Parses @ selection syntax into 1-based indices."""
@@ -1397,6 +1418,7 @@ class CmdletExecutor:
"get-relationship",
"get-rel",
".pipe",
".mpv",
".matrix",
".telegram",
"telegram",
@@ -2418,96 +2440,117 @@ class PipelineExecutor:
)
command_expanded = False
example_selector_triggered = False
normalized_source_cmd = str(source_cmd or "").replace("_", "-").strip().lower()
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
# Command expansion via @N:
# - Default behavior: expand ONLY for single-row selections.
# - Special case: allow multi-row expansion for add-file directory tables by
# combining selected rows into a single `-path file1,file2,...` argument.
if source_cmd and not skip_pipe_expansion:
src = str(source_cmd).replace("_", "-").strip().lower()
if normalized_source_cmd in HELP_EXAMPLE_SOURCE_COMMANDS and selection_indices:
try:
idx = selection_indices[0]
row_args = ctx.get_current_stage_table_row_selection_args(idx)
except Exception:
row_args = None
tokens: List[str] = []
if isinstance(row_args, list) and row_args:
tokens = [str(x) for x in row_args if x is not None]
if tokens:
stage_groups = _split_pipeline_tokens(tokens)
if stage_groups:
for stage in reversed(stage_groups):
stages.insert(0, stage)
selection_indices = []
command_expanded = True
example_selector_triggered = True
if src == "add-file" and selection_indices:
row_args_list: List[List[str]] = []
for idx in selection_indices:
if not example_selector_triggered:
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 in {".pipe", ".mpv"} and len(stages) > 0
# Command expansion via @N:
# - Default behavior: expand ONLY for single-row selections.
# - Special case: allow multi-row expansion for add-file directory tables by
# combining selected rows into a single `-path file1,file2,...` argument.
if source_cmd and not skip_pipe_expansion:
src = str(source_cmd).replace("_", "-").strip().lower()
if src == "add-file" and selection_indices:
row_args_list: List[List[str]] = []
for idx in selection_indices:
try:
row_args = ctx.get_current_stage_table_row_selection_args(
idx
)
except Exception:
row_args = None
if isinstance(row_args, list) and row_args:
row_args_list.append(
[str(x) for x in row_args if x is not None]
)
# Combine `['-path', <file>]` from each row into one `-path` arg.
paths: List[str] = []
can_merge = bool(row_args_list) and (
len(row_args_list) == len(selection_indices)
)
if can_merge:
for ra in row_args_list:
if len(ra) == 2 and str(ra[0]).strip().lower() in {
"-path",
"--path",
"-p",
}:
p = str(ra[1]).strip()
if p:
paths.append(p)
else:
can_merge = False
break
if can_merge and paths:
selected_row_args.extend(["-path", ",".join(paths)])
elif len(selection_indices) == 1 and row_args_list:
selected_row_args.extend(row_args_list[0])
else:
# Only perform @N command expansion for *single-item* selections.
# For multi-item selections (e.g. @*, @1-5), expanding to one row
# would silently drop items. In those cases we pipe items downstream.
if len(selection_indices) == 1:
idx = selection_indices[0]
row_args = ctx.get_current_stage_table_row_selection_args(idx)
if row_args:
selected_row_args.extend(row_args)
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:
row_args = ctx.get_current_stage_table_row_selection_args(
idx
worker_manager.log_step(
pipeline_session.worker_id,
f"@N expansion: {source_cmd} + {' '.join(str(x) for x in selected_row_args)}",
)
except Exception:
row_args = None
if isinstance(row_args, list) and row_args:
row_args_list.append(
[str(x) for x in row_args if x is not None]
)
pass
# Combine `['-path', <file>]` from each row into one `-path` arg.
paths: List[str] = []
can_merge = bool(row_args_list) and (
len(row_args_list) == len(selection_indices)
)
if can_merge:
for ra in row_args_list:
if len(ra) == 2 and str(ra[0]).strip().lower() in {
"-path",
"--path",
"-p",
}:
p = str(ra[1]).strip()
if p:
paths.append(p)
else:
can_merge = False
break
if can_merge and paths:
selected_row_args.extend(["-path", ",".join(paths)])
elif len(selection_indices) == 1 and row_args_list:
selected_row_args.extend(row_args_list[0])
else:
# Only perform @N command expansion for *single-item* selections.
# For multi-item selections (e.g. @*, @1-5), expanding to one row
# would silently drop items. In those cases we pipe items downstream.
if len(selection_indices) == 1:
idx = selection_indices[0]
row_args = ctx.get_current_stage_table_row_selection_args(idx)
if row_args:
selected_row_args.extend(row_args)
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
selection_indices = []
command_expanded = True
selection_indices = []
command_expanded = True
if (not command_expanded) and selection_indices:
stage_table = None
@@ -2597,74 +2640,44 @@ class PipelineExecutor:
"table") else None
)
def _norm_cmd(name: Any) -> str:
return str(name or "").replace("_", "-").strip().lower()
auto_stage = None
if isinstance(table_type, str) and table_type:
try:
from ProviderCore.registry import selection_auto_stage_for_table
auto_stage = selection_auto_stage_for_table(table_type)
except Exception:
auto_stage = None
if not stages:
if table_type == "youtube":
print("Auto-running YouTube selection via download-file")
stages.append(["download-file"])
elif table_type == "bandcamp":
print("Auto-running Bandcamp selection via download-file")
stages.append(["download-file"])
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"])
elif isinstance(table_type, str) and table_type.startswith("metadata."):
if isinstance(table_type, str) and table_type.startswith("metadata."):
print("Auto-applying metadata selection via get-tag")
stages.append(["get-tag"])
elif auto_stage:
try:
print(f"Auto-running selection via {auto_stage[0]}")
except Exception:
pass
stages.append(list(auto_stage))
else:
first_cmd = stages[0][0] if stages and stages[0] else None
if table_type == "soulseek" and first_cmd not in (
"download-file",
".pipe",
):
debug("Auto-inserting download-file after Soulseek selection")
stages.insert(0, ["download-file"])
if table_type == "youtube" and first_cmd not in (
"download-file",
".pipe",
):
debug("Auto-inserting download-file after YouTube selection")
stages.insert(0, ["download-file"])
if table_type == "bandcamp" and first_cmd not in (
"download-file",
".pipe",
):
print("Auto-inserting download-file after Bandcamp selection")
stages.insert(0, ["download-file"])
if table_type == "internetarchive" and first_cmd not in (
"download-file",
".pipe",
):
debug(
"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",
".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",
".pipe",
):
print("Auto-inserting download-file after Libgen selection")
stages.insert(0, ["download-file"])
if isinstance(table_type, str) and table_type.startswith("metadata.") and first_cmd not in (
"get-tag",
"get_tag",
".pipe",
".mpv",
):
print("Auto-inserting get-tag after metadata selection")
stages.insert(0, ["get-tag"])
elif auto_stage:
first_cmd_norm = _norm_cmd(first_cmd)
auto_cmd_norm = _norm_cmd(auto_stage[0])
if first_cmd_norm not in (auto_cmd_norm, ".pipe", ".mpv"):
debug(f"Auto-inserting {auto_cmd_norm} after selection")
stages.insert(0, list(auto_stage))
return True, piped_result
else:
@@ -2744,7 +2757,7 @@ class PipelineExecutor:
# `.pipe` (MPV) is an interactive launcher; disable pipeline Live progress
# for it because it doesn't meaningfully "complete" (mpv may keep running)
# and Live output interferes with MPV playlist UI.
if name == ".pipe":
if name in {".pipe", ".mpv"}:
continue
# `.matrix` uses a two-phase picker (@N then .matrix -send). Pipeline Live
# progress can linger across those phases and interfere with interactive output.
@@ -3161,62 +3174,37 @@ class PipelineExecutor:
if stage_index + 1 < len(stages) and stages[stage_index + 1]:
next_cmd = _norm_stage_cmd(stages[stage_index + 1][0])
auto_stage = None
if isinstance(table_type, str) and table_type:
try:
from ProviderCore.registry import selection_auto_stage_for_table
# Preserve historical behavior: only forward selection-stage args
# to the auto stage when we are appending a new last stage.
at_end = bool(stage_index + 1 >= len(stages))
auto_stage = selection_auto_stage_for_table(
table_type,
stage_args if at_end else None,
)
except Exception:
auto_stage = None
# 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-file")
stages.append(["download-file", *stage_args])
elif table_type == "bandcamp":
print("Auto-running Bandcamp selection via download-file")
stages.append(["download-file"])
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"])
if auto_stage:
try:
print(f"Auto-running selection via {auto_stage[0]}")
except Exception:
pass
stages.append(list(auto_stage))
else:
if table_type == "soulseek" and next_cmd not in (
"download-file",
".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-file",
".pipe",
):
debug("Auto-inserting download-file after YouTube selection")
stages.insert(stage_index + 1, ["download-file"])
if table_type == "bandcamp" and next_cmd not in (
"download-file",
".pipe",
):
print("Auto-inserting download-file after Bandcamp selection")
stages.insert(stage_index + 1, ["download-file"])
if table_type == "internetarchive" and next_cmd not in (
"download-file",
".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",
".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",
".pipe",
):
print("Auto-inserting download-file after Libgen selection")
stages.insert(stage_index + 1, ["download-file"])
if auto_stage:
auto_cmd = _norm_stage_cmd(auto_stage[0])
if next_cmd not in (auto_cmd, ".pipe", ".mpv"):
debug(f"Auto-inserting {auto_cmd} after selection")
stages.insert(stage_index + 1, list(auto_stage))
continue
cmd_fn = REGISTRY.get(cmd_name)
@@ -3386,9 +3374,9 @@ class PipelineExecutor:
except Exception:
pass
# `.pipe` is typically the terminal interactive stage (MPV UI).
# `.pipe`/`.mpv` is typically the terminal interactive stage (MPV UI).
# Stop Live progress before running it so output doesn't get stuck behind Live.
if (cmd_name == ".pipe" and progress_ui is not None
if (cmd_name in {".pipe", ".mpv"} and progress_ui is not None
and (stage_index + 1 >= len(stages))):
try:
progress_ui.stop()
@@ -3495,7 +3483,7 @@ class PipelineExecutor:
"bandcamp",
"youtube",
} or stage_table_source in {"download-file"}
or stage_table_type in {"internetarchive.formats"}
or stage_table_type in {"internetarchive.format", "internetarchive.formats"}
or stage_table_source in {"download-file"})):
try:
is_selectable = not bool(