diff --git a/CLI.py b/CLI.py
index 6a10274..5ed01c6 100644
--- a/CLI.py
+++ b/CLI.py
@@ -1710,6 +1710,51 @@ class PipelineExecutor:
return False
+ @staticmethod
+ def _maybe_open_url_selection(current_table: Any, selected_items: list, *, stage_is_last: bool) -> bool:
+ if not stage_is_last:
+ return False
+ if not selected_items or len(selected_items) != 1:
+ return False
+
+ table_type = ""
+ source_cmd = ""
+ try:
+ table_type = str(getattr(current_table, "table", "") or "").strip().lower()
+ except Exception:
+ table_type = ""
+ try:
+ source_cmd = str(getattr(current_table, "source_command", "") or "").strip().replace("_", "-").lower()
+ except Exception:
+ source_cmd = ""
+
+ if table_type != "url" and source_cmd != "get-url":
+ return False
+
+ item = selected_items[0]
+ url = None
+ try:
+ from cmdlet._shared import get_field
+
+ url = get_field(item, "url")
+ except Exception:
+ try:
+ url = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
+ except Exception:
+ url = None
+
+ url_text = str(url or "").strip()
+ if not url_text:
+ return False
+
+ try:
+ import webbrowser
+
+ webbrowser.open(url_text, new=2)
+ return True
+ except Exception:
+ return False
+
def _maybe_enable_background_notifier(self, worker_manager: Any, config: Any, pipeline_session: Any) -> None:
if not (pipeline_session and worker_manager and isinstance(config, dict)):
return
@@ -1798,12 +1843,15 @@ class PipelineExecutor:
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 selection_indices:
- row_args = ctx.get_current_stage_table_row_selection_args(idx)
- if row_args:
- selected_row_args.extend(row_args)
- break
+ # Only perform @N command expansion for *single-item* selections.
+ # For multi-item selections (e.g. @*, @1-5), expanding to a single
+ # row would silently drop items. In those cases we pipe the selected
+ # items downstream instead.
+ if source_cmd and not skip_pipe_expansion and 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):
@@ -1834,30 +1882,42 @@ class PipelineExecutor:
command_expanded = True
if (not command_expanded) and selection_indices:
- last_piped_items = None
- try:
- last_piped_items = ctx.get_last_result_items()
- except Exception:
- last_piped_items = None
-
stage_table = None
try:
stage_table = ctx.get_current_stage_table()
except Exception:
stage_table = None
- if not stage_table and hasattr(ctx, "get_display_table"):
- try:
- stage_table = ctx.get_display_table()
- except Exception:
- stage_table = None
+
+ display_table = None
+ try:
+ display_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None
+ except Exception:
+ display_table = None
+
+ if not stage_table and display_table is not None:
+ stage_table = display_table
if not stage_table:
try:
stage_table = ctx.get_last_result_table()
except Exception:
stage_table = None
- resolved_items = last_piped_items if last_piped_items else []
- if last_piped_items:
+ # Prefer selecting from the last selectable *table* (search/playlist)
+ # rather than from display-only emitted items, unless we're explicitly
+ # selecting from an overlay table.
+ try:
+ if display_table is not None and stage_table is display_table:
+ items_list = ctx.get_last_result_items() or []
+ else:
+ if hasattr(ctx, "get_last_selectable_result_items"):
+ items_list = ctx.get_last_selectable_result_items() or []
+ else:
+ items_list = ctx.get_last_result_items() or []
+ except Exception:
+ items_list = []
+
+ resolved_items = items_list if items_list else []
+ if items_list:
filtered = [resolved_items[i] for i in selection_indices if 0 <= i < len(resolved_items)]
if not filtered:
print("No items matched selection in pipeline\n")
@@ -2003,6 +2063,14 @@ class PipelineExecutor:
try:
self._try_clear_pipeline_stop(ctx)
+
+ # Preflight (URL-duplicate prompts, etc.) should be cached within a single
+ # pipeline run, not across independent pipelines.
+ try:
+ ctx.store_value("preflight", {})
+ except Exception:
+ pass
+
stages = self._split_stages(tokens)
if not stages:
print("Invalid pipeline syntax\n")
@@ -2066,11 +2134,39 @@ class PipelineExecutor:
stage_args = stage_tokens[1:]
if cmd_name == "@":
+ # Prefer piping the last emitted/visible items (e.g. add-file results)
+ # over the result-table subject. The subject can refer to older context
+ # (e.g. a playlist row) and may not contain store+hash.
+ last_items = None
+ try:
+ last_items = ctx.get_last_result_items()
+ except Exception:
+ last_items = None
+
+ if last_items:
+ from cmdlet._shared import coerce_to_pipe_object
+
+ try:
+ pipe_items = [coerce_to_pipe_object(x) for x in list(last_items)]
+ except Exception:
+ pipe_items = list(last_items)
+ piped_result = pipe_items if len(pipe_items) > 1 else pipe_items[0]
+ try:
+ ctx.set_last_items(pipe_items)
+ except Exception:
+ pass
+ if pipeline_session and worker_manager:
+ try:
+ worker_manager.log_step(pipeline_session.worker_id, "@ used last result items")
+ except Exception:
+ pass
+ continue
+
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 @"
+ pipeline_error = "No result items/subject for @"
return
piped_result = subject
try:
@@ -2095,18 +2191,34 @@ class PipelineExecutor:
return
selected_indices = []
+ # Prefer selecting from the last selectable *table* (search/playlist)
+ # rather than from display-only emitted items, unless we're explicitly
+ # selecting from an overlay table.
+ display_table = None
+ try:
+ display_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None
+ except Exception:
+ display_table = None
+
+ stage_table = ctx.get_current_stage_table()
+ if not stage_table and display_table is not None:
+ stage_table = display_table
+ if not stage_table:
+ stage_table = ctx.get_last_result_table()
+
+ if display_table is not None and stage_table is display_table:
+ items_list = ctx.get_last_result_items() or []
+ else:
+ if hasattr(ctx, "get_last_selectable_result_items"):
+ items_list = ctx.get_last_selectable_result_items() or []
+ else:
+ items_list = ctx.get_last_result_items() or []
+
if is_select_all:
- last_items = ctx.get_last_result_items() or []
- selected_indices = list(range(len(last_items)))
+ selected_indices = list(range(len(items_list)))
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 = items_list if items_list else []
filtered = [resolved_items[i] for i in selected_indices if 0 <= i < len(resolved_items)]
if not filtered:
@@ -2115,6 +2227,20 @@ class PipelineExecutor:
pipeline_error = "Empty selection"
return
+ # 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`.
+ current_table = ctx.get_current_stage_table() or ctx.get_last_result_table()
+ if (not is_select_all) and (len(filtered) == 1):
+ try:
+ PipelineExecutor._maybe_open_url_selection(
+ current_table,
+ filtered,
+ stage_is_last=(stage_index + 1 >= len(stages)),
+ )
+ except Exception:
+ pass
+
if PipelineExecutor._maybe_run_class_selector(ctx, config, filtered, stage_is_last=(stage_index + 1 >= len(stages))):
return
@@ -2366,12 +2492,19 @@ class PipelineExecutor:
# 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 ""
+ try:
+ stage_table_source = str(getattr(stage_table, "source_command", "") or "").strip().replace("_", "-").lower() if stage_table else ""
+ except Exception:
+ stage_table_source = ""
if (
(not stage_is_last)
and (not emits)
and cmd_name in {"download-media", "download_media"}
and stage_table is not None
- and stage_table_type in {"ytdlp.formatlist", "download-media", "download_media"}
+ and (
+ stage_table_type in {"ytdlp.formatlist", "download-media", "download_media", "bandcamp", "youtube"}
+ or stage_table_source in {"download-media", "download_media"}
+ )
):
try:
is_selectable = not bool(getattr(stage_table, "no_choice", False))
@@ -2407,6 +2540,10 @@ class PipelineExecutor:
stdout_console().print()
stdout_console().print(stage_table)
+ # Always pause the pipeline when a selectable table was produced.
+ # The user will continue by running @N/@* which will re-attach the
+ # pending downstream stages.
+
try:
remaining = stages[stage_index + 1 :]
source_cmd = (
diff --git a/Provider/libgen.py b/Provider/libgen.py
index 214f51a..c1d3983 100644
--- a/Provider/libgen.py
+++ b/Provider/libgen.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import html as html_std
import logging
import re
import requests
@@ -22,6 +23,595 @@ except ImportError:
lxml_html = None
+def _strip_html_to_text(raw: str) -> str:
+ s = html_std.unescape(str(raw or ""))
+ s = re.sub(r"(?i)
", "\n", s)
+ # Help keep lists readable when they are link-heavy.
+ s = re.sub(r"(?i)", ", ", s)
+ s = re.sub(r"<[^>]+>", " ", s)
+ s = re.sub(r"\s+", " ", s)
+ return s.strip()
+
+
+def _strip_html_to_lines(raw: str) -> List[str]:
+ """Convert a small HTML snippet to a list of meaningful text lines.
+
+ Unlike `_strip_html_to_text`, this preserves `
` as line breaks so we can
+ parse LibGen ads.php tag blocks that use `
` separators.
+ """
+
+ s = html_std.unescape(str(raw or ""))
+ s = re.sub(r"(?is)