update
This commit is contained in:
+51
-12
@@ -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()
|
||||||
|
|||||||
+872
-120
File diff suppressed because it is too large
Load Diff
+15
-10
@@ -553,13 +553,26 @@ 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:
|
||||||
return 1
|
if not raw_tag and results and query_value:
|
||||||
|
raw_tag = [str(query_value)]
|
||||||
|
query_hash = None
|
||||||
|
else:
|
||||||
|
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".
|
||||||
|
|||||||
@@ -462,13 +462,23 @@ 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:
|
||||||
return 1
|
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
|
||||||
|
|
||||||
# Selection syntax (@...) is handled by the pipeline runner, not by this cmdlet.
|
# Selection syntax (@...) is handled by the pipeline runner, not by this cmdlet.
|
||||||
# If @ reaches here as a literal argument, it's almost certainly user error.
|
# If @ reaches here as a literal argument, it's almost certainly user error.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+431
-65
@@ -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")
|
||||||
table.set_source_command(".config", [])
|
if browse_path:
|
||||||
|
table.set_source_command(".config", ["-browse", str(browse_path)])
|
||||||
|
else:
|
||||||
|
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,41 +625,33 @@ 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
|
|
||||||
new_value = _strip_value_quotes(new_value)
|
if selection_kind == "section" and selection_browse_path and not args and value_from_pipe is None:
|
||||||
try:
|
return _show_config_table(current_config, browse_path=selection_browse_path)
|
||||||
set_nested_config(current_config, selection_key, new_value)
|
|
||||||
# For AllDebrid API changes, use the verified save path to ensure
|
if selection_kind == "value" and selection_key:
|
||||||
# the new API key persisted to disk; otherwise fall back to normal save.
|
new_value = value_from_pipe or _extract_selected_update_value(args)
|
||||||
|
if new_value is not None:
|
||||||
|
new_value = _strip_value_quotes(new_value)
|
||||||
try:
|
try:
|
||||||
key_l = str(selection_key or "").lower()
|
set_nested_config(current_config, selection_key, new_value)
|
||||||
except Exception:
|
_save_updated_config(current_config, selection_key)
|
||||||
key_l = ""
|
print(f"Updated '{selection_display_path}' to '{new_value}'")
|
||||||
if "alldebrid" in key_l or "all-debrid" in key_l:
|
return 0
|
||||||
try:
|
except Exception as exc:
|
||||||
save_config_and_verify(current_config)
|
log(f"Error updating config '{selection_key}': {exc}")
|
||||||
except Exception as exc:
|
print(f"Error updating config: {exc}")
|
||||||
log(f"Configuration save verification failed for '{selection_key}': {exc}")
|
return 1
|
||||||
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
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"Error updating config '{selection_key}': {exc}")
|
|
||||||
print(f"Error updating config: {exc}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not args:
|
if not args:
|
||||||
if sys.stdin.isatty() and not piped_result:
|
if sys.stdin.isatty() and not piped_result:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user