This commit is contained in:
2026-05-16 15:03:33 -07:00
parent 717cb13dda
commit 5048729b0c
10 changed files with 1646 additions and 241 deletions
+51 -12
View File
@@ -3174,7 +3174,9 @@ class PipelineExecutor:
pipe_idx = pipe_index_by_stage.get(stage_index) pipe_idx = pipe_index_by_stage.get(stage_index)
overlay_table: Any | None = None output_table: Any | None = None
pre_stage_table: Any | None = None
pre_last_result_table: Any | None = None
session = _worker().WorkerStages.begin_stage( session = _worker().WorkerStages.begin_stage(
worker_manager, worker_manager,
cmd_name=cmd_name, cmd_name=cmd_name,
@@ -3204,6 +3206,22 @@ class PipelineExecutor:
# should call begin_pipe themselves with the actual count. # should call begin_pipe themselves with the actual count.
progress_ui.begin_pipe(pipe_idx, total_items=1) progress_ui.begin_pipe(pipe_idx, total_items=1)
if stage_index + 1 >= len(stages):
try:
pre_stage_table = (
ctx.get_current_stage_table()
if hasattr(ctx, "get_current_stage_table") else None
)
except Exception:
pre_stage_table = None
try:
pre_last_result_table = (
ctx.get_last_result_table()
if hasattr(ctx, "get_last_result_table") else None
)
except Exception:
pre_last_result_table = None
# RUN THE CMDLET # RUN THE CMDLET
ret_code = cmd_fn(piped_result, stage_args, config) ret_code = cmd_fn(piped_result, stage_args, config)
if ret_code is not None: if ret_code is not None:
@@ -3216,20 +3234,41 @@ class PipelineExecutor:
pipeline_error = f"Stage '{cmd_name}' failed with exit code {normalized_ret}" pipeline_error = f"Stage '{cmd_name}' failed with exit code {normalized_ret}"
return return
# Pipeline overlay tables (e.g., get-url detail views) need to be # Terminal pipeline stages need to render overlay tables and also
# rendered when running inside a pipeline because the CLI path # newly produced standard result tables from row actions like
# normally handles rendering. The overlay is only useful when # `.config -browse ...`, because there is no outer CLI render pass.
# we're at the terminal stage of the pipeline. Save the table so output_table = None
# it can be printed after the pipe finishes.
overlay_table = None
if stage_index + 1 >= len(stages): if stage_index + 1 >= len(stages):
try: try:
overlay_table = ( output_table = (
ctx.get_display_table() ctx.get_display_table()
if hasattr(ctx, "get_display_table") else None if hasattr(ctx, "get_display_table") else None
) )
except Exception: except Exception:
overlay_table = None output_table = None
if output_table is None:
current_stage_table = None
last_result_table = None
try:
current_stage_table = (
ctx.get_current_stage_table()
if hasattr(ctx, "get_current_stage_table") else None
)
except Exception:
current_stage_table = None
try:
last_result_table = (
ctx.get_last_result_table()
if hasattr(ctx, "get_last_result_table") else None
)
except Exception:
last_result_table = None
if current_stage_table is not None and current_stage_table is not pre_stage_table:
output_table = current_stage_table
elif last_result_table is not None and last_result_table is not pre_last_result_table:
output_table = last_result_table
# Update piped_result for next stage from emitted items # Update piped_result for next stage from emitted items
stage_emits = list(stage_ctx.emits) stage_emits = list(stage_ctx.emits)
@@ -3240,14 +3279,14 @@ class PipelineExecutor:
finally: finally:
if progress_ui is not None and pipe_idx is not None: if progress_ui is not None and pipe_idx is not None:
progress_ui.finish_pipe(pipe_idx) progress_ui.finish_pipe(pipe_idx)
if overlay_table is not None: if output_table is not None:
try: try:
from SYS.rich_display import stdout_console from SYS.rich_display import stdout_console
stdout_console().print() stdout_console().print()
stdout_console().print(overlay_table) stdout_console().print(output_table)
except Exception: except Exception:
logger.exception("Failed to render overlay_table to stdout_console") logger.exception("Failed to render output_table to stdout_console")
if session: if session:
try: try:
session.close() session.close()
+866 -114
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -553,12 +553,25 @@ class Add_Tag(Cmdlet):
extract_debug = bool(parsed.get("extract-debug", False)) extract_debug = bool(parsed.get("extract-debug", False))
extract_debug_rx, extract_debug_err = _try_compile_extract_template(extract_template) extract_debug_rx, extract_debug_err = _try_compile_extract_template(extract_template)
raw_tag = parsed.get("tag", [])
if isinstance(raw_tag, str):
raw_tag = [raw_tag]
# Normalize input early so a non-hash -query can be treated as the tag payload
# when the target item is already coming from the pipeline.
results = normalize_result_input(result)
query_value = parsed.get("query")
query_hash, query_valid = sh.require_single_hash_query( query_hash, query_valid = sh.require_single_hash_query(
parsed.get("query"), query_value,
"[add_tag] Error: -query must be of the form hash:<sha256>", "[add_tag] Error: -query must be of the form hash:<sha256>",
log_file=sys.stderr, log_file=sys.stderr,
) )
if not query_valid: if not query_valid:
if not raw_tag and results and query_value:
raw_tag = [str(query_value)]
query_hash = None
else:
return 1 return 1
hash_override = query_hash hash_override = query_hash
@@ -581,9 +594,6 @@ class Add_Tag(Cmdlet):
if has_downstream and not include_temp and not store_override: if has_downstream and not include_temp and not store_override:
include_temp = True include_temp = True
# Normalize input to list
results = normalize_result_input(result)
# Filter by temp status (unless --all is set) # Filter by temp status (unless --all is set)
if not include_temp: if not include_temp:
results = filter_results_by_temp(results, include_temp=False) results = filter_results_by_temp(results, include_temp=False)
@@ -599,11 +609,6 @@ class Add_Tag(Cmdlet):
) )
return 1 return 1
# Get tag from arguments (or fallback to pipeline payload)
raw_tag = parsed.get("tag", [])
if isinstance(raw_tag, str):
raw_tag = [raw_tag]
# Fallback: if no tag provided explicitly, try to pull from first result payload. # Fallback: if no tag provided explicitly, try to pull from first result payload.
# IMPORTANT: when -extract is used, users typically want *only* extracted tags, # IMPORTANT: when -extract is used, users typically want *only* extracted tags,
# not "re-add whatever tags are already in the payload". # not "re-add whatever tags are already in the payload".
+10 -4
View File
@@ -462,12 +462,22 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
rest.append(a) rest.append(a)
i += 1 i += 1
# Normalize the incoming target list early so a non-hash -query can be treated
# as the delete-tag payload when the file target already comes from the pipeline.
items_to_process = sh.normalize_result_items(result)
tags_arg = _parse_delete_tag_arguments(rest)
override_hash, query_valid = sh.require_single_hash_query( override_hash, query_valid = sh.require_single_hash_query(
override_query, override_query,
"Invalid -query value (expected hash:<sha256>)", "Invalid -query value (expected hash:<sha256>)",
log_file=sys.stderr, log_file=sys.stderr,
) )
if not query_valid: if not query_valid:
if (not tags_arg and override_query and items_to_process and not has_piped_tag
and not has_piped_tag_list):
tags_arg = _parse_delete_tag_arguments([override_query])
override_hash = None
else:
return 1 return 1
# Selection syntax (@...) is handled by the pipeline runner, not by this cmdlet. # Selection syntax (@...) is handled by the pipeline runner, not by this cmdlet.
@@ -485,7 +495,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception: except Exception:
grouped_table = "" grouped_table = ""
grouped_tags = get_field(result, "tag") if result is not None else None grouped_tags = get_field(result, "tag") if result is not None else None
tags_arg = _parse_delete_tag_arguments(rest)
if (grouped_table == "tag.selection" and isinstance(grouped_tags, if (grouped_table == "tag.selection" and isinstance(grouped_tags,
list) and grouped_tags list) and grouped_tags
and not tags_arg): and not tags_arg):
@@ -503,9 +512,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log("Requires at least one tag argument") log("Requires at least one tag argument")
return 1 return 1
# Normalize result to a list for processing
items_to_process = sh.normalize_result_items(result)
# Process each item # Process each item
success_count = 0 success_count = 0
+423 -57
View File
@@ -15,11 +15,44 @@ from SYS.logger import log
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.result_table import Table from SYS.result_table import Table
from cmdnat._parsing import ( from cmdnat._parsing import (
VALUE_ARG_FLAGS,
extract_piped_value as _extract_piped_value, extract_piped_value as _extract_piped_value,
extract_arg_value as _extract_arg_value,
extract_value_arg as _extract_value_arg, extract_value_arg as _extract_value_arg,
has_flag as _has_flag, has_flag as _has_flag,
) )
_PREFERENCES_BROWSE_PATH = "__preferences__"
_PLUGINS_BROWSE_PATH = "__plugins__"
_PLUGIN_CATEGORY_KEYS = ("plugin", "provider", "tool")
_KNOWN_SECTION_LABELS = {
"plugin": "Plugins",
"provider": "Plugins",
"tool": "Plugins",
}
_KNOWN_SECTION_DESCRIPTIONS = {
_PREFERENCES_BROWSE_PATH: "Global preferences and simple values",
_PLUGINS_BROWSE_PATH: "All configured plugins and plugin instances",
"provider": "Plugin configuration",
"plugin": "Plugin configuration",
"tool": "Plugin configuration",
}
_SENSITIVE_CONFIG_KEYS = {
"access_key",
"access_token",
"api",
"api_key",
"apikey",
"authorization",
"bearer_token",
"cookie",
"cookies",
"password",
"secret",
"token",
}
CMDLET = Cmdlet( CMDLET = Cmdlet(
name=".config", name=".config",
summary="Manage configuration settings", summary="Manage configuration settings",
@@ -173,29 +206,316 @@ def _show_config_logs(args: Sequence[str]) -> int:
return 0 return 0
def flatten_config(config: Dict[str, Any], parent_key: str = "", sep: str = ".") -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
for k, v in config.items():
if k.startswith("_"):
continue
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(flatten_config(v, new_key, sep=sep))
else:
items.append({
"key": new_key,
"value": v,
"value_display": str(v),
"type": type(v).__name__,
})
return items
def set_nested_config(config: Dict[str, Any], key: str, value: str) -> bool: def set_nested_config(config: Dict[str, Any], key: str, value: str) -> bool:
return set_nested_config_value(config, key, value, on_error=print) return set_nested_config_value(config, key, value, on_error=print)
def _get_selected_config_key() -> Optional[str]: def _visible_config_entries(config_data: Any) -> List[tuple[str, Any]]:
if not isinstance(config_data, dict):
return []
return [
(str(key), value)
for key, value in config_data.items()
if isinstance(key, str) and not key.startswith("_")
]
def _format_config_label(value: Any) -> str:
text = str(value or "").strip()
if not text:
return "Configuration"
if text == _PREFERENCES_BROWSE_PATH:
return "Preferences"
if text == _PLUGINS_BROWSE_PATH:
return "Plugins"
return text.replace("_", " ").replace("-", " ").strip().title()
def _format_config_path_label(browse_path: Optional[str]) -> str:
text = str(browse_path or "").strip()
if not text:
return "Root"
if text == _PREFERENCES_BROWSE_PATH:
return "Preferences"
if text == _PLUGINS_BROWSE_PATH:
return "Plugins"
parts = [part for part in text.split(".") if part]
formatted: List[str] = []
for idx, part in enumerate(parts):
if idx == 0 and part in _PLUGIN_CATEGORY_KEYS:
formatted.append("Plugins")
else:
formatted.append(_format_config_label(part))
return " / ".join(formatted)
def _format_config_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, (list, tuple, set)):
return ", ".join(str(item) for item in value)
return str(value)
def _is_sensitive_config_key(key_path: str) -> bool:
leaf = str(key_path or "").split(".")[-1].strip().lower()
return leaf in _SENSITIVE_CONFIG_KEYS
def _format_config_entry_count(value: Any) -> str:
count = len(_visible_config_entries(value)) if isinstance(value, dict) else 0
if count == 1:
return "1 entry"
return f"{count} entries"
def _iter_plugin_branches(config_data: Dict[str, Any]) -> List[tuple[str, str, Any]]:
branches: List[tuple[str, str, Any]] = []
if not isinstance(config_data, dict):
return branches
for category in _PLUGIN_CATEGORY_KEYS:
category_block = config_data.get(category)
if not isinstance(category_block, dict):
continue
for name, value in _visible_config_entries(category_block):
branches.append((category, name, value))
return branches
def _collect_plugin_root_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]:
plugin_items: Dict[str, Dict[str, Any]] = {}
for category, name, value in _iter_plugin_branches(config_data):
key = str(name or "").strip().lower()
if not key:
continue
existing = plugin_items.get(key)
if existing is None:
plugin_items[key] = {
"kind": "section",
"title": _format_config_label(name),
"browse_path": f"{category}.{name}",
"summary": _format_config_entry_count(value),
"type": "section",
"description": "Plugin configuration",
}
continue
if str(category) == "plugin" and not str(existing.get("browse_path") or "").startswith("plugin."):
existing["browse_path"] = f"{category}.{name}"
try:
current_count = int(str(existing.get("summary") or "0").split()[0])
except Exception:
current_count = 0
extra_count = len(_visible_config_entries(value)) if isinstance(value, dict) else 0
merged_count = current_count + extra_count
existing["summary"] = "1 entry" if merged_count == 1 else f"{merged_count} entries"
return sorted(plugin_items.values(), key=lambda item: str(item.get("title") or "").lower())
def _resolve_config_branch(
config_data: Dict[str, Any],
browse_path: Optional[str],
) -> Optional[Dict[str, Any]]:
text = str(browse_path or "").strip()
if not text:
return config_data if isinstance(config_data, dict) else None
if text == _PREFERENCES_BROWSE_PATH:
return {
key: value
for key, value in _visible_config_entries(config_data)
if not isinstance(value, dict)
}
if text == _PLUGINS_BROWSE_PATH:
return {
str(item.get("title") or ""): item
for item in _collect_plugin_root_items(config_data)
}
current: Any = config_data
for part in text.split("."):
if not isinstance(current, dict):
return None
current = current.get(part)
return current if isinstance(current, dict) else None
def _build_section_item(
*,
title: str,
browse_path: str,
value: Any,
description: Optional[str] = None,
) -> Dict[str, Any]:
return {
"kind": "section",
"title": title,
"browse_path": browse_path,
"summary": _format_config_entry_count(value),
"type": "section",
"description": str(description or "").strip() or _KNOWN_SECTION_DESCRIPTIONS.get(browse_path, ""),
}
def _build_value_item(
*,
key_path: str,
name: str,
value: Any,
) -> Dict[str, Any]:
display_value = "***" if _is_sensitive_config_key(key_path) else _format_config_value(value)
path_parts = [part for part in str(key_path or "").split(".") if part]
display_path = " / ".join(
[_format_config_path_label(".".join(path_parts[:-1]))] if len(path_parts) > 1 else []
+ [_format_config_label(path_parts[-1])] if path_parts else [_format_config_label(name)]
)
return {
"kind": "value",
"key": key_path,
"name": name,
"title": _format_config_label(name),
"value": value,
"value_display": display_value,
"display_path": display_path,
"type": type(value).__name__,
}
def _build_root_config_items(config_data: Dict[str, Any]) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
visible_entries = _visible_config_entries(config_data)
preferences = {
key: value
for key, value in visible_entries
if not isinstance(value, dict)
}
if preferences:
items.append(
_build_section_item(
title="Preferences",
browse_path=_PREFERENCES_BROWSE_PATH,
value=preferences,
)
)
plugin_items = _collect_plugin_root_items(config_data)
if plugin_items:
items.append(
_build_section_item(
title="Plugins",
browse_path=_PLUGINS_BROWSE_PATH,
value={item["title"]: item for item in plugin_items},
)
)
other_sections: List[Dict[str, Any]] = []
for key, value in visible_entries:
if key in set(_PLUGIN_CATEGORY_KEYS) or not isinstance(value, dict):
continue
other_sections.append(
_build_section_item(
title=_format_config_label(key),
browse_path=key,
value=value,
)
)
other_sections.sort(key=lambda item: str(item.get("title") or "").lower())
items.extend(other_sections)
return items
def _build_nested_config_items(
config_data: Dict[str, Any],
browse_path: str,
) -> List[Dict[str, Any]]:
if browse_path == _PLUGINS_BROWSE_PATH:
return _collect_plugin_root_items(config_data)
branch = _resolve_config_branch(config_data, browse_path)
if branch is None:
return []
section_items: List[Dict[str, Any]] = []
value_items: List[Dict[str, Any]] = []
is_preferences_view = browse_path == _PREFERENCES_BROWSE_PATH
for key, value in _visible_config_entries(branch):
full_key = key if is_preferences_view else f"{browse_path}.{key}"
if isinstance(value, dict):
section_items.append(
_build_section_item(
title=_format_config_label(key),
browse_path=full_key,
value=value,
)
)
else:
value_items.append(
_build_value_item(
key_path=full_key,
name=key,
value=value,
)
)
section_items.sort(key=lambda item: str(item.get("title") or "").lower())
value_items.sort(key=lambda item: str(item.get("name") or "").lower())
return section_items + value_items
def _build_config_items(
config_data: Dict[str, Any],
browse_path: Optional[str] = None,
) -> List[Dict[str, Any]]:
text = str(browse_path or "").strip()
if not text:
return _build_root_config_items(config_data)
return _build_nested_config_items(config_data, text)
def _build_config_table_title(browse_path: Optional[str]) -> str:
text = str(browse_path or "").strip()
if not text:
return "Configuration"
return f"Configuration: {_format_config_path_label(text)}"
def _build_config_header_lines(browse_path: Optional[str]) -> List[str]:
text = str(browse_path or "").strip()
if not text:
return [
"Use @N on a section to drill in. Use @.. to go back.",
]
return [
f"Path: {_format_config_path_label(text)}",
"Use @N on a section to drill in. Use @N | .config <value> to update a setting. Use @.. to go back.",
]
def _extract_browse_arg(args: Sequence[str]) -> Optional[str]:
return _extract_arg_value(args, flags={"-browse", "--browse"}, allow_positional=False)
def _extract_selected_update_value(args: Sequence[str]) -> Optional[str]:
explicit = _extract_arg_value(args, flags=VALUE_ARG_FLAGS, allow_positional=False)
if explicit is not None:
return explicit
tokens = [str(arg).strip() for arg in (args or []) if str(arg).strip()]
positional = [token for token in tokens if not token.startswith("-")]
if len(positional) == 1:
return positional[0]
return None
def _get_selected_config_item() -> Optional[Dict[str, Any]]:
try: try:
indices = ctx.get_last_selection() or [] indices = ctx.get_last_selection() or []
except Exception: except Exception:
@@ -211,32 +531,83 @@ def _get_selected_config_key() -> Optional[str]:
return None return None
item = items[idx] item = items[idx]
if isinstance(item, dict): if isinstance(item, dict):
return item.get("key") return item
return getattr(item, "key", None)
normalized: Dict[str, Any] = {}
for key in ("kind", "key", "title", "browse_path", "name", "value", "value_display", "type"):
try:
value = getattr(item, key, None)
except Exception:
value = None
if value is not None:
normalized[key] = value
return normalized or None
def _show_config_table(config_data: Dict[str, Any]) -> int: def _show_config_table(
items = flatten_config(config_data) config_data: Dict[str, Any],
*,
browse_path: Optional[str] = None,
) -> int:
items = _build_config_items(config_data, browse_path=browse_path)
if not items: if not items:
print("No configuration entries available.") path_text = _format_config_path_label(browse_path)
print(f"No configuration entries available for {path_text}.")
return 0 return 0
items.sort(key=lambda x: x.get("key"))
table = Table("Configuration") table = Table(_build_config_table_title(browse_path), preserve_order=True)
table.set_table("config") table.set_table("config")
if browse_path:
table.set_source_command(".config", ["-browse", str(browse_path)])
else:
table.set_source_command(".config", []) table.set_source_command(".config", [])
table.set_header_lines(_build_config_header_lines(browse_path))
for item in items: for idx, item in enumerate(items):
row = table.add_row() row = table.add_row()
row.add_column("Key", item.get("key", "")) row.add_column("Name", item.get("title", ""))
row.add_column("Value", item.get("value_display", "")) row.add_column("Value", item.get("summary") or item.get("value_display", ""))
row.add_column("Type", item.get("type", "")) row.add_column("Type", item.get("type", ""))
if item.get("kind") == "section" and item.get("browse_path"):
table.set_row_selection_action(
idx,
[".config", "-browse", str(item.get("browse_path"))],
)
ctx.set_last_result_table_overlay(table, items) ctx.set_last_result_table(table, items)
ctx.set_current_stage_table(table) ctx.set_current_stage_table(table)
return 0 return 0
def _save_updated_config(config_data: Dict[str, Any], key_path: str) -> None:
try:
key_l = str(key_path or "").lower()
except Exception:
key_l = ""
if "alldebrid" in key_l or "all-debrid" in key_l:
save_config_and_verify(config_data)
return
save_config(config_data)
def _resolve_direct_browse_path(
config_data: Dict[str, Any],
token: str,
) -> Optional[str]:
text = str(token or "").strip()
if not text:
return None
lowered = text.lower()
if lowered in {"preferences", "prefs"}:
return _PREFERENCES_BROWSE_PATH
if lowered in {"plugins", "plugin", "providers", "provider", "tools", "tool"}:
return _PLUGINS_BROWSE_PATH
branch = _resolve_config_branch(config_data, text)
if isinstance(branch, dict):
return text
return None
def _strip_value_quotes(value: str) -> str: def _strip_value_quotes(value: str) -> str:
if not value: if not value:
return value return value
@@ -254,36 +625,28 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int:
# Load configuration from the database # Load configuration from the database
current_config = load_config() current_config = load_config()
selection_key = _get_selected_config_key() browse_path = _extract_browse_arg(args)
value_from_args = _extract_value_arg(args) if selection_key else None if browse_path:
value_from_pipe = _extract_piped_value(piped_result) return _show_config_table(current_config, browse_path=browse_path)
if selection_key: selection_item = _get_selected_config_item()
new_value = value_from_pipe or value_from_args value_from_pipe = _extract_piped_value(piped_result)
if not new_value: selection_kind = str((selection_item or {}).get("kind") or "").strip().lower()
print( selection_key = str((selection_item or {}).get("key") or "").strip() or None
"Provide a new value via pipe or argument: @N | .config <value>" selection_browse_path = str((selection_item or {}).get("browse_path") or "").strip() or None
) selection_display_path = str((selection_item or {}).get("display_path") or selection_key or "").strip() or selection_key
return 1
if selection_kind == "section" and selection_browse_path and not args and value_from_pipe is None:
return _show_config_table(current_config, browse_path=selection_browse_path)
if selection_kind == "value" and selection_key:
new_value = value_from_pipe or _extract_selected_update_value(args)
if new_value is not None:
new_value = _strip_value_quotes(new_value) new_value = _strip_value_quotes(new_value)
try: try:
set_nested_config(current_config, selection_key, new_value) set_nested_config(current_config, selection_key, new_value)
# For AllDebrid API changes, use the verified save path to ensure _save_updated_config(current_config, selection_key)
# the new API key persisted to disk; otherwise fall back to normal save. print(f"Updated '{selection_display_path}' to '{new_value}'")
try:
key_l = str(selection_key or "").lower()
except Exception:
key_l = ""
if "alldebrid" in key_l or "all-debrid" in key_l:
try:
save_config_and_verify(current_config)
except Exception as exc:
log(f"Configuration save verification failed for '{selection_key}': {exc}")
print(f"Error saving configuration (verification failed): {exc}")
return 1
else:
save_config(current_config)
print(f"Updated '{selection_key}' to '{new_value}'")
return 0 return 0
except Exception as exc: except Exception as exc:
log(f"Error updating config '{selection_key}': {exc}") log(f"Error updating config '{selection_key}': {exc}")
@@ -300,13 +663,16 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int:
key = args[0] key = args[0]
if len(args) < 2: if len(args) < 2:
browse_target = _resolve_direct_browse_path(current_config, key)
if browse_target:
return _show_config_table(current_config, browse_path=browse_target)
print(f"Error: Value required for key '{key}'") print(f"Error: Value required for key '{key}'")
return 1 return 1
value = _strip_value_quotes(" ".join(args[1:])) value = _strip_value_quotes(" ".join(args[1:]))
try: try:
set_nested_config(current_config, key, value) set_nested_config(current_config, key, value)
save_config(current_config) _save_updated_config(current_config, key)
print(f"Updated '{key}' to '{value}'") print(f"Updated '{key}' to '{value}'")
return 0 return 0
except Exception as exc: except Exception as exc:
+5 -5
View File
@@ -92,7 +92,7 @@
"(hitfile\\.net/[a-z0-9A-Z]{4,9})" "(hitfile\\.net/[a-z0-9A-Z]{4,9})"
], ],
"regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))", "regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))",
"status": true "status": false
}, },
"mega": { "mega": {
"name": "mega", "name": "mega",
@@ -463,7 +463,7 @@
"isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12})" "isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12})"
], ],
"regexp": "((isra\\.cloud/[0-9a-zA-Z]{12}))|(isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12}))", "regexp": "((isra\\.cloud/[0-9a-zA-Z]{12}))|(isra\\.cloud/\\?op=report_file&id=([0-9a-zA-Z]{12}))",
"status": true, "status": false,
"hardRedirect": [ "hardRedirect": [
"isra\\.cloud/([0-9a-zA-Z]{12})" "isra\\.cloud/([0-9a-zA-Z]{12})"
] ]
@@ -478,11 +478,11 @@
"katfile.vip" "katfile.vip"
], ],
"regexps": [ "regexps": [
"katfile\\.(cloud|online|vip|ws)/([0-9a-zA-Z]{12})", "katfile\\.(cloud|online|vip|ws|space)/([0-9a-zA-Z]{12})",
"(katfile\\.com/[0-9a-zA-Z]{12})" "(katfile\\.com/[0-9a-zA-Z]{12})"
], ],
"regexp": "(katfile\\.(cloud|online|vip|ws)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))", "regexp": "(katfile\\.(cloud|online|vip|ws|space)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))",
"status": true "status": false
}, },
"mediafire": { "mediafire": {
"name": "mediafire", "name": "mediafire",
+2
View File
@@ -6535,6 +6535,7 @@ mp.register_script_message('medios-load-url-event', function(json)
if not M._reset_uosc_input_state('load-url-submit') then if not M._reset_uosc_input_state('load-url-submit') then
_lua_log('[LOAD-URL] UOSC not loaded, cannot close menu') _lua_log('[LOAD-URL] UOSC not loaded, cannot close menu')
end end
M._schedule_uosc_cursor_resync('load-url-submit')
end end
-- Close the URL prompt immediately once the user submits. Playback may still -- Close the URL prompt immediately once the user submits. Playback may still
@@ -6602,6 +6603,7 @@ mp.register_script_message('medios-load-url-event', function(json)
_lua_log('[LOAD-URL] URL is yt-dlp compatible, prefetching formats in background') _lua_log('[LOAD-URL] URL is yt-dlp compatible, prefetching formats in background')
mp.add_timeout(0.5, function() mp.add_timeout(0.5, function()
_prefetch_formats_for_url(url) _prefetch_formats_for_url(url)
M._schedule_uosc_cursor_resync('file-loaded-web')
end) end)
end end
end) end)
+175 -5
View File
@@ -895,7 +895,7 @@ def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]:
def _extract_title_from_item(item: Dict[str, Any]) -> str: def _extract_title_from_item(item: Dict[str, Any]) -> str:
"""Extract a clean title from an MPV playlist item, handling memory:// M3U hacks.""" """Extract a clean title from an MPV playlist item, handling memory:// M3U hacks."""
title = item.get("title") title = item.get("title")
filename = item.get("filename") or "" filename = item.get("filename") or item.get("playlist-path") or ""
# Special handling for memory:// M3U playlists (used to pass titles via IPC) # Special handling for memory:// M3U playlists (used to pass titles via IPC)
if "memory://" in filename and "#EXTINF:" in filename: if "memory://" in filename and "#EXTINF:" in filename:
@@ -923,6 +923,163 @@ def _extract_title_from_item(item: Dict[str, Any]) -> str:
return title or filename or "Unknown" return title or filename or "Unknown"
def _looks_like_raw_playlist_title(
title: Optional[str],
target: Optional[str],
) -> bool:
text = str(title or "").strip()
if not text or text == "Unknown":
return True
target_text = str(target or "").strip()
if target_text and text == target_text:
return True
lower = text.lower()
if lower.startswith(("http://", "https://", "hydrus://", "file://", "memory://")):
return True
if _WINDOWS_PATH_RE.match(text) or text.startswith("\\\\"):
return True
return False
def _resolve_hydrus_playlist_title(
target: str,
*,
store_name: Optional[str],
file_hash: Optional[str],
config: Optional[Dict[str, Any]],
) -> Optional[str]:
raw_target = str(target or "").strip()
if not raw_target:
return None
resolved_store = str(store_name or "").strip() or None
resolved_hash = str(file_hash or "").strip().lower() or None
looks_hydrus = bool(resolved_store) or bool(
_SHA256_FULL_RE.fullmatch(raw_target.lower())
) or raw_target.lower().startswith("hydrus://") or _is_hydrus_path(raw_target, None)
if not looks_hydrus:
return None
try:
hydrus_plugin = get_plugin("hydrusnetwork", config or {})
except Exception:
hydrus_plugin = None
if hydrus_plugin is None:
return None
try:
parsed_store, parsed_hash = hydrus_plugin.parse_hydrus_url(raw_target)
except Exception:
parsed_store, parsed_hash = None, ""
if not resolved_store and parsed_store:
resolved_store = str(parsed_store).strip() or None
if not resolved_hash and parsed_hash:
resolved_hash = str(parsed_hash).strip().lower() or None
if not resolved_store:
try:
inferred_store = hydrus_plugin.infer_playlist_store(
None,
target=raw_target,
file_storage=None,
)
except Exception:
inferred_store = None
if inferred_store:
resolved_store = str(inferred_store).strip() or None
if not resolved_hash:
try:
hashes = hydrus_plugin.find_hashes_by_url(
raw_target,
store_name=resolved_store,
)
except TypeError:
try:
hashes = hydrus_plugin.find_hashes_by_url(raw_target)
except Exception:
hashes = []
except Exception:
hashes = []
if isinstance(hashes, list) and hashes:
resolved_hash = str(hashes[0] or "").strip().lower() or None
if not resolved_hash:
return None
try:
resolved_title = hydrus_plugin.get_title(
resolved_hash,
store_name=resolved_store,
)
except Exception:
resolved_title = ""
title_text = str(resolved_title or "").strip()
if not title_text:
return None
if title_text.lower() in {resolved_hash, resolved_hash[:16] + "..."}:
return None
return title_text
def _resolve_playlist_display_title(
item: Dict[str, Any],
*,
config: Optional[Dict[str, Any]] = None,
file_storage: Optional[Any] = None,
store_name: Optional[str] = None,
file_hash: Optional[str] = None,
title_cache: Optional[Dict[tuple[str, str, str], Optional[str]]] = None,
) -> str:
title = _extract_title_from_item(item)
filename = item.get("filename") or item.get("playlist-path") or ""
real_path = _extract_target_from_memory_uri(filename) or filename
if not _looks_like_raw_playlist_title(title, real_path):
return title
resolved_store = str(store_name or "").strip() or None
resolved_hash = str(file_hash or "").strip().lower() or None
if not resolved_store or not resolved_hash:
extracted_store, extracted_hash = _extract_store_and_hash(
{
"store": resolved_store,
"hash": resolved_hash,
"path": real_path,
"filename": filename,
"title": title,
},
config=config,
)
if not resolved_store and extracted_store:
resolved_store = str(extracted_store).strip() or None
if not resolved_hash and extracted_hash:
resolved_hash = str(extracted_hash).strip().lower() or None
cache_key = (
str(real_path or "").strip().lower(),
str(resolved_store or "").strip().lower(),
str(resolved_hash or "").strip().lower(),
)
if title_cache is not None and cache_key in title_cache:
cached_title = title_cache[cache_key]
return cached_title or title
resolved_title = _resolve_hydrus_playlist_title(
real_path,
store_name=resolved_store,
file_hash=resolved_hash,
config=config,
)
if title_cache is not None:
title_cache[cache_key] = resolved_title
return resolved_title or title
def _extract_target_from_memory_uri(text: str) -> Optional[str]: def _extract_target_from_memory_uri(text: str) -> Optional[str]:
"""Extract the real target URL/path from a memory:// M3U payload.""" """Extract the real target URL/path from a memory:// M3U payload."""
if not isinstance(text, str) or not text.startswith("memory://"): if not isinstance(text, str) or not text.startswith("memory://"):
@@ -1927,7 +2084,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1 return 1
# Build result object with file info # Build result object with file info
title = _extract_title_from_item(current_item) title = _resolve_playlist_display_title(current_item, config=config)
filename = current_item.get("filename", "") filename = current_item.get("filename", "")
# Emit the current item to pipeline # Emit the current item to pipeline
@@ -2340,7 +2497,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1 return 1
item = items[idx] item = items[idx]
title = _extract_title_from_item(item) title = _resolve_playlist_display_title(
item,
config=config,
file_storage=file_storage,
)
filename = item.get("filename", "") if isinstance(item, dict) else "" filename = item.get("filename", "") if isinstance(item, dict) else ""
hydrus_header = _build_hydrus_header(config or {}) hydrus_header = _build_hydrus_header(config or {})
hydrus_url = None hydrus_url = None
@@ -2446,9 +2607,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Convert MPV items to PipeObjects with proper hash and store # Convert MPV items to PipeObjects with proper hash and store
pipe_objects = [] pipe_objects = []
title_cache: Dict[tuple[str, str, str], Optional[str]] = {}
for i, item in enumerate(items): for i, item in enumerate(items):
is_current = item.get("current", False) is_current = item.get("current", False)
title = _extract_title_from_item(item)
filename = item.get("filename", "") filename = item.get("filename", "")
# Extract the real path/URL from memory:// wrapper if present # Extract the real path/URL from memory:// wrapper if present
@@ -2458,7 +2619,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
store_name, file_hash = _extract_store_and_hash( store_name, file_hash = _extract_store_and_hash(
{ {
"path": real_path, "path": real_path,
"title": title, "filename": filename,
}, },
config=config, config=config,
) )
@@ -2480,6 +2641,15 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
config=config, config=config,
) )
title = _resolve_playlist_display_title(
item,
config=config,
file_storage=file_storage,
store_name=store_name,
file_hash=file_hash,
title_cache=title_cache,
)
# Build PipeObject with proper metadata # Build PipeObject with proper metadata
pipe_obj = PipeObject( pipe_obj = PipeObject(
hash=file_hash or "unknown", hash=file_hash or "unknown",
+76 -18
View File
@@ -544,6 +544,81 @@ class ytdlp(TableProviderMixin, Provider):
} }
AUTO_STAGE_USE_SELECTION_ARGS = True AUTO_STAGE_USE_SELECTION_ARGS = True
@staticmethod
def _playlist_entry_to_url(entry: Any, *, extractor_name: str) -> Optional[str]:
if not isinstance(entry, dict):
return None
for key in ("webpage_url", "original_url", "url"):
value = entry.get(key)
if isinstance(value, str) and value.strip():
cleaned = value.strip()
try:
if urlparse(cleaned).scheme in {"http", "https"}:
return cleaned
except Exception:
return cleaned
entry_id = entry.get("id")
if isinstance(entry_id, str) and entry_id.strip() and "youtube" in extractor_name:
return f"https://www.youtube.com/watch?v={entry_id.strip()}"
return None
def resolve_preflight_items(self, url: str, **kwargs: Any) -> Optional[List[Dict[str, Any]]]:
url_str = str(url or "").strip()
if not url_str or not is_url_supported_by_ytdlp(url_str):
return None
parsed = kwargs.get("parsed") if isinstance(kwargs.get("parsed"), dict) else {}
query_spec = parsed.get("query")
query_keyed = _parse_query_keyed_spec(str(query_spec) if query_spec is not None else None)
playlist_items = str(parsed.get("item")) if parsed.get("item") else None
item_values: List[str] = []
if isinstance(query_keyed, dict):
item_values.extend(query_keyed.get("item", []) or [])
if item_values and not playlist_items:
playlist_items = ",".join([value for value in item_values if value])
ytdlp_tool = YtDlpTool(self.config)
try:
probe = probe_url(
url_str,
no_playlist=False,
playlist_items=playlist_items,
timeout_seconds=15,
cookiefile=_cookiefile_str(ytdlp_tool),
)
except Exception:
probe = None
if not isinstance(probe, dict):
return None
entries = probe.get("entries")
if not isinstance(entries, list) or not entries:
return None
extractor_name = str(probe.get("extractor") or probe.get("extractor_key") or "").strip().lower()
items: List[Dict[str, Any]] = []
for idx, entry in enumerate(entries, 1):
entry_url = self._playlist_entry_to_url(entry, extractor_name=extractor_name)
if not entry_url:
continue
playlist_index = None
if isinstance(entry, dict):
playlist_index = entry.get("playlist_index")
try:
playlist_index_value = int(playlist_index)
except Exception:
playlist_index_value = idx
items.append(
{
"url": entry_url,
"playlist_index": playlist_index_value,
}
)
return items or None
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]: def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
normalized_query, inline_args = parse_inline_query_arguments(query) normalized_query, inline_args = parse_inline_query_arguments(query)
search_parts: List[str] = [] search_parts: List[str] = []
@@ -745,23 +820,6 @@ class ytdlp(TableProviderMixin, Provider):
elif "youtube" in extractor_name: elif "youtube" in extractor_name:
table_type = "youtube" table_type = "youtube"
def _entry_to_url(entry: Any) -> Optional[str]:
if not isinstance(entry, dict):
return None
for key in ("webpage_url", "original_url", "url"):
value = entry.get(key)
if isinstance(value, str) and value.strip():
cleaned = value.strip()
try:
if urlparse(cleaned).scheme in {"http", "https"}:
return cleaned
except Exception:
return cleaned
entry_id = entry.get("id")
if isinstance(entry_id, str) and entry_id.strip() and "youtube" in extractor_name:
return f"https://www.youtube.com/watch?v={entry_id.strip()}"
return None
table = Table(preserve_order=True) table = Table(preserve_order=True)
safe_url = str(url or "").strip() safe_url = str(url or "").strip()
table.title = f'download-file -url "{safe_url}"' if safe_url else "download-file" table.title = f'download-file -url "{safe_url}"' if safe_url else "download-file"
@@ -781,7 +839,7 @@ class ytdlp(TableProviderMixin, Provider):
title = entry.get("title") if isinstance(entry, dict) else None title = entry.get("title") if isinstance(entry, dict) else None
uploader = entry.get("uploader") if isinstance(entry, dict) else None uploader = entry.get("uploader") if isinstance(entry, dict) else None
duration = entry.get("duration") if isinstance(entry, dict) else None duration = entry.get("duration") if isinstance(entry, dict) else None
entry_url = _entry_to_url(entry) entry_url = self._playlist_entry_to_url(entry, extractor_name=extractor_name)
row = build_table_result_payload( row = build_table_result_payload(
table="download-file", table="download-file",
title=str(title or f"Item {idx}"), title=str(title or f"Item {idx}"),
+8 -1
View File
@@ -520,6 +520,7 @@ def probe_url(
timeout_seconds: int = 15, timeout_seconds: int = 15,
*, *,
cookiefile: Optional[str] = None, cookiefile: Optional[str] = None,
playlist_items: Optional[str] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Probe URL metadata without downloading. """Probe URL metadata without downloading.
@@ -529,8 +530,12 @@ def probe_url(
if not is_url_supported_by_ytdlp(url): if not is_url_supported_by_ytdlp(url):
return None return None
playlist_items_key = str(playlist_items or "").strip() or None
# Simple in-memory cache to avoid duplicate probes for the same URL/options in a short window. # Simple in-memory cache to avoid duplicate probes for the same URL/options in a short window.
cache_key = hashlib.md5(f"{url}|{no_playlist}|{cookiefile}".encode()).hexdigest() cache_key = hashlib.md5(
f"{url}|{no_playlist}|{cookiefile}|{playlist_items_key or ''}".encode()
).hexdigest()
now = time.monotonic() now = time.monotonic()
if cache_key in _PROBE_CACHE: if cache_key in _PROBE_CACHE:
ts, result = _PROBE_CACHE[cache_key] ts, result = _PROBE_CACHE[cache_key]
@@ -562,6 +567,8 @@ def probe_url(
if no_playlist: if no_playlist:
ydl_opts["noplaylist"] = True ydl_opts["noplaylist"] = True
elif playlist_items_key:
ydl_opts["playlist_items"] = playlist_items_key
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type] with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type]
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)