cmdlet refactor

This commit is contained in:
2026-05-04 18:41:01 -07:00
parent 3ce339b3c1
commit 24f983473f
44 changed files with 1320 additions and 309 deletions
+98 -29
View File
@@ -725,8 +725,29 @@ class CmdletCompleter(Completer):
return None
@staticmethod
def _selected_plugin_name(cmd_name: str, stage_tokens: Sequence[str]) -> Optional[str]:
def _effective_cmd_name(cmd_name: str, stage_tokens: Sequence[str]) -> str:
canonical_cmd = str(cmd_name or "").replace("_", "-").strip().lower()
if canonical_cmd != "file":
return canonical_cmd
lowered = {str(tok or "").strip().lower() for tok in (stage_tokens or [])}
if "-search" in lowered or "--search" in lowered:
return "search-file"
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
if "-add" in lowered or "--add" in lowered:
return "add-file"
if "-get" in lowered or "--get" in lowered:
return "get-file"
if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered:
return "delete-file"
if "-merge" in lowered or "--merge" in lowered:
return "merge-file"
return canonical_cmd
@staticmethod
def _selected_plugin_name(cmd_name: str, stage_tokens: Sequence[str]) -> Optional[str]:
canonical_cmd = CmdletCompleter._effective_cmd_name(cmd_name, stage_tokens)
if canonical_cmd not in {"search-file", "add-file", "download-file"}:
return None
return CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin")
@@ -778,7 +799,7 @@ class CmdletCompleter(Completer):
if not arg_names:
return []
canonical_cmd = str(cmd_name or "").replace("_", "-").strip().lower()
canonical_cmd = self._effective_cmd_name(cmd_name, stage_tokens)
plugin_name = self._selected_plugin_name(canonical_cmd, stage_tokens)
instance_choices = self._plugin_instance_choices(plugin_name, config)
has_named_instances = bool(instance_choices)
@@ -862,6 +883,7 @@ class CmdletCompleter(Completer):
return
cmd_name = stage_tokens[0].replace("_", "-").lower()
effective_cmd = self._effective_cmd_name(cmd_name, stage_tokens)
if ends_with_space:
current_token = ""
prev_token = stage_tokens[-1].lower()
@@ -872,12 +894,12 @@ class CmdletCompleter(Completer):
config = self._config_loader.load_shared()
provider_name = None
if cmd_name == "search-file":
if effective_cmd == "search-file":
provider_name = self._flag_value(stage_tokens, "-plugin", "--plugin")
selected_plugin = self._selected_plugin_name(cmd_name, stage_tokens)
selected_plugin = self._selected_plugin_name(effective_cmd, stage_tokens)
query_specs = self._query_args(cmd_name, config)
query_specs = self._query_args(effective_cmd, config)
query_flag_index = -1
for idx, tok in enumerate(stage_tokens):
if str(tok or "").strip().lower() in {"-query", "--query"}:
@@ -923,7 +945,7 @@ class CmdletCompleter(Completer):
partial_lower = partial.strip().lower()
inline_choices = []
if cmd_name == "search-file" and provider_name:
if effective_cmd == "search-file" and provider_name:
inline_choices = self._inline_query_choices(provider_name, field, config)
choice_pool = inline_choices or field_choices.get(field, [])
@@ -948,7 +970,7 @@ class CmdletCompleter(Completer):
return
if (
cmd_name == "search-file"
effective_cmd == "search-file"
and provider_name
and not ends_with_space
and ":" in current_token
@@ -990,7 +1012,7 @@ class CmdletCompleter(Completer):
choices = self._plugin_instance_choices(selected_plugin, config)
if not choices:
choices = self._arg_choices(
cmd_name=cmd_name,
cmd_name=effective_cmd,
arg_name=prev_token,
config=config,
force=False,
@@ -1016,12 +1038,12 @@ class CmdletCompleter(Completer):
return
arg_names = self._filter_stage_arg_names(
cmd_name=cmd_name,
cmd_name=effective_cmd,
stage_tokens=stage_tokens,
config=config,
arg_names=self._cmdlet_args(cmd_name, config),
arg_names=self._cmdlet_args(effective_cmd, config),
)
used_logicals = self._used_arg_logicals(cmd_name, stage_tokens, config)
used_logicals = self._used_arg_logicals(effective_cmd, stage_tokens, config)
logical_seen: Set[str] = set()
for arg in arg_names:
arg_low = arg.lower()
@@ -1228,6 +1250,26 @@ class CmdletExecutor:
emitted_items: Optional[List[Any]] = None,
cmd_args: Optional[List[str]] = None,
) -> str:
def _file_action(args: Optional[List[str]]) -> str | None:
tokens = [str(t or "").strip().lower() for t in (args or [])]
token_set = set(tokens)
if "-search" in token_set or "--search" in token_set:
return "search-file"
if "-download" in token_set or "--download" in token_set or "-dl" in token_set or "--dl" in token_set:
return "download-file"
if "-get" in token_set or "--get" in token_set:
return "get-file"
if "-add" in token_set or "--add" in token_set:
return "add-file"
if "-delete" in token_set or "--delete" in token_set or "-del" in token_set or "--del" in token_set:
return "delete-file"
if "-merge" in token_set or "--merge" in token_set:
return "merge-file"
return None
normalized_cmd = str(cmd_name or "").replace("_", "-").lower().strip()
mapped_cmd = _file_action(cmd_args) if normalized_cmd == "file" else normalized_cmd
title_map = {
"search-file": "Results",
"search_file": "Results",
@@ -1235,14 +1277,9 @@ class CmdletExecutor:
"download_data": "Downloads",
"download-file": "Downloads",
"download_file": "Downloads",
"get-tag": "Tags",
"get_tag": "Tags",
"metadata": "Tags",
"get-file": "Results",
"get_file": "Results",
"add-tags": "Results",
"add_tags": "Results",
"delete-tag": "Results",
"delete_tag": "Results",
"add-url": "Results",
"add_url": "Results",
"get-url": "url",
@@ -1266,7 +1303,7 @@ class CmdletExecutor:
"get-metadata": None,
"get_metadata": None,
}
mapped = title_map.get(cmd_name, "Results")
mapped = title_map.get(mapped_cmd or normalized_cmd, "Results")
if mapped is not None:
return mapped
@@ -1358,10 +1395,25 @@ class CmdletExecutor:
) -> None:
nonlocal progress_ui, pipe_idx
def _effective_file_cmd(name: str, args: List[str]) -> str:
norm = str(name or "").replace("_", "-").strip().lower()
if norm != "file":
return norm
lowered = {str(a or "").strip().lower() for a in (args or [])}
if "-add" in lowered or "--add" in lowered:
return "add-file"
if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered:
return "delete-file"
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
return norm
effective_cmd = _effective_file_cmd(cmd_name_norm, filtered_args)
# Keep behavior consistent with pipeline runner exclusions.
# Some commands render their own Rich UI (tables/panels) and don't
# play nicely with Live cursor control.
if cmd_name_norm in {
if effective_cmd in {
"get-relationship",
"get-rel",
".pipe",
@@ -1375,8 +1427,7 @@ class CmdletExecutor:
return
# add-file directory selector mode: show only the selection table, no Live progress.
if cmd_name_norm in {"add-file",
"add_file"}:
if effective_cmd in {"add-file", "add_file"}:
try:
from pathlib import Path as _Path
@@ -1457,8 +1508,7 @@ class CmdletExecutor:
while i < len(toks):
t = str(toks[i])
low = t.lower().strip()
if (cmd_name_norm in {"add-file",
"add_file"} and low in {"-path",
if (effective_cmd in {"add-file", "add_file"} and low in {"-path",
"--path",
"-p"}
and i + 1 < len(toks)):
@@ -1706,6 +1756,25 @@ class CmdletExecutor:
filtered_args
)
def _effective_cmd_name(name: str, args: List[str]) -> str:
norm = str(name or "").replace("_", "-").strip().lower()
if norm != "file":
return norm
lowered = {str(a or "").strip().lower() for a in (args or [])}
if "-search" in lowered or "--search" in lowered:
return "search-file"
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
if "-get" in lowered or "--get" in lowered:
return "get-file"
if "-add" in lowered or "--add" in lowered:
return "add-file"
if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered:
return "delete-file"
return norm
effective_cmd = _effective_cmd_name(cmd_name, filtered_args)
selectable_commands = {
"search-file",
"download-data",
@@ -1729,8 +1798,7 @@ class CmdletExecutor:
"get_metadata",
}
self_managing_commands = {
"get-tag",
"get_tag",
"tag",
"tags",
"get-metadata",
"get_metadata",
@@ -1740,9 +1808,10 @@ class CmdletExecutor:
"search_file",
"add-file",
"add_file",
"file",
}
if cmd_name in self_managing_commands:
if effective_cmd in self_managing_commands or cmd_name in self_managing_commands:
table = (
ctx.get_display_table()
if hasattr(ctx, "get_display_table") else None
@@ -1758,11 +1827,11 @@ class CmdletExecutor:
for emitted in emits:
table.add_result(emitted)
if cmd_name in selectable_commands:
table.set_source_command(cmd_name, filtered_args)
if effective_cmd in selectable_commands:
table.set_source_command(effective_cmd, filtered_args)
ctx.set_last_result_table(table, emits)
ctx.set_current_stage_table(None)
elif cmd_name in display_only_commands:
elif effective_cmd in display_only_commands or cmd_name in display_only_commands:
ctx.set_last_result_items_only(emits)
else:
ctx.set_last_result_items_only(emits)
+15
View File
@@ -636,6 +636,21 @@ class Provider(ABC):
"""True if tag writes should be deferred until after file ingest."""
return False
@property
def supports_url_association(self) -> bool:
"""True when this provider supports associating URLs to files."""
return False
@property
def supports_note_association(self) -> bool:
"""True when this provider supports per-file named notes."""
return False
@property
def supports_relationship_association(self) -> bool:
"""True when this provider supports file relationship links (king/alt/related)."""
return False
def add_file(self, file_path: Path, **kwargs: Any) -> str:
"""Ingest a file and return its canonical hash."""
raise NotImplementedError(f"Plugin '{self.name}' does not support add_file")
+16 -1
View File
@@ -471,7 +471,7 @@ class PluginRegistry:
if not info.is_multi_instance:
continue
if not info.supported_cmdlets.intersection(
{"add-file", "get-file", "get-tag", "add-tag"}
{"add-file", "get-file", "tag"}
):
continue
try:
@@ -544,6 +544,9 @@ def get_plugin_capabilities(
"supports_upload": False,
"supports_pipe_download": False,
"supports_delete_file": False,
"supports_url_association": False,
"supports_note_association": False,
"supports_relationship_association": False,
"is_multi_instance": False,
"configured_instances": [],
}
@@ -559,11 +562,20 @@ def get_plugin_capabilities(
supports_delete_file = callable(delete_method) and delete_method is not base_delete_method
configured_instances: List[str] = []
supports_url_association = False
supports_note_association = False
supports_relationship_association = False
try:
plugin_obj = info.plugin_class(config or {})
configured_instances = [str(v) for v in (plugin_obj.configured_instances() or []) if str(v).strip()]
supports_url_association = bool(getattr(plugin_obj, "supports_url_association", False))
supports_note_association = bool(getattr(plugin_obj, "supports_note_association", False))
supports_relationship_association = bool(getattr(plugin_obj, "supports_relationship_association", False))
except Exception:
configured_instances = []
supports_url_association = False
supports_note_association = False
supports_relationship_association = False
return {
"name": info.canonical_name,
@@ -572,6 +584,9 @@ def get_plugin_capabilities(
"supports_upload": bool(info.supports_upload),
"supports_pipe_download": bool(supports_pipe_download),
"supports_delete_file": bool(supports_delete_file),
"supports_url_association": bool(supports_url_association),
"supports_note_association": bool(supports_note_association),
"supports_relationship_association": bool(supports_relationship_association),
"is_multi_instance": bool(info.is_multi_instance),
"configured_instances": configured_instances,
}
+79 -36
View File
@@ -1282,15 +1282,30 @@ class PipelineExecutor:
def _norm(name: str) -> str:
return str(name or "").replace("_", "-").strip().lower()
def _file_action(stage_tokens: List[str]) -> str | None:
if not stage_tokens:
return None
head = _norm(stage_tokens[0])
if head in {"download-file", "add-file"}:
return head
if head != "file":
return None
args = {_norm(t) for t in stage_tokens[1:]}
if "-download" in args or "--download" in args or "-dl" in args or "--dl" in args:
return "download-file"
if "-add" in args or "--add" in args:
return "add-file"
return None
names: List[str] = []
for stage in stages or []:
if not stage:
continue
names.append(_norm(stage[0]))
dl_idxs = [i for i, n in enumerate(names) if n == "download-file"]
dl_idxs = [i for i, stage in enumerate(stages or []) if _file_action(stage or []) == "download-file"]
rel_idxs = [i for i, n in enumerate(names) if n == "add-relationship"]
add_file_idxs = [i for i, n in enumerate(names) if n == "add-file"]
add_file_idxs = [i for i, stage in enumerate(stages or []) if _file_action(stage or []) == "add-file"]
if not dl_idxs or not rel_idxs:
return True
@@ -1981,6 +1996,23 @@ class PipelineExecutor:
def _norm_cmd_name(value: Any) -> str:
return str(value or "").replace("_", "-").strip().lower()
def _stage_file_action(stage_tokens: Sequence[Any]) -> str | None:
if not stage_tokens:
return None
head = _norm_cmd_name(stage_tokens[0])
if head in {"add-file", "download-file", "delete-file"}:
return head
if head != "file":
return None
args = {_norm_cmd_name(t) for t in stage_tokens[1:]}
if "-add" in args or "--add" in args:
return "add-file"
if "-download" in args or "--download" in args or "-dl" in args or "--dl" in args:
return "download-file"
if "-delete" in args or "--delete" in args or "-del" in args or "--del" in args:
return "delete-file"
return None
# ============================================================================
# PHASE 2: Parse source command and table metadata
# ============================================================================
@@ -2333,8 +2365,8 @@ class PipelineExecutor:
# ====================================================================
if not stages:
if isinstance(table_type, str) and table_type.startswith("metadata."):
print("Auto-applying metadata selection via get-tag")
stages.append(["get-tag"])
print("Auto-applying metadata selection via metadata -get")
stages.append(["metadata", "-get"])
elif auto_stage:
try:
print(f"Auto-running selection via {auto_stage[0]}")
@@ -2383,12 +2415,12 @@ class PipelineExecutor:
first_cmd_norm = _norm_cmd_name(first_cmd)
inserted_provider_download = False
if first_cmd_norm == "add-file":
if _stage_file_action(stages[0]) == "add-file":
# If selected rows advertise an explicit download-file action,
# run download before add-file so add-file receives local files.
if len(selection_indices) == 1:
row_action = _get_row_action(selection_indices[0], items_list)
if row_action and _norm_cmd_name(row_action[0]) == "download-file":
if row_action and _stage_file_action(row_action) == "download-file":
stages.insert(0, [str(x) for x in row_action if x is not None])
inserted_provider_download = True
debug("Auto-inserting row download-file action before add-file")
@@ -2401,24 +2433,24 @@ class PipelineExecutor:
has_download_row_action = False
for idx in selection_indices:
row_action = _get_row_action(idx, items_list)
if row_action and _norm_cmd_name(row_action[0]) == "download-file":
if row_action and _stage_file_action(row_action) == "download-file":
has_download_row_action = True
break
if has_download_row_action:
stages.insert(0, ["download-file"])
stages.insert(0, ["file", "-download"])
inserted_provider_download = True
debug("Auto-inserting download-file before add-file for provider selection")
except Exception:
pass
if isinstance(table_type, str) and table_type.startswith("metadata.") and first_cmd not in (
"get-tag",
"get_tag",
"metadata",
"tag",
".pipe",
".mpv",
):
print("Auto-inserting get-tag after metadata selection")
stages.insert(0, ["get-tag"])
print("Auto-inserting metadata -get after metadata selection")
stages.insert(0, ["metadata", "-get"])
elif auto_stage:
first_cmd_norm = _norm_cmd_name(stages[0][0] if stages and stages[0] else None)
auto_cmd_norm = _norm_cmd_name(auto_stage[0])
@@ -2513,8 +2545,7 @@ class PipelineExecutor:
# add-file directory selector stage: avoid Live progress so the
# selection table renders cleanly.
if name in {"add-file",
"add_file"}:
if _stage_file_action(stage_tokens) == "add-file" or name in {"add_file"}:
try:
from pathlib import Path as _Path
@@ -2559,8 +2590,7 @@ class PipelineExecutor:
continue
# `delete-file` prints a Rich table directly; Live progress interferes and
# can truncate/overwrite the output.
if name in {"delete-file",
"del-file"}:
if _stage_file_action(stage_tokens) == "delete-file" or name in {"del-file"}:
continue
pipe_stage_indices.append(idx)
pipe_labels.append(name)
@@ -2707,7 +2737,7 @@ class PipelineExecutor:
stage_args = stage_tokens[1:]
if cmd_name == "@":
# Special-case get-tag tables: `@ | add-tag ...` should target the
# Special-case metadata -get tables: `@ | metadata -add ...` should target the
# underlying file subject once, not each emitted TagItem row.
try:
next_cmd = None
@@ -2721,28 +2751,36 @@ class PipelineExecutor:
current_table = None
source_cmd = str(getattr(current_table, "source_command", "") or "").replace("_", "-").strip().lower()
is_get_tag_table = source_cmd == "get-tag"
is_get_tag_table = source_cmd == "metadata"
if is_get_tag_table and next_cmd in {"add-tag"}:
if is_get_tag_table and next_cmd == "metadata":
subject = ctx.get_last_result_subject()
if subject is not None:
piped_result = subject
try:
subject_items = subject if isinstance(subject, list) else [subject]
ctx.set_last_items(subject_items)
except Exception:
logger.exception("Failed to set last_items from get-tag subject during @ handling")
if pipeline_session and worker_manager:
next_args = [
str(x).replace("_", "-").strip().lower()
for x in (stages[stage_index + 1][1:] if stage_index + 1 < len(stages) else [])
]
if "-add" not in next_args and "--add" not in next_args:
# Only apply this flattening for explicit tag-add pipelines.
pass
else:
piped_result = subject
try:
worker_manager.log_step(
pipeline_session.worker_id,
"@ used get-tag table subject for add-tag"
)
subject_items = subject if isinstance(subject, list) else [subject]
ctx.set_last_items(subject_items)
except Exception:
logger.exception("Failed to record pipeline log step for '@ used get-tag table subject for add-tag' (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None))
continue
logger.exception("Failed to set last_items from tag subject during @ handling")
if pipeline_session and worker_manager:
try:
worker_manager.log_step(
pipeline_session.worker_id,
"@ used metadata table subject for metadata -add"
)
except Exception:
logger.exception("Failed to record pipeline log step for '@ used metadata table subject for metadata -add' (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None))
continue
except Exception:
logger.exception("Failed to evaluate get-tag @ subject special-case")
logger.exception("Failed to evaluate tag @ subject special-case")
# Prefer piping the last emitted/visible items (e.g. add-file results)
# over the result-table subject. The subject can refer to older context
@@ -2962,17 +3000,23 @@ class PipelineExecutor:
stage_is_last=(stage_index + 1 >= len(stages))):
return
# Special case: selecting multiple tags from get-tag and piping into delete-tag
# Special case: selecting multiple metadata tag rows and piping into metadata -delete
# should batch into a single operation (one backend call).
next_cmd = None
next_args: List[str] = []
try:
if stage_index + 1 < len(stages) and stages[stage_index + 1]:
next_cmd = str(stages[stage_index + 1][0]
).replace("_",
"-").lower()
next_args = [
str(x).replace("_", "-").strip().lower()
for x in stages[stage_index + 1][1:]
]
except Exception:
logger.exception("Failed to determine next_cmd during selection expansion for stage_index %s", stage_index)
next_cmd = None
next_args = []
def _is_tag_row(obj: Any) -> bool:
try:
@@ -2991,8 +3035,7 @@ class PipelineExecutor:
logger.exception("Failed to inspect dict tag_name while checking _is_tag_row")
return False
if (next_cmd in {"delete-tag",
"delete_tag"} and len(filtered) > 1
if (next_cmd == "tag" and ("-delete" in next_args or "--delete" in next_args) and len(filtered) > 1
and all(_is_tag_row(x) for x in filtered)):
from SYS.field_access import get_field
+15
View File
@@ -40,6 +40,21 @@ class Store(ABC):
"""True if the store prefers tags to be applied after the file is added."""
return False
@property
def supports_url_association(self) -> bool:
"""True when this store supports associating URLs to files."""
return False
@property
def supports_note_association(self) -> bool:
"""True when this store supports per-file named notes."""
return False
@property
def supports_relationship_association(self) -> bool:
"""True when this store supports file relationship links (king/alt/related)."""
return False
@abstractmethod
def add_file(self, file_path: Path, **kwargs: Any) -> str:
raise NotImplementedError
+32 -33
View File
@@ -224,7 +224,7 @@ class TagEditorPopup(ModalScreen[None]):
if not tags:
try:
runner: PipelineRunner = getattr(app, "executor")
cmd = "@1 | get-tag"
cmd = "@1 | metadata -get"
res = runner.run_pipeline(cmd, seeds=self._seeds, isolate=True)
tags = _extract_tag_names_from_table(getattr(res, "result_table", None))
if not tags:
@@ -364,10 +364,10 @@ class TagEditorPopup(ModalScreen[None]):
if to_del:
del_args = " ".join(json.dumps(t) for t in to_del)
del_cmd = f"delete-tag -instance {store_tok}{query_chunk} {del_args}"
_log_pipeline_command("delete-tag", del_cmd)
del_cmd = f"metadata -delete -instance {store_tok}{query_chunk} {del_args}"
_log_pipeline_command("metadata-delete", del_cmd)
del_res = runner.run_pipeline(del_cmd, seeds=self._seeds, isolate=True)
_log_pipeline_result("delete-tag", del_res)
_log_pipeline_result("metadata-delete", del_res)
if not getattr(del_res, "success", False):
failures.append(
str(
@@ -375,16 +375,16 @@ class TagEditorPopup(ModalScreen[None]):
"error",
"") or getattr(del_res,
"stderr",
"") or "delete-tag failed"
"") or "metadata -delete failed"
).strip()
)
if to_add:
add_args = " ".join(json.dumps(t) for t in to_add)
add_cmd = f"add-tag -instance {store_tok}{query_chunk} {add_args}"
_log_pipeline_command("add-tag", add_cmd)
add_cmd = f"metadata -add -instance {store_tok}{query_chunk} {add_args}"
_log_pipeline_command("metadata-add", add_cmd)
add_res = runner.run_pipeline(add_cmd, seeds=self._seeds, isolate=True)
_log_pipeline_result("add-tag", add_res)
_log_pipeline_result("metadata-add", add_res)
if not getattr(add_res, "success", False):
failures.append(
str(
@@ -392,7 +392,7 @@ class TagEditorPopup(ModalScreen[None]):
"error",
"") or getattr(add_res,
"stderr",
"") or "add-tag failed"
"") or "metadata -add failed"
).strip()
)
@@ -1027,8 +1027,8 @@ class PipelineHubApp(App):
"""Apply store/path/tags UI fields to the pipeline text.
Rules (simple + non-destructive):
- If output path is set and the first stage is download-file and has no -path/--path, append -path.
- If an instance is selected and pipeline has no add-file stage, append add-file -instance <name>.
- If output path is set and the first stage is file-download and has no -path/--path, append -path.
- If an instance is selected and pipeline has no file-add stage, append file -add -instance <name>.
"""
base = str(pipeline_text or "").strip()
if not base:
@@ -1058,29 +1058,28 @@ class PipelineHubApp(App):
except Exception:
first_stage_cmd = ""
# Apply -path to download-file first stage (only if missing)
# Apply -path to file-download first stage (only if missing)
if output_path:
first = stages[0]
low = first.lower()
if low.startswith("download-file"
) and " -path" not in low and " --path" not in low:
if (low.startswith("download-file") or (low.startswith("file ") and " -download" in low)) and " -path" not in low and " --path" not in low:
stages[0] = f"{first} -path {json.dumps(output_path)}"
joined = " | ".join(stages)
low_joined = joined.lower()
# Only auto-append add-file for download pipelines.
# Only auto-append file -add for download pipelines.
should_auto_add_file = bool(
selected_store and ("add-file" not in low_joined) and (
first_stage_cmd
in {"download-file"}
selected_store and ("add-file" not in low_joined and "file -add" not in low_joined) and (
first_stage_cmd in {"download-file"}
or (first_stage_cmd == "file" and " -download" in stages[0].lower())
)
)
if should_auto_add_file:
store_token = json.dumps(selected_store)
joined = f"{joined} | add-file -instance {store_token}"
joined = f"{joined} | file -add -instance {store_token}"
return joined
@@ -1092,13 +1091,13 @@ class PipelineHubApp(App):
return command
low = command.lower()
if "add-tag" in low:
# User already controls tag stage explicitly.
if "metadata -add" in low:
# User already controls metadata tag stage explicitly.
self._pending_pipeline_tags_applied = False
return command
# Apply draft tags when pipeline stores/emits files via add-file.
if "add-file" not in low:
# Apply draft tags when pipeline stores/emits files via file-add.
if "add-file" not in low and "file -add" not in low:
self._pending_pipeline_tags_applied = False
return command
@@ -1109,7 +1108,7 @@ class PipelineHubApp(App):
self._pending_pipeline_tags_applied = True
self.notify(f"Applying {len(pending)} pending tag(s) after pipeline", timeout=3)
return f"{command} | add-tag {tag_args}"
return f"{command} | metadata -add {tag_args}"
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if not self.results_table or event.control is not self.results_table:
@@ -1656,27 +1655,27 @@ class PipelineHubApp(App):
try:
if to_del:
del_args = " ".join(json.dumps(t) for t in to_del)
del_cmd = f"delete-tag -instance {store_tok}{query_chunk} {del_args}"
del_cmd = f"metadata -delete -instance {store_tok}{query_chunk} {del_args}"
del_res = runner.run_pipeline(del_cmd, seeds=seeds, isolate=True)
if not getattr(del_res, "success", False):
failures.append(
str(
getattr(del_res, "error", "")
or getattr(del_res, "stderr", "")
or "delete-tag failed"
or "metadata -delete failed"
).strip()
)
if to_add:
add_args = " ".join(json.dumps(t) for t in to_add)
add_cmd = f"add-tag -instance {store_tok}{query_chunk} {add_args}"
add_cmd = f"metadata -add -instance {store_tok}{query_chunk} {add_args}"
add_res = runner.run_pipeline(add_cmd, seeds=seeds, isolate=True)
if not getattr(add_res, "success", False):
failures.append(
str(
getattr(add_res, "error", "")
or getattr(add_res, "stderr", "")
or "add-tag failed"
or "metadata -add failed"
).strip()
)
@@ -1835,7 +1834,7 @@ class PipelineHubApp(App):
if not store_name or not file_hash:
return
try:
from cmdlet.get_tag import _emit_tags_as_table
from cmdlet.metadata.tag_get import _emit_tags_as_table
except Exception:
return
@@ -2358,7 +2357,7 @@ class PipelineHubApp(App):
self.notify("Delete action requires store + hash", severity="warning", timeout=3)
return
query = f"hash:{hash_value}"
cmd = f"delete-file -instance {json.dumps(store_name)} -query {json.dumps(query)}"
cmd = f"file -delete -instance {json.dumps(store_name)} -query {json.dumps(query)}"
self._start_pipeline_execution(cmd)
return
@@ -2398,11 +2397,11 @@ class PipelineHubApp(App):
query = f"hash:{hash_value}"
base_copy = (
f"search-file -instance {json.dumps(store_name)} {json.dumps(query)}"
f" | add-file -instance {json.dumps(selected_store)}"
f"file -search -instance {json.dumps(store_name)} {json.dumps(query)}"
f" | file -add -instance {json.dumps(selected_store)}"
)
if action == "move_to_selected_store":
delete_cmd = f"delete-file -instance {json.dumps(store_name)} -query {json.dumps(query)}"
delete_cmd = f"file -delete -instance {json.dumps(store_name)} -query {json.dumps(query)}"
cmd = f"{base_copy} | @ | {delete_cmd}"
else:
cmd = base_copy
+5 -5
View File
@@ -30,21 +30,21 @@ PIPELINE_PRESETS: List[PipelinePreset] = [
PipelinePreset(
label="Download → Merge → Local",
description=
"Use download-file with playlist auto-selection, merge the pieces, tag, then import into local storage.",
"Use file -download with playlist auto-selection, merge the pieces, tag, then import into local storage.",
pipeline=
'download-file "<url>" | merge-file | add-tags -instance local | add-file -storage local',
'file -download "<url>" | file -merge | metadata -add -instance local | file -add -storage local',
),
PipelinePreset(
label="Download → Hydrus",
description="Fetch media, auto-tag, and push directly into Hydrus.",
pipeline=
'download-file "<url>" | merge-file | add-tags -instance hydrus | add-file -storage hydrus',
'file -download "<url>" | file -merge | metadata -add -instance hydrus | file -add -storage hydrus',
),
PipelinePreset(
label="Search Local Library",
description=
"Run search-file against the local library and emit a result table for further piping.",
pipeline='search-file -library local -query "<keywords>"',
"Run file -search against the local library and emit a result table for further piping.",
pipeline='file -search -library local -query "<keywords>"',
),
]
+28 -27
View File
@@ -404,7 +404,7 @@ class DownloadModal(ModalScreen):
download_succeeded = False
download_stderr_text = "" # Store for merge stage
if download_enabled:
download_cmdlet_name = "download-file"
download_cmdlet_name = "file"
download_cmdlet = get_cmdlet(download_cmdlet_name)
if download_cmdlet:
logger.info(f"📥 Executing {download_cmdlet_name} stage")
@@ -416,7 +416,7 @@ class DownloadModal(ModalScreen):
worker.log_step(f"Starting {download_cmdlet_name} stage...")
# Build yt-dlp playlist arguments for download-file streaming (if applicable).
cmdlet_args = []
cmdlet_args = ["-download"]
if self.is_playlist:
# Always use yt-dlp's native --playlist-items for playlists
if playlist_selection:
@@ -807,7 +807,7 @@ class DownloadModal(ModalScreen):
# Stage 2: Merge files if enabled and this is a playlist (BEFORE tagging)
merged_file_path = None
if merge_enabled and download_succeeded and self.is_playlist:
merge_cmdlet = get_cmdlet("merge-file")
merge_cmdlet = get_cmdlet("file")
if merge_cmdlet:
from pathlib import Path
@@ -818,6 +818,7 @@ class DownloadModal(ModalScreen):
worker.log_step("Starting merge-file stage...")
merge_args = [
"-merge",
"-delete",
"-format",
"mka",
@@ -963,10 +964,10 @@ class DownloadModal(ModalScreen):
else:
logger.info("merge-file cmdlet not found")
# Stage 3: Add tags (now after merge, if merge happened)
# Stage 3: Add metadata tags (now after merge, if merge happened)
# If merge succeeded, result_obj now points to merged file
if tags and (download_succeeded or not download_enabled):
add_tags_cmdlet = get_cmdlet("add-tags")
add_tags_cmdlet = get_cmdlet("metadata")
if add_tags_cmdlet:
logger.info(f"Executing add-tags stage with {len(tags)} tags")
logger.info(f" Tags: {tags}")
@@ -980,9 +981,9 @@ class DownloadModal(ModalScreen):
f"Starting add-tags stage with {len(tags)} tags..."
)
# Build add-tags arguments. add-tags requires a store; for downloads, default to local sidecar tagging.
# Build metadata-tag arguments. Default to local sidecar tagging for downloads.
tag_args = (
["-instance",
["-add", "-instance",
"local"] + [str(t) for t in tags] + ["--source",
str(source)]
)
@@ -1051,7 +1052,7 @@ class DownloadModal(ModalScreen):
self.app.call_from_thread(self._hide_progress)
return
else:
logger.error("add-tags cmdlet not found")
logger.error("metadata cmdlet not found for add stage")
else:
if tags and download_enabled and not download_succeeded:
skip_msg = "⚠️ Skipping add-tags stage because download failed"
@@ -1455,7 +1456,7 @@ class DownloadModal(ModalScreen):
# Tag the file if tags provided
if tags and get_cmdlet:
tag_cmdlet = get_cmdlet("add-tags")
tag_cmdlet = get_cmdlet("metadata")
if tag_cmdlet:
logger.info(f"Tagging merged PDF with {len(tags)} tags")
@@ -1475,7 +1476,7 @@ class DownloadModal(ModalScreen):
stdout_buf = io.StringIO()
stderr_buf = io.StringIO()
tag_args = ["-instance", "local"] + [str(t) for t in tags]
tag_args = ["-add", "-instance", "local"] + [str(t) for t in tags]
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
tag_returncode = tag_cmdlet(
result_obj,
@@ -1548,7 +1549,7 @@ class DownloadModal(ModalScreen):
wipe_tags_and_source: bool = False,
skip_tag_scraping: bool = False
) -> None:
"""Background worker to scrape metadata using get-tag cmdlet.
"""Background worker to scrape metadata using metadata -get.
Args:
url: URL to scrape metadata from
@@ -1558,7 +1559,7 @@ class DownloadModal(ModalScreen):
try:
logger.info(f"Metadata worker started for: {url}")
# Call get-tag cmdlet to scrape URL
# Call metadata cmdlet to scrape URL
if not get_cmdlet:
logger.error("cmdlet module not available")
self.app.call_from_thread(
@@ -1569,13 +1570,13 @@ class DownloadModal(ModalScreen):
)
return
# Get the get-tag cmdlet
get_tag_cmdlet = get_cmdlet("get-tag")
# Get the metadata cmdlet
get_tag_cmdlet = get_cmdlet("metadata")
if not get_tag_cmdlet:
logger.error("get-tag cmdlet not found")
logger.error("metadata cmdlet not found")
self.app.call_from_thread(
self.app.notify,
"get-tag cmdlet not found",
"metadata cmdlet not found",
title="Error",
severity="error"
)
@@ -1591,7 +1592,7 @@ class DownloadModal(ModalScreen):
result_obj = URLResult(url)
# Call the cmdlet with -scrape flag (unless skipping tag scraping)
# Call the cmdlet with -get/-scrape flags (unless skipping tag scraping)
import io
from contextlib import redirect_stdout, redirect_stderr
@@ -1599,7 +1600,7 @@ class DownloadModal(ModalScreen):
error_buffer = io.StringIO()
# Only scrape if not skipping tag scraping
args = [] if skip_tag_scraping else ["-scrape", url]
args = ["-get"] if skip_tag_scraping else ["-get", "-scrape", url]
with redirect_stdout(output_buffer), redirect_stderr(error_buffer):
returncode = get_tag_cmdlet(result_obj,
@@ -1608,7 +1609,7 @@ class DownloadModal(ModalScreen):
if returncode != 0:
error_msg = error_buffer.getvalue()
logger.error(f"get-tag cmdlet failed: {error_msg}")
logger.error(f"metadata cmdlet failed: {error_msg}")
try:
self.app.call_from_thread(
self.app.notify,
@@ -1623,11 +1624,11 @@ class DownloadModal(ModalScreen):
# Parse the JSON output
output = output_buffer.getvalue().strip()
if not output:
logger.warning("get-tag returned no output")
logger.warning("metadata -get returned no output")
try:
self.app.call_from_thread(
self.app.notify,
"No metadata returned from get-tag",
"No metadata returned from metadata -get",
title="Error",
severity="error",
)
@@ -1635,7 +1636,7 @@ class DownloadModal(ModalScreen):
logger.debug(f"Could not notify user: {e}")
return
# Extract the JSON line (skip debug messages that start with [get-tag])
# Extract the JSON line (skip debug messages that start with [tag])
json_line = None
for line in output.split("\n"):
if line.strip().startswith("{"):
@@ -1643,7 +1644,7 @@ class DownloadModal(ModalScreen):
break
if not json_line:
logger.error("No JSON found in get-tag output")
logger.error("No JSON found in metadata -get output")
logger.debug(f"Raw output: {output}")
try:
self.app.call_from_thread(
@@ -1832,7 +1833,7 @@ class DownloadModal(ModalScreen):
# Stage 1: Download data if enabled
if download_enabled:
download_cmdlet_name = "download-file"
download_cmdlet_name = "file"
download_cmdlet = get_cmdlet(download_cmdlet_name)
if download_cmdlet:
stage_msg = f"📥 Executing {download_cmdlet_name} stage"
@@ -1853,7 +1854,7 @@ class DownloadModal(ModalScreen):
)
if isinstance(cmd_config, dict):
cmd_config["_quiet_background_output"] = True
returncode = download_cmdlet(result_obj, [], cmd_config)
returncode = download_cmdlet(result_obj, ["-download"], cmd_config)
stdout_text = stdout_buf.getvalue()
stderr_text = stderr_buf.getvalue()
@@ -1902,14 +1903,14 @@ class DownloadModal(ModalScreen):
# Stage 2: Tag the file if tags provided
if tags:
tag_cmdlet = get_cmdlet("add-tags")
tag_cmdlet = get_cmdlet("metadata")
if tag_cmdlet and result_obj.get("path"):
stage_msg = f"🏷️ Tagging with {len(tags)} tags"
logger.info(stage_msg)
if worker:
worker.append_stdout(f"{stage_msg}\n")
try:
tag_args = tags
tag_args = ["-add"] + [str(t) for t in tags]
import io
from contextlib import redirect_stdout, redirect_stderr
+14 -1
View File
@@ -89,7 +89,20 @@ def _load_root_modules() -> None:
def _load_helper_modules() -> None:
# Provider-specific module pre-loading removed; providers are loaded lazily
# through ProviderCore.registry when first referenced.
pass
#
# Keep explicit imports for cmdlets that were moved under cmdlet/file so they
# remain registered under their legacy command names (add-note/add-url/add-relationship).
for mod in (
".file.add_note",
".file.add_url",
".file.add_relationship",
".metadata.get_note",
".metadata.get_relationship",
):
try:
_import_module(mod, __name__)
except Exception as exc:
print(f"Error importing cmdlet helper '{mod}': {exc}", file=sys.stderr)
def _register_native_commands() -> None:
+7
View File
@@ -1235,6 +1235,13 @@ def run_store_note_batches(
if on_store_error is not None and exc is not None:
on_store_error(store_name, exc)
continue
supports_note_capability = getattr(backend, "supports_note_association", None)
if supports_note_capability is None:
supports_note_capability = hasattr(backend, "set_note")
if not bool(supports_note_capability):
if on_unsupported_store is not None:
on_unsupported_store(store_name)
continue
if not hasattr(backend, "set_note"):
if on_unsupported_store is not None:
on_unsupported_store(store_name)
+3
View File
@@ -0,0 +1,3 @@
"""File action cmdlets package."""
__all__ = []
+132 -25
View File
@@ -19,7 +19,7 @@ from SYS.rich_display import show_available_plugins_panel, show_plugin_config_pa
from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS
from Store import Store
from API.HTTP import _download_direct_file
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -862,7 +862,7 @@ class Add_File(Cmdlet):
subject = collected_payloads[0] if len(collected_payloads) == 1 else collected_payloads
# Use helper to display items and make them @-selectable
from ._shared import display_and_persist_items
from .._shared import display_and_persist_items
display_and_persist_items(collected_payloads, title="Result", subject=subject)
try:
@@ -911,7 +911,7 @@ class Add_File(Cmdlet):
return None
try:
from cmdlet.search_file import CMDLET as search_file_cmdlet
from cmdlet.file.search import CMDLET as search_file_cmdlet
query = "hash:" + ",".join(hashes)
args = ["-instance", str(instance), "-internal-refresh", query]
@@ -1135,6 +1135,9 @@ class Add_File(Cmdlet):
except Exception:
continue
if not bool(getattr(backend, "supports_relationship_association", False)):
continue
setter = getattr(backend, "set_relationship", None)
if not callable(setter):
continue
@@ -1981,7 +1984,7 @@ class Add_File(Cmdlet):
return
try:
from ._shared import display_and_persist_items
from .._shared import display_and_persist_items
display_and_persist_items([payload], title="Result", subject=payload)
except Exception:
@@ -2045,7 +2048,7 @@ class Add_File(Cmdlet):
Returns the emitted search-file payload items on success, else None.
"""
try:
from cmdlet.search_file import CMDLET as search_file_cmdlet
from cmdlet.file.search import CMDLET as search_file_cmdlet
args = ["-instance", str(instance), f"hash:{str(hash_value)}"]
@@ -2233,6 +2236,63 @@ class Add_File(Cmdlet):
pipe_obj.extra["url"] = merged_url
return merged_tags, merged_url, preferred_title, file_hash
@staticmethod
def _normalize_hash_candidate(value: Any) -> str:
text = str(value or "").strip().lower()
if len(text) != 64:
return ""
if any(ch not in "0123456789abcdef" for ch in text):
return ""
return text
@staticmethod
def _find_existing_hash_by_urls(
backend: Any,
urls: Sequence[str],
) -> Optional[str]:
"""Best-effort duplicate detection by URL before ingesting file bytes."""
url_candidates: List[str] = []
for raw in urls or []:
text = str(raw or "").strip()
if not text or not Add_File._is_probable_url(text):
continue
if text not in url_candidates:
url_candidates.append(text)
if not url_candidates:
return None
lookup_exact = getattr(backend, "find_hashes_by_url", None)
if callable(lookup_exact):
for candidate_url in url_candidates:
try:
hashes = lookup_exact(candidate_url) or []
except Exception:
continue
if not isinstance(hashes, (list, tuple, set)):
continue
for item in hashes:
normalized = Add_File._normalize_hash_candidate(item)
if normalized:
return normalized
searcher = getattr(backend, "search", None)
if callable(searcher):
for candidate_url in url_candidates:
try:
hits = searcher(f"url:{candidate_url}", limit=1, minimal=True) or []
except Exception:
continue
if not isinstance(hits, list) or not hits:
continue
hit = hits[0]
for key in ("hash", "file_hash", "sha256"):
normalized = Add_File._normalize_hash_candidate(get_field(hit, key))
if normalized:
return normalized
return None
@staticmethod
def _handle_local_export(
media_path: Path,
@@ -2383,8 +2443,6 @@ class Add_File(Cmdlet):
list_plugins_with_capability,
)
log(f"Uploading via {plugin_name}: {media_path.name}", file=sys.stderr)
try:
file_provider = get_plugin_with_capability(plugin_name, "upload", config)
if not file_provider:
@@ -2406,7 +2464,36 @@ class Add_File(Cmdlet):
pipe_obj=pipe_obj,
instance=instance_name,
)
log(f"File uploaded: {hoster_url}", file=sys.stderr)
duplicate_upload = False
duplicate_rule = ""
duplicate_target = ""
try:
if isinstance(getattr(pipe_obj, "extra", None), dict):
duplicate_upload = bool(pipe_obj.extra.get("upload_duplicate"))
duplicate_rule = str(pipe_obj.extra.get("upload_duplicate_rule") or "").strip()
duplicate_target = str(pipe_obj.extra.get("upload_duplicate_target") or "").strip()
except Exception:
duplicate_upload = False
duplicate_rule = ""
duplicate_target = ""
try:
debug_panel(
"add-file plugin upload",
[
("plugin", plugin_name),
("instance", instance_name or "<default>"),
("source", media_path),
("duplicate", duplicate_upload),
("rule", duplicate_rule or "none"),
("target", duplicate_target or ""),
("url", hoster_url),
],
border_style="yellow" if duplicate_upload else "green",
)
except Exception:
pass
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
@@ -2505,11 +2592,21 @@ class Add_File(Cmdlet):
try:
store = store_instance if store_instance is not None else Store(config)
backend = store[backend_name]
backend, store, backend_exc = sh.get_preferred_store_backend(
config,
backend_name,
store_registry=store,
suppress_debug=True,
)
if backend is None:
raise backend_exc or KeyError(f"Unknown store backend: {backend_name}")
# Use backend properties to drive metadata deferral behavior.
is_remote_backend = getattr(backend, "is_remote", False)
prefer_defer_tags = getattr(backend, "prefer_defer_tags", False)
supports_url_association = bool(getattr(backend, "supports_url_association", False))
supports_note_association = bool(getattr(backend, "supports_note_association", False))
supports_relationship_association = bool(getattr(backend, "supports_relationship_association", False))
# ...
# Prepare metadata from pipe_obj and sidecars
@@ -2573,7 +2670,7 @@ class Add_File(Cmdlet):
pass
# Collect relationship pairs for post-ingest DB/API persistence.
if collect_relationship_pairs is not None:
if collect_relationship_pairs is not None and supports_relationship_association:
rels = Add_File._get_relationships(result, pipe_obj)
if isinstance(rels, dict) and rels:
king_hash, alt_hashes = Add_File._parse_relationships_king_alts(rels)
@@ -2622,16 +2719,23 @@ class Add_File(Cmdlet):
except Exception:
pass
# Call backend's add_file with full metadata
# Backend returns hash as identifier. If we already know the hash from _resolve_source
# (which came from download-file emit), pass it to skip re-hashing the 4GB file.
file_identifier = backend.add_file(
media_path,
title=title,
tag=upload_tags,
url=[] if (defer_url_association and url) else url,
file_hash=f_hash,
)
duplicate_hash = Add_File._find_existing_hash_by_urls(backend, url)
if duplicate_hash:
debug(
f"[add-file] URL duplicate detected in '{backend_name}', skipping upload and reusing hash {duplicate_hash[:12]}..."
)
file_identifier = duplicate_hash
else:
# Call backend's add_file with full metadata.
# Backend returns hash as identifier. If we already know the hash from _resolve_source
# (which came from download-file emit), pass it to skip re-hashing large files.
file_identifier = backend.add_file(
media_path,
title=title,
tag=upload_tags,
url=[] if ((defer_url_association and url) or (not supports_url_association)) else url,
file_hash=f_hash,
)
##log(f"✓ File added to '{backend_name}': {file_identifier}", file=sys.stderr)
stored_path: Optional[str] = None
@@ -2687,7 +2791,7 @@ class Add_File(Cmdlet):
# If we have url(s), ensure they get associated with the destination file.
# This mirrors `add-url` behavior but avoids emitting extra pipeline noise.
if url:
if url and supports_url_association:
if defer_url_association and pending_url_associations is not None:
try:
pending_url_associations.setdefault(
@@ -2708,7 +2812,7 @@ class Add_File(Cmdlet):
# If a subtitle note was provided upstream (e.g., download-media writes notes.sub),
# persist it automatically like add-note would.
sub_note = Add_File._get_note_text(result, pipe_obj, "sub")
if sub_note:
if sub_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2726,7 +2830,7 @@ class Add_File(Cmdlet):
)
lyric_note = Add_File._get_note_text(result, pipe_obj, "lyric")
if lyric_note:
if lyric_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2744,7 +2848,7 @@ class Add_File(Cmdlet):
)
chapters_note = Add_File._get_note_text(result, pipe_obj, "chapters")
if chapters_note:
if chapters_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2762,7 +2866,7 @@ class Add_File(Cmdlet):
)
caption_note = Add_File._get_note_text(result, pipe_obj, "caption")
if caption_note:
if caption_note and supports_note_association:
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
@@ -2905,6 +3009,9 @@ class Add_File(Cmdlet):
if backend is None:
continue
if not bool(getattr(backend, "supports_url_association", False)):
continue
items = sh.coalesce_hash_value_pairs(pairs)
if not items:
continue
@@ -8,7 +8,7 @@ import re
from SYS.logger import log
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -213,6 +213,12 @@ class Add_Note(Cmdlet):
)
if backend is None:
raise exc or KeyError(store_override)
if not bool(getattr(backend, "supports_note_association", False)):
log(
f"[add_note] Error: Store '{store_override}' does not support notes",
file=sys.stderr,
)
return 1
ok = bool(
backend.set_note(
str(hash_override),
@@ -12,7 +12,7 @@ from SYS.item_accessors import get_sha256_hex, get_store_name
from ProviderCore.registry import get_plugin
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -603,6 +603,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
if store_name:
backend, _store_registry, _exc = sh.get_store_backend(config, str(store_name))
if backend is not None:
if not bool(getattr(backend, "supports_relationship_association", False)):
log(
f"Store '{store_name}' does not support relationships",
file=sys.stderr,
)
return 1
loc = getattr(backend, "location", None)
if callable(loc):
is_folder_store = True
+20 -2
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, Sequence, Tuple
import sys
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
from SYS.logger import log
from Store import Store
@@ -135,10 +135,25 @@ class Add_Url(sh.Cmdlet):
on_warning=_warn,
)
supported_batch: Dict[str, List[Tuple[str, Sequence[str]]]] = {}
for store_text, store_pairs in batch.items():
backend, storage, _exc = sh.get_store_backend(
config,
store_text,
store_registry=storage,
)
if backend is None:
_warn(f"Store '{store_text}' not configured; skipping")
continue
if not bool(getattr(backend, "supports_url_association", False)):
_warn(f"Store '{store_text}' does not support URLs; skipping")
continue
supported_batch[store_text] = store_pairs
# Execute per-instance batches.
storage, batch_stats = sh.run_store_hash_value_batches(
config,
batch,
supported_batch,
bulk_method_name="add_url_bulk",
single_method_name="add_url",
store_registry=storage,
@@ -166,6 +181,9 @@ class Add_Url(sh.Cmdlet):
if backend is None:
log(f"Error: Storage backend '{store_name}' not configured")
return 1
if not bool(getattr(backend, "supports_url_association", False)):
log(f"Error: Store '{store_name}' does not support URL associations")
return 1
backend.add_url(str(file_hash), urls, config=config)
ctx.print_if_visible(
f"✓ add-url: {len(urls)} url(s) added",
@@ -19,7 +19,7 @@ from SYS.utils import extract_hydrus_hash_from_url
from SYS import pipeline as ctx
from SYS.config import resolve_output_dir
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -9,7 +9,7 @@ import subprocess
from SYS.logger import log, debug
from SYS.payload_builders import build_file_result_payload
from SYS.utils import sha256_file
from . import _shared as sh
from .. import _shared as sh
from SYS import pipeline as ctx
Cmdlet = sh.Cmdlet
@@ -10,7 +10,7 @@ from pathlib import Path
from SYS.logger import debug, log
from ProviderCore.registry import get_plugin
from Store import Store
from . import _shared as sh
from .. import _shared as sh
from SYS import pipeline as ctx
from SYS.result_table_helpers import add_row_columns
from SYS.result_table import Table, _format_size
@@ -24,6 +24,7 @@ from SYS.pipeline_progress import PipelineProgress
from SYS.result_table import Table
from SYS.rich_display import stderr_console as get_stderr_console
from SYS import pipeline as pipeline_context
from rich.prompt import Prompt
# SYS.metadata import deferred: normalize_urls loaded lazily at call site to avoid
# pulling in Cryptodome (~900ms) at module import time.
from SYS.selection_builder import (
@@ -38,7 +39,7 @@ try:
except Exception: # pragma: no cover - optional dependency for tests/runtime wrappers
YtDlpTool = None # type: ignore
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -936,7 +937,7 @@ class Download_File(Cmdlet):
try:
subject = emitted_items[0] if len(emitted_items) == 1 else list(emitted_items)
# Use helper to display items and make them @-selectable
from ._shared import display_and_persist_items
from .._shared import display_and_persist_items
display_and_persist_items(list(emitted_items), title="Result", subject=subject)
except Exception:
pass
@@ -984,56 +985,13 @@ class Download_File(Cmdlet):
def _find_existing_hash_for_url(
cls, storage: Any, canonical_url: str, *, hydrus_available: bool
) -> Optional[str]:
if storage is None or not canonical_url:
return None
hydrus_provider = None
try:
registry_helpers = cls._load_provider_registry()
get_plugin = registry_helpers.get("get_plugin")
if callable(get_plugin):
hydrus_provider = get_plugin("hydrusnetwork", {})
except Exception:
hydrus_provider = None
try:
backend_names = list(storage.list_searchable_backends() or [])
except Exception:
backend_names = []
for backend_name in backend_names:
try:
backend = storage[backend_name]
except Exception:
continue
try:
if str(backend_name).strip().lower() == "temp":
continue
except Exception:
pass
try:
if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(backend_name)) and not hydrus_available:
continue
except Exception:
pass
try:
if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(backend_name)):
hashes = backend.find_hashes_by_url(canonical_url) or []
for existing_hash in hashes:
normalized = sh.normalize_hash(existing_hash)
if normalized:
return normalized
continue
hits = backend.search(f"url:{canonical_url}", limit=5) or []
except Exception:
hits = []
for hit in hits:
extracted = cls._extract_hash_from_search_hit(hit)
if extracted:
return extracted
return None
hashes = cls._find_existing_hashes_for_url(
storage,
canonical_url,
hydrus_available=hydrus_available,
config={},
)
return hashes[0] if hashes else None
@staticmethod
def _init_storage(config: Dict[str, Any]) -> tuple[Any, bool]:
@@ -1265,6 +1223,205 @@ class Download_File(Cmdlet):
download_timeout_seconds=int(config.get("_pipeobject_timeout_seconds") or 300) if isinstance(config, dict) else 300,
)
@classmethod
def _find_existing_hashes_for_url(
cls,
storage: Any,
canonical_url: str,
*,
hydrus_available: bool,
config: Optional[Dict[str, Any]] = None,
) -> List[str]:
if not canonical_url:
return []
config_dict = config if isinstance(config, dict) else {}
found_hashes: List[str] = []
seen_hashes: set[str] = set()
seen_backends: set[str] = set()
def _add_hash(value: Any) -> None:
normalized = sh.normalize_hash(str(value) if value is not None else None)
if not normalized or normalized in seen_hashes:
return
seen_hashes.add(normalized)
found_hashes.append(normalized)
def _iter_backends() -> List[tuple[str, Any]]:
backends: List[tuple[str, Any]] = []
if storage is not None:
try:
backend_names = list(storage.list_searchable_backends() or [])
except Exception:
backend_names = []
for backend_name in backend_names:
try:
backend = storage[backend_name]
except Exception:
continue
name_text = str(backend_name).strip()
if not name_text:
continue
if name_text.lower() == "temp":
continue
key = name_text.lower()
if key in seen_backends:
continue
seen_backends.add(key)
backends.append((name_text, backend))
# Hydrus can be plugin-configured without appearing in Store.list_searchable_backends().
try:
registry_helpers = cls._load_provider_registry()
get_plugin = registry_helpers.get("get_plugin")
hydrus_provider = get_plugin("hydrusnetwork", config_dict) if callable(get_plugin) else None
if hydrus_provider is not None:
for backend_name, backend in hydrus_provider.iter_backends():
name_text = str(backend_name or "").strip()
if not name_text:
continue
key = name_text.lower()
if key in seen_backends:
continue
seen_backends.add(key)
backends.append((name_text, backend))
except Exception:
pass
return backends
for backend_name, backend in _iter_backends():
try:
if not hydrus_available and str(getattr(backend, "STORE_TYPE", "")).strip().lower() == "hydrusnetwork":
continue
except Exception:
pass
lookup_exact = getattr(backend, "find_hashes_by_url", None)
if callable(lookup_exact):
try:
hashes = lookup_exact(canonical_url) or []
except Exception:
hashes = []
if isinstance(hashes, (list, tuple, set)):
for existing_hash in hashes:
_add_hash(existing_hash)
if found_hashes:
continue
searcher = getattr(backend, "search", None)
if callable(searcher):
try:
hits = searcher(f"url:{canonical_url}", limit=5, minimal=True) or []
except Exception:
hits = []
for hit in hits:
extracted = cls._extract_hash_from_search_hit(hit)
_add_hash(extracted)
return found_hashes
def _preflight_explicit_url_duplicates(
self,
*,
raw_urls: Sequence[str],
config: Dict[str, Any],
) -> tuple[List[str], Optional[int], int]:
"""Return (urls_to_process, early_exit, skipped_count)."""
urls = [str(u or "").strip() for u in (raw_urls or []) if str(u or "").strip()]
if not urls:
return [], None, 0
if bool(config.get("_skip_url_preflight")):
return urls, None, 0
storage, hydrus_available = self._init_storage(config)
duplicates: Dict[str, List[str]] = {}
for url in urls:
found = self._find_existing_hashes_for_url(
storage,
url,
hydrus_available=hydrus_available,
config=config,
)
if found:
duplicates[url] = found
if not duplicates:
return urls, None, 0
duplicate_count = len(duplicates)
total_count = len(urls)
try:
debug_panel(
"download-file duplicate preflight",
[
("total_urls", total_count),
("duplicate_urls", duplicate_count),
],
border_style="yellow",
)
except Exception:
pass
table = Table(f"Duplicate URLs detected ({duplicate_count}/{total_count})", max_columns=8)
table._interactive(False)
for url, hashes in duplicates.items():
table.add_result(
build_table_result_payload(
title="(exists)",
columns=[
("URL", url),
("Hash", str((hashes[0] if hashes else "") or "")),
],
url=url,
hash=str((hashes[0] if hashes else "") or ""),
)
)
try:
stdin_interactive = bool(sys.stdin and sys.stdin.isatty())
except Exception:
stdin_interactive = False
policy = "skip"
if stdin_interactive:
console = get_stderr_console()
console.print(table)
policy = Prompt.ask(
"Duplicate URLs found. Action?",
choices=["ignore", "skip", "cancel"],
default="skip",
console=console,
)
else:
# Safe default in non-interactive runs: avoid redownloading known duplicates.
policy = "skip"
try:
get_stderr_console().print(table)
except Exception:
pass
if policy == "cancel":
try:
pipeline_context.request_pipeline_stop(reason="duplicate-url cancelled", exit_code=0)
except Exception:
pass
return [], 0, 0
if policy == "ignore":
return urls, None, 0
filtered = [u for u in urls if u not in duplicates]
skipped = len(urls) - len(filtered)
if skipped:
try:
log(f"Skipped {skipped} duplicate URL(s); processing remaining {len(filtered)}.", file=sys.stderr)
except Exception:
pass
return filtered, None, skipped
@staticmethod
def _format_timecode(seconds: int, *, force_hours: bool) -> str:
total = max(0, int(seconds))
@@ -1739,8 +1896,18 @@ class Download_File(Cmdlet):
items_preview=preview
)
raw_url, preflight_exit, skipped_dupe_count = self._preflight_explicit_url_duplicates(
raw_urls=raw_url,
config=config,
)
if preflight_exit is not None:
return int(preflight_exit)
downloaded_count = 0
if skipped_dupe_count and not raw_url and not piped_items:
return 0
urls_downloaded, early_exit = self._process_explicit_urls(
raw_urls=raw_url,
final_output_dir=final_output_dir,
+1 -1
View File
@@ -16,7 +16,7 @@ from urllib.parse import urljoin
from urllib.request import pathname2url
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
from SYS.item_accessors import get_result_title
from SYS.logger import log, debug, debug_panel
from SYS.config import resolve_output_dir
@@ -13,7 +13,7 @@ import re as _re
from SYS.config import resolve_output_dir
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -23,7 +23,7 @@ from SYS.item_accessors import extract_item_tags, get_result_title
from API.HTTP import HTTPClient
from SYS.pipeline_progress import PipelineProgress
from SYS.utils import ensure_directory, sha256_file, unique_path, unique_preserve_order
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -26,7 +26,7 @@ from SYS.item_accessors import get_extension_field, get_int_field, get_result_ti
from SYS.selection_builder import build_default_selection
from SYS.result_publication import publish_result_table
from ._shared import (
from .._shared import (
Cmdlet,
CmdletArg,
SharedArgs,
+1 -1
View File
@@ -14,7 +14,7 @@ from urllib.parse import urlparse
from SYS.logger import log, debug
from SYS.item_accessors import get_store_name
from SYS.utils import sha256_file
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from importlib import import_module
from typing import Any, Dict, List, Sequence
import sys
from SYS.logger import log
from . import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
SharedArgs = sh.SharedArgs
class File(Cmdlet):
"""Unified file command: file -add|-delete|-get|-merge|..."""
_ACTION_FLAGS = {
"add": {"-add", "--add"},
"delete": {"-delete", "--delete", "-del", "--del"},
"get": {"-get", "--get"},
"merge": {"-merge", "--merge"},
"download": {"-download", "--download", "-dl", "--dl"},
"search": {"-search", "--search"},
"convert": {"-convert", "--convert"},
"trim": {"-trim", "--trim"},
"archive": {"-archive", "--archive"},
"screenshot": {"-screenshot", "--screenshot", "-screen-shot", "--screen-shot", "-shot", "--shot"},
}
_ACTION_MODULE = {
"add": "cmdlet.file.add",
"delete": "cmdlet.file.delete",
"get": "cmdlet.file.get",
"merge": "cmdlet.file.merge",
"download": "cmdlet.file.download",
"search": "cmdlet.file.search",
"convert": "cmdlet.file.convert",
"trim": "cmdlet.file.trim",
"archive": "cmdlet.file.archive",
"screenshot": "cmdlet.file.screenshot",
}
def __init__(self) -> None:
super().__init__(
name="file",
summary="Manage file operations with one command",
usage='file (-add|-delete|-get|-merge|-download|-search|-convert|-trim|-archive|-screenshot) [args]',
arg=[
SharedArgs.QUERY,
SharedArgs.INSTANCE,
SharedArgs.PATH,
CmdletArg("-add", type="flag", required=False, description="Run add-file"),
CmdletArg("-delete", type="flag", required=False, description="Run delete-file", alias="del"),
CmdletArg("-get", type="flag", required=False, description="Run get-file"),
CmdletArg("-merge", type="flag", required=False, description="Run merge-file"),
CmdletArg("-download", type="flag", required=False, description="Run download-file", alias="dl"),
CmdletArg("-search", type="flag", required=False, description="Run search-file"),
CmdletArg("-convert", type="flag", required=False, description="Run convert-file"),
CmdletArg("-trim", type="flag", required=False, description="Run trim-file"),
CmdletArg("-archive", type="flag", required=False, description="Run archive-file"),
CmdletArg("-screenshot", type="flag", required=False, description="Run screen-shot", alias="shot"),
],
detail=[
"- Exactly one action flag is required.",
"- Remaining args are passed through to the selected file cmdlet.",
"- Examples: file -add ..., file -delete ..., file -merge ...",
],
exec=self.run,
)
self.register()
@classmethod
def _extract_action(cls, args: Sequence[str]) -> tuple[str | None, List[str], List[str]]:
matched_actions: List[str] = []
passthrough: List[str] = []
for token in args or []:
text = str(token or "")
lower = text.strip().lower()
matched = None
for action_name, variants in cls._ACTION_FLAGS.items():
if lower in variants:
matched = action_name
break
if matched:
matched_actions.append(matched)
continue
passthrough.append(text)
unique_actions: List[str] = []
for action in matched_actions:
if action not in unique_actions:
unique_actions.append(action)
if len(unique_actions) != 1:
return None, passthrough, unique_actions
return unique_actions[0], passthrough, unique_actions
@classmethod
def _dispatch(cls, action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
module_name = cls._ACTION_MODULE.get(action)
if not module_name:
log(f"file: unsupported action '{action}'", file=sys.stderr)
return 1
module = import_module(module_name)
cmdlet_obj = getattr(module, "CMDLET", None)
if cmdlet_obj is not None:
exec_fn = getattr(cmdlet_obj, "exec", None)
if callable(exec_fn):
return int(exec_fn(result, args, config))
fallback_run = getattr(module, "_run", None)
if callable(fallback_run):
return int(fallback_run(result, args, config))
log(f"file: cannot dispatch action '{action}' via module '{module_name}'", file=sys.stderr)
return 1
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
action, passthrough_args, seen = self._extract_action(args)
if action is None:
if not seen:
log(
"file: missing action flag; choose exactly one of -add, -delete, -get, -merge, -download, -search, -convert, -trim, -archive, -screenshot",
file=sys.stderr,
)
else:
rendered = ", ".join(f"-{name}" for name in seen)
log(f"file: conflicting actions ({rendered}); choose exactly one", file=sys.stderr)
return 1
return self._dispatch(action, result, passthrough_args, config)
CMDLET = File()
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
"""Metadata domain command handlers.
This package centralizes routing for metadata sub-domains (tag, url,
relationship, note, inspect) behind the top-level `metadata` cmdlet.
"""
from .tag import run_tag_action
from .url import run_url_action
from .relationship import run_relationship_action
from .note import run_note_action
from .inspect import run_inspect_action
__all__ = [
"run_tag_action",
"run_url_action",
"run_relationship_action",
"run_note_action",
"run_inspect_action",
]
@@ -10,7 +10,7 @@ from SYS.result_publication import publish_result_table
from SYS.result_table_helpers import add_row_columns
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -11,7 +11,7 @@ from SYS.selection_builder import build_hash_store_selection
from SYS.result_publication import publish_result_table
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_inspect_action(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata inspect to get-metadata implementation."""
from cmdlet.get_metadata import CMDLET as GET_METADATA_CMDLET
exec_fn = getattr(GET_METADATA_CMDLET, "exec", None)
if callable(exec_fn):
return int(exec_fn(result, args, config))
return int(GET_METADATA_CMDLET.run(result, args, config))
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_note_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata note actions to note cmdlets."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.file.add_note import CMDLET as ADD_NOTE_CMDLET
return int(ADD_NOTE_CMDLET.run(result, args, config))
if act == "delete":
from cmdlet.delete_note import CMDLET as DELETE_NOTE_CMDLET
return int(DELETE_NOTE_CMDLET.run(result, args, config))
if act == "get":
from cmdlet.metadata.get_note import CMDLET as GET_NOTE_CMDLET
return int(GET_NOTE_CMDLET.run(result, args, config))
return 1
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_relationship_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata relationship actions to relationship cmdlets."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.file.add_relationship import _run as run_add_relationship
return int(run_add_relationship(result, args, config))
if act == "delete":
from cmdlet.delete_relationship import _run as run_delete_relationship
return int(run_delete_relationship(list(args), config))
if act == "get":
from cmdlet.metadata.get_relationship import _run as run_get_relationship
return int(run_get_relationship(result, args, config))
return 1
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_tag_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata tag actions to the existing tag implementations."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.metadata.tag_add import Add_Tag
return Add_Tag(register_cmdlet=False).run(result, args, config)
if act == "delete":
from cmdlet.metadata.tag_delete import _run as run_delete
return run_delete(result, args, config)
if act == "get":
from cmdlet.metadata.tag_get import _run as run_get
return run_get(result, args, config)
return 1
@@ -12,7 +12,7 @@ from SYS.result_publication import publish_result_table
from SYS import models
from SYS import pipeline as ctx
from . import _shared as sh
from .. import _shared as sh
from Store import Store # retained for test monkeypatch compatibility
normalize_result_input = sh.normalize_result_input
@@ -411,7 +411,7 @@ def _refresh_tag_view(
get_tag = None
try:
get_tag = get_cmdlet("get-tag")
get_tag = get_cmdlet("metadata")
except Exception:
get_tag = None
if not callable(get_tag):
@@ -422,7 +422,7 @@ def _refresh_tag_view(
if not subject or not _matches_target(subject, target_hash, target_path, store_name):
return
refresh_args: List[str] = ["-query", f"hash:{target_hash}"]
refresh_args: List[str] = ["-get", "-query", f"hash:{target_hash}"]
# Build a lean subject so get-tag fetches fresh tags instead of reusing cached payloads.
def _build_refresh_subject() -> Dict[str, Any]:
@@ -463,14 +463,14 @@ def _refresh_tag_view(
class Add_Tag(Cmdlet):
"""Class-based add-tag cmdlet with Cmdlet metadata inheritance."""
"""Class-based metadata -add tag handler with Cmdlet metadata inheritance."""
def __init__(self) -> None:
def __init__(self, *, register_cmdlet: bool = True) -> None:
super().__init__(
name="add-tag",
name="tag",
summary="Add tag to a file in a store.",
usage=
'add-tag -instance <store> [-query "hash:<sha256>"] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]',
'metadata -add -instance <store> [-query "hash:<sha256>"] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]',
arg=[
CmdletArg(
"tag",
@@ -519,22 +519,23 @@ class Add_Tag(Cmdlet):
"- If -query is not provided, uses the piped item's hash (or derives from its path when possible).",
"- Multiple tag can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
'- tag can also reference lists with curly braces: add-tag {philosophy} "other:tag"',
'- tag can also reference lists with curly braces: metadata -add {philosophy} "other:tag"',
"- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).",
"- Use -extract to derive namespaced tags from the current title (title field or title: tag) using a simple template.",
"- Use #(namespace) inside a tag value to insert existing values, e.g. add-tag \"title:#(track) - #(series)\".",
"- Use angle-bracket transforms for advanced formatting, e.g. add-tag \"code:e<padding(00,#(episode))>\".",
"- Use #(namespace) inside a tag value to insert existing values, e.g. metadata -add \"title:#(track) - #(series)\".",
"- Use angle-bracket transforms for advanced formatting, e.g. metadata -add \"code:e<padding(00,#(episode))>\".",
"- Current documented transforms include padding, default, replace, and increment.",
"- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.",
"- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
],
exec=self.run,
)
self.register()
if register_cmdlet:
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Add tag to a file with smart filtering for pipeline results."""
@@ -1215,4 +1216,3 @@ class Add_Tag(Cmdlet):
return 0
CMDLET = Add_Tag()
@@ -7,7 +7,7 @@ from SYS import pipeline as ctx
from SYS.item_accessors import set_field
from SYS.payload_builders import extract_title_tag_value
from SYS.result_publication import publish_result_table
from . import _shared as sh
from .. import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
@@ -203,7 +203,7 @@ def _refresh_tag_view_if_current(
get_tag = None
try:
get_tag = get_cmdlet("get-tag")
get_tag = get_cmdlet("metadata")
except Exception:
get_tag = None
if not callable(get_tag):
@@ -245,7 +245,7 @@ def _refresh_tag_view_if_current(
if not is_match:
return
refresh_args: list[str] = []
refresh_args: list[str] = ["-get"]
if file_hash:
refresh_args.extend(["-query", f"hash:{file_hash}"])
@@ -385,10 +385,10 @@ def _parse_delete_tag_arguments(arguments: Sequence[str]) -> list[str]:
return tags
CMDLET = Cmdlet(
name="delete-tag",
_DELETE_TAG_CMDLET = Cmdlet(
name="tag",
summary="Remove tags from a file in a store.",
usage='delete-tag -instance <store> [-query "hash:<sha256>"] <tag>[,<tag>...]',
usage='metadata -delete -instance <store> [-query "hash:<sha256>"] <tag>[,<tag>...]',
arg=[
SharedArgs.QUERY,
SharedArgs.INSTANCE,
@@ -401,8 +401,8 @@ CMDLET = Cmdlet(
detail=[
"- Requires a Hydrus file (hash present) or explicit -query override.",
"- Multiple tags can be comma-separated or space-separated.",
"- Use #(namespace) inside a tag value to remove a derived tag, e.g. delete-tag \"title:#(track) - #(series)\".",
"- Angle-bracket transforms match add-tag syntax, e.g. delete-tag \"code:e<padding(00,#(episode))>\".",
"- Use #(namespace) inside a tag value to remove a derived tag, e.g. metadata -delete \"title:#(track) - #(series)\".",
"- Angle-bracket transforms match metadata -add syntax, e.g. metadata -delete \"code:e<padding(00,#(episode))>\".",
"- Current documented transforms include padding, default, replace, and increment.",
"- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.",
"- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
@@ -413,7 +413,7 @@ CMDLET = Cmdlet(
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Help
if should_show_help(args):
log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}")
log(f"Cmdlet: {_DELETE_TAG_CMDLET.name}\nSummary: {_DELETE_TAG_CMDLET.summary}\nUsage: {_DELETE_TAG_CMDLET.usage}")
return 0
def _looks_like_tag_row(obj: Any) -> bool:
@@ -474,7 +474,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# If @ reaches here as a literal argument, it's almost certainly user error.
if rest and str(rest[0]
).startswith("@") and not (has_piped_tag or has_piped_tag_list):
log("Selection syntax is only supported via piping. Use: @N | delete-tag")
log("Selection syntax is only supported via piping. Use: @N | metadata -delete")
return 1
# Special case: grouped tag selection created by the pipeline runner.
@@ -766,7 +766,7 @@ def _process_deletion(
remaining_titles = [t for t in current_titles if t.lower() not in del_title_set]
if current_titles and not remaining_titles:
log(
'Cannot delete the last title: tag. Add a replacement title first (add-tags "title:new title").',
'Cannot delete the last title: tag. Add a replacement title first (metadata -add "title:new title").',
file=sys.stderr,
)
return False
@@ -803,6 +803,3 @@ def _process_deletion(
return False
# Register cmdlet (no legacy decorator)
CMDLET.exec = _run
CMDLET.register()
@@ -37,7 +37,7 @@ from SYS.detail_view_helpers import create_detail_view, prepare_detail_metadata
from SYS.payload_builders import extract_title_tag_value
from SYS.result_publication import publish_result_table
from SYS.result_table_helpers import add_row_columns
from . import _shared as sh
from .. import _shared as sh
from SYS.field_access import get_field
normalize_hash = sh.normalize_hash
@@ -276,8 +276,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Get tags from Hydrus, local sidecar, or URL metadata.
Usage:
get-tag [-query "hash:<sha256>"] [--instance <key>] [--emit]
get-tag -scrape <url|provider>
metadata -get [-query "hash:<sha256>"] [--instance <key>] [--emit]
metadata -get -scrape <url|provider>
Options:
-query "hash:<sha256>": Override hash to use instead of result's hash
@@ -292,7 +292,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Internal implementation details for get-tag."""
"""Internal implementation details for metadata -get."""
emit_mode = False
is_store_backed = False
args_list = [str(arg) for arg in (args or [])]
@@ -329,7 +329,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return getattr(obj, field, default)
# Parse arguments using shared parser
parsed_args = parse_cmdlet_args(args_list, CMDLET)
parsed_args = parse_cmdlet_args(args_list, Get_Tag(register_cmdlet=False))
# Detect if -scrape flag was provided without a value (parse_cmdlet_args skips missing values)
scrape_flag_present = any(
@@ -660,7 +660,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
table = Table(f"Metadata: {provider.name}")
table.set_table(f"metadata.{provider.name}")
table.set_source_command("get-tag", [])
table.set_source_command("metadata", ["-get"])
selection_payload = []
hash_for_payload = normalize_hash(hash_override) or normalize_hash(
get_field(result,
@@ -956,15 +956,15 @@ _SCRAPE_CHOICES = [
class Get_Tag(Cmdlet):
"""Class-based get-tag cmdlet with self-registration."""
"""Class-based metadata -get tag handler with self-registration."""
def __init__(self) -> None:
"""Initialize get-tag cmdlet."""
def __init__(self, *, register_cmdlet: bool = True) -> None:
"""Initialize metadata -get handler."""
super().__init__(
name="get-tag",
name="tag",
summary="Get tag values from Hydrus or local sidecar metadata",
usage=
'get-tag [-query "hash:<sha256>"] [--instance <key>] [--emit] [-scrape <url|provider>]',
'metadata -get [-query "hash:<sha256>"] [--instance <key>] [--emit] [-scrape <url|provider>]',
alias=[],
arg=[
SharedArgs.QUERY,
@@ -1001,12 +1001,11 @@ class Get_Tag(Cmdlet):
],
exec=self.run,
)
self.register()
if register_cmdlet:
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Execute get-tag cmdlet."""
"""Execute metadata -get."""
return _run(result, args, config)
# Create and register the cmdlet
CMDLET = Get_Tag()
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, Sequence
def run_url_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Route metadata URL actions to URL cmdlets."""
act = str(action or "").strip().lower()
if act == "add":
from cmdlet.file.add_url import CMDLET as ADD_URL_CMDLET
return int(ADD_URL_CMDLET.run(result, args, config))
if act == "delete":
from cmdlet.delete_url import CMDLET as DELETE_URL_CMDLET
return int(DELETE_URL_CMDLET.run(result, args, config))
if act == "get":
from cmdlet.get_url import CMDLET as GET_URL_CMDLET
return int(GET_URL_CMDLET.run(result, args, config))
return 1
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from typing import Any, Dict, List, Sequence
import sys
from SYS.logger import log
from . import _shared as sh
Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg
SharedArgs = sh.SharedArgs
class Metadata(Cmdlet):
"""Unified metadata command with domain + action routing."""
_ACTION_FLAGS = {
"add": {"-add", "--add"},
"delete": {"-delete", "--delete", "-del", "--del"},
"get": {"-get", "--get"},
"inspect": {"-inspect", "--inspect", "-info", "--info", "-file", "--file"},
}
_DOMAIN_FLAGS = {
"tag": {"-tag", "--tag", "-tags", "--tags"},
"url": {"-url", "--url", "-urls", "--urls"},
"relationship": {
"-relationship",
"--relationship",
"-relationships",
"--relationships",
"-rel",
"--rel",
},
"note": {"-note", "--note", "-notes", "--notes"},
"file": {"-file", "--file"},
}
def __init__(self) -> None:
super().__init__(
name="metadata",
summary="Manage metadata domains with one command",
usage='metadata [-tag|-url|-relationship|-note] (-add|-delete|-get) [args] OR metadata -inspect [args]',
alias=["meta"],
arg=[
SharedArgs.QUERY,
SharedArgs.INSTANCE,
CmdletArg("-tag", type="flag", required=False, description="Metadata tag domain (default if omitted)"),
CmdletArg("-url", type="flag", required=False, description="URL metadata domain"),
CmdletArg("-relationship", type="flag", required=False, description="Relationship metadata domain", alias="rel"),
CmdletArg("-note", type="flag", required=False, description="Note metadata domain"),
CmdletArg("-add", type="flag", required=False, description="Add metadata tag value(s)"),
CmdletArg("-delete", type="flag", required=False, description="Delete metadata tag value(s)", alias="del"),
CmdletArg("-get", type="flag", required=False, description="Read metadata values for selected domain"),
CmdletArg("-inspect", type="flag", required=False, description="Inspect file metadata details", alias="info"),
CmdletArg(
"<tag>[,<tag>...]",
type="string",
required=False,
variadic=True,
description="Tag values for -add/-delete",
),
],
detail=[
"- Use one action flag: -add, -delete, -get, or -inspect.",
"- Domain flags: -tag (default), -url, -relationship, -note.",
"- Examples:",
" metadata -url -add <url>",
" metadata -url -delete <url>",
" metadata -relationship -get",
" metadata -note -add -query \"title:lyric text:...\"",
"- -inspect maps to get-metadata and prints rich metadata details.",
],
exec=self.run,
)
self.register()
@classmethod
def _extract_parts(
cls,
args: Sequence[str],
) -> tuple[str | None, str, List[str], List[str], List[str]]:
matched_actions: List[str] = []
matched_domains: List[str] = []
passthrough: List[str] = []
for token in args or []:
text = str(token or "")
lower = text.strip().lower()
matched = None
for action_name, variants in cls._ACTION_FLAGS.items():
if lower in variants:
matched = action_name
break
if matched:
matched_actions.append(matched)
continue
matched_domain = None
for domain_name, variants in cls._DOMAIN_FLAGS.items():
if lower in variants:
matched_domain = domain_name
break
if matched_domain:
matched_domains.append(matched_domain)
continue
passthrough.append(text)
unique_actions: List[str] = []
for action in matched_actions:
if action not in unique_actions:
unique_actions.append(action)
unique_domains: List[str] = []
for domain in matched_domains:
if domain not in unique_domains:
unique_domains.append(domain)
action = unique_actions[0] if len(unique_actions) == 1 else None
domain = unique_domains[0] if len(unique_domains) == 1 else "tag"
return action, domain, passthrough, unique_actions, unique_domains
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
action, domain, passthrough_args, seen_actions, seen_domains = self._extract_parts(args)
if action is None:
if not seen_actions:
log(
"metadata: missing action flag; choose exactly one of -add, -delete, -get, -inspect",
file=sys.stderr,
)
else:
rendered = ", ".join(f"-{name}" for name in seen_actions)
log(f"metadata: conflicting actions ({rendered}); choose exactly one", file=sys.stderr)
return 1
if len(seen_domains) > 1:
rendered_domains = ", ".join(f"-{name}" for name in seen_domains)
log(f"metadata: conflicting domains ({rendered_domains}); choose one domain", file=sys.stderr)
return 1
if action == "inspect":
from cmdlet.metadata.inspect import run_inspect_action
return run_inspect_action(result, passthrough_args, config)
if domain == "file":
log("metadata: -file only supports -inspect; use metadata -inspect", file=sys.stderr)
return 1
if domain == "tag":
from cmdlet.metadata.tag import run_tag_action
return run_tag_action(action, result, passthrough_args, config)
if domain == "url":
from cmdlet.metadata.url import run_url_action
return run_url_action(action, result, passthrough_args, config)
if domain == "relationship":
from cmdlet.metadata.relationship import run_relationship_action
return run_relationship_action(action, result, passthrough_args, config)
if domain == "note":
from cmdlet.metadata.note import run_note_action
return run_note_action(action, result, passthrough_args, config)
log(f"metadata: unsupported domain '{domain}'", file=sys.stderr)
return 1
CMDLET = Metadata()
+4 -4
View File
@@ -103,8 +103,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Check for -add flag
if "-add" in remaining_args:
# .adjective category -add tag
# or .adjective category tag -add
# .adjective category -add <value>
# or .adjective category <value> -add
add_idx = remaining_args.index("-add")
# Tag could be before or after
tag = None
@@ -126,8 +126,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Check for -delete flag
elif "-delete" in remaining_args:
# .adjective category -delete tag
# or .adjective category tag -delete
# .adjective category -delete <value>
# or .adjective category <value> -delete
del_idx = remaining_args.index("-delete")
tag = None
if del_idx + 1 < len(remaining_args):
+23 -23
View File
@@ -1,6 +1,6 @@
# Tag Template Syntax
This guide documents the reusable template syntax for tag mutation commands such as `add-tag` and `delete-tag`.
This guide documents the reusable template syntax for tag mutation commands such as `metadata -add` and `metadata -delete`.
The current goal is lowercase-first tagging. Examples in this document use lowercase tag names and lowercase text values, and no case-conversion transforms are part of the documented syntax.
@@ -8,8 +8,8 @@ The current goal is lowercase-first tagging. Examples in this document use lower
The shared template resolver currently applies to:
- `add-tag`
- `delete-tag`
- `metadata -add`
- `metadata -delete`
Templates are resolved per item against that item's current tag set and lightweight result fields such as the current title.
@@ -20,9 +20,9 @@ Use `#(namespace)` to insert the value from an existing namespaced tag.
Examples:
```powershell
add-tag "title:#(track) - #(series)"
add-tag "album:#(series)"
delete-tag "title:#(track) - #(series)"
metadata -add "title:#(track) - #(series)"
metadata -add "album:#(series)"
metadata -delete "title:#(track) - #(series)"
```
If an item has:
@@ -53,8 +53,8 @@ title:9 - ancient greek intensive course
Examples:
```powershell
add-tag "title:#(track #) - #(series)"
add-tag "code:#(disc number)"
metadata -add "title:#(track #) - #(series)"
metadata -add "code:#(disc number)"
```
## Transform Syntax
@@ -74,9 +74,9 @@ Use `padding`, `pad`, or `zfill` to zero-pad a value.
Examples:
```powershell
add-tag "code:e<padding(00,#(episode))>"
add-tag "code:e<pad(2,#(episode))>"
add-tag "code:e<zfill(2,#(episode))>"
metadata -add "code:e<padding(00,#(episode))>"
metadata -add "code:e<pad(2,#(episode))>"
metadata -add "code:e<zfill(2,#(episode))>"
```
If `episode:3` exists, each example resolves to:
@@ -99,8 +99,8 @@ Use `default(value,fallback)` when a namespace may be missing.
Examples:
```powershell
add-tag "season:<default(#(season),0)>"
add-tag "disc:<default(#(disc),1)>"
metadata -add "season:<default(#(season),0)>"
metadata -add "disc:<default(#(disc),1)>"
```
If `season:` is missing, the first example resolves to:
@@ -116,8 +116,8 @@ Use `replace(value,old,new)` for simple substring replacement.
Examples:
```powershell
add-tag "slug:<replace(#(title),' ',_)>"
add-tag "slug:<replace(#(series),-,_)>"
metadata -add "slug:<replace(#(title),' ',_)>"
metadata -add "slug:<replace(#(series),-,_)>"
```
If `title:ancient greek intensive course` exists, the first example resolves to:
@@ -135,8 +135,8 @@ Use `increment(value,amount)` to do small integer adjustments.
Examples:
```powershell
add-tag "episode_next:<increment(#(episode),1)>"
add-tag "disc_next:<increment(#(disc),1)>"
metadata -add "episode_next:<increment(#(episode),1)>"
metadata -add "disc_next:<increment(#(disc),1)>"
```
If `episode:3` exists, the first example resolves to:
@@ -154,7 +154,7 @@ Tag arguments still support comma-separated tags, but commas inside transform ca
This means the following stays as two tags, not three fragments:
```powershell
add-tag "code:e<padding(00,#(episode))>,title:#(series)"
metadata -add "code:e<padding(00,#(episode))>,title:#(series)"
```
## Combining With `-extract`
@@ -164,7 +164,7 @@ Templates are especially useful after deriving tags from a title.
Example:
```powershell
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
metadata -add -extract "(series) - part (track)" "title:#(track) - #(series)"
```
For a title like:
@@ -197,25 +197,25 @@ The command logs a warning summary for skipped unresolved templates.
Episode-style numbering:
```powershell
add-tag "code:e<padding(00,#(episode))>"
metadata -add "code:e<padding(00,#(episode))>"
```
Title synthesis from extracted tags:
```powershell
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
metadata -add -extract "(series) - part (track)" "title:#(track) - #(series)"
```
Delete a derived title tag:
```powershell
delete-tag "title:#(track) - #(series)"
metadata -delete "title:#(track) - #(series)"
```
Reuse an existing value under a new namespace:
```powershell
add-tag "album:#(series)"
metadata -add "album:#(series)"
```
## Mass Tagging Recipes
+1 -1
View File
@@ -392,7 +392,7 @@ def _dispatch_alldebrid_magnet_search(
config: Dict[str, Any],
) -> None:
try:
from cmdlet.search_file import CMDLET as _SEARCH_FILE_CMDLET
from cmdlet.file.search import CMDLET as _SEARCH_FILE_CMDLET
exec_fn = getattr(_SEARCH_FILE_CMDLET, "exec", None)
if callable(exec_fn):
+45
View File
@@ -549,6 +549,8 @@ class FTP(Provider):
if not local_path.exists() or not local_path.is_file():
raise FileNotFoundError(f"File not found: {local_path}")
pipe_obj = kwargs.get("pipe_obj")
settings = self._resolve_settings(
instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None,
require_explicit=bool(kwargs.get("instance") or kwargs.get("store")),
@@ -569,6 +571,19 @@ class FTP(Provider):
ftp = self._connect(settings=settings)
try:
self._ensure_directory(ftp, remote_dir, base_path=str(settings.get("base_path") or "/"))
# FTP duplicate check is filename-based at the destination directory.
# If the exact filename already exists remotely, skip re-upload.
if self._remote_filename_exists(ftp, remote_dir, remote_name):
try:
if pipe_obj is not None:
if not isinstance(getattr(pipe_obj, "extra", None), dict):
pipe_obj.extra = {}
pipe_obj.extra["upload_duplicate"] = True
pipe_obj.extra["upload_duplicate_rule"] = "filename"
pipe_obj.extra["upload_duplicate_target"] = remote_path
except Exception:
pass
return self._build_url(remote_path, settings=settings)
with local_path.open("rb") as handle:
ftp.storbinary(f"STOR {remote_path}", handle)
finally:
@@ -930,6 +945,36 @@ class FTP(Provider):
if not self._is_directory(ftp, partial):
raise
def _remote_filename_exists(self, ftp: ftplib.FTP, remote_dir: str, filename: str) -> bool:
target_name = str(filename or "").strip()
if not target_name:
return False
normalized_dir = self._normalize_remote_path(remote_dir, default=self._base_path)
try:
for name, facts in ftp.mlsd(normalized_dir):
_ = facts
if str(name or "").strip() == target_name:
return True
except Exception:
pass
try:
entries = ftp.nlst(normalized_dir)
except Exception:
entries = []
for entry in entries or []:
entry_text = str(entry or "").strip().rstrip("/")
if not entry_text:
continue
entry_name = posixpath.basename(entry_text)
if entry_name == target_name:
return True
return False
def _item_metadata(self, item: Any, *, pipe_obj: Any = None) -> Dict[str, Any]:
metadata: Dict[str, Any] = {}
for source in (item, pipe_obj):
+14 -14
View File
@@ -2842,7 +2842,7 @@ local function _start_screenshot_store_save(store, out_path, tags)
if screenshot_url == '' or not screenshot_url:match('^https?://') then
screenshot_url = ''
end
local cmd = 'add-file -store ' .. quote_pipeline_arg(store)
local cmd = 'file -add -store ' .. quote_pipeline_arg(store)
.. ' -path ' .. quote_pipeline_arg(out_path)
if screenshot_url ~= '' then
cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url)
@@ -2854,7 +2854,7 @@ local function _start_screenshot_store_save(store, out_path, tags)
local tag_suffix = (#tag_list > 0) and (' | tags: ' .. tostring(#tag_list)) or ''
if #tag_list > 0 then
local tag_string = table.concat(tag_list, ',')
cmd = cmd .. ' | add-tag ' .. quote_pipeline_arg(tag_string)
cmd = cmd .. ' | tag -add ' .. quote_pipeline_arg(tag_string)
end
local queue_target = is_named_store and ('store ' .. store) or 'folder'
@@ -5539,7 +5539,7 @@ local function _start_download_flow_for_current()
end
ensure_mpv_ipc_server()
local pipeline_cmd = 'get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder)
local pipeline_cmd = 'file -get -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder)
_queue_pipeline_in_repl(
pipeline_cmd,
'Queued in REPL: store copy',
@@ -5835,9 +5835,9 @@ mp.register_script_message('medios-download-pick-store', function(json)
end
local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or ''
local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url)
local pipeline_cmd = 'file -download -url ' .. quote_pipeline_arg(url)
.. ' -query ' .. quote_pipeline_arg(query)
.. ' | add-file -store ' .. quote_pipeline_arg(store)
.. ' | file -add -store ' .. quote_pipeline_arg(store)
_set_selected_store(store)
_queue_pipeline_in_repl(
@@ -5901,9 +5901,9 @@ mp.register_script_message('medios-download-pick-path', function()
end
local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or ''
local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url)
local pipeline_cmd = 'file -download -url ' .. quote_pipeline_arg(url)
.. ' -query ' .. quote_pipeline_arg(query)
.. ' | add-file -path ' .. quote_pipeline_arg(folder)
.. ' | file -add -path ' .. quote_pipeline_arg(folder)
_queue_pipeline_in_repl(
pipeline_cmd,
@@ -6137,7 +6137,7 @@ function M.delete_current_file()
local seed = {{path = path}}
M.run_pipeline('delete-file', seed, function(_, err)
M.run_pipeline('file -delete', seed, function(_, err)
if err then
mp.osd_message('Delete failed: ' .. tostring(err), 3)
return
@@ -6302,17 +6302,17 @@ local function _start_trim_with_range(range)
_lua_log('trim: building store file pipeline (original from store)')
if selected_store then
pipeline_cmd =
'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
' | add-file -path ' .. quote_pipeline_arg(output_path) ..
' | file -add -path ' .. quote_pipeline_arg(output_path) ..
' -store "' .. selected_store .. '"' ..
' | add-relationship -store "' .. selected_store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
else
pipeline_cmd =
'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
' | add-file -path ' .. quote_pipeline_arg(output_path) ..
' | file -add -path ' .. quote_pipeline_arg(output_path) ..
' -store "' .. store_hash.store .. '"' ..
' | add-relationship -store "' .. store_hash.store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
@@ -6321,9 +6321,9 @@ local function _start_trim_with_range(range)
-- Local file: save to selected store if available
_lua_log('trim: local file pipeline (not from store)')
if selected_store then
_lua_log('trim: building add-file command to selected_store=' .. selected_store)
_lua_log('trim: building file -add command to selected_store=' .. selected_store)
-- Don't add title if empty - the file path will be used as title by default
pipeline_cmd = 'add-file -path ' .. quote_pipeline_arg(output_path) ..
pipeline_cmd = 'file -add -path ' .. quote_pipeline_arg(output_path) ..
' -store "' .. selected_store .. '"'
_lua_log('trim: pipeline_cmd=' .. pipeline_cmd)
else
+3 -3
View File
@@ -470,11 +470,11 @@ class MPV:
def _q(s: str) -> str:
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
pipeline = f"download-file -url {_q(url)} -query {_q(f'format:{fmt}')}"
pipeline = f"file -download -url {_q(url)} -query {_q(f'format:{fmt}')}"
if store:
pipeline += f" | add-file -instance {_q(store)}"
pipeline += f" | file -add -instance {_q(store)}"
else:
pipeline += f" | add-file -path {_q(path or '')}"
pipeline += f" | file -add -path {_q(path or '')}"
try:
from TUI.pipeline_runner import PipelineRunner # noqa: WPS433
+37
View File
@@ -582,6 +582,8 @@ class SCP(Provider):
if not local_path.exists() or not local_path.is_file():
raise FileNotFoundError(f"File not found: {local_path}")
pipe_obj = kwargs.get("pipe_obj")
settings = self._resolve_settings(
instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None,
require_explicit=bool(kwargs.get("instance") or kwargs.get("store")),
@@ -609,8 +611,30 @@ class SCP(Provider):
if not self._is_sftp_negotiation_error(exc):
raise
self._ensure_directory_via_ssh(ssh, remote_dir)
if self._remote_filename_exists_via_ssh(ssh, remote_path):
try:
if pipe_obj is not None:
if not isinstance(getattr(pipe_obj, "extra", None), dict):
pipe_obj.extra = {}
pipe_obj.extra["upload_duplicate"] = True
pipe_obj.extra["upload_duplicate_rule"] = "filename"
pipe_obj.extra["upload_duplicate_target"] = remote_path
except Exception:
pass
return self._build_url(remote_path, settings=settings)
else:
self._ensure_directory(sftp, remote_dir, base_path=str(settings.get("base_path") or "/"))
if self._remote_filename_exists(sftp, remote_path):
try:
if pipe_obj is not None:
if not isinstance(getattr(pipe_obj, "extra", None), dict):
pipe_obj.extra = {}
pipe_obj.extra["upload_duplicate"] = True
pipe_obj.extra["upload_duplicate_rule"] = "filename"
pipe_obj.extra["upload_duplicate_target"] = remote_path
except Exception:
pass
return self._build_url(remote_path, settings=settings)
scp_client = self._open_scp(ssh)
scp_client.put(str(local_path), remote_path=remote_path)
finally:
@@ -620,6 +644,19 @@ class SCP(Provider):
return self._build_url(remote_path, settings=settings)
def _remote_filename_exists(self, sftp: Any, remote_path: str) -> bool:
try:
sftp.stat(remote_path)
return True
except Exception:
return False
def _remote_filename_exists_via_ssh(self, ssh: Any, remote_path: str) -> bool:
normalized = self._normalize_remote_path(remote_path, default=self._base_path)
quoted_path = shlex.quote(normalized)
status, _, _ = self._run_ssh_command(ssh, f"test -e {quoted_path}")
return status == 0
def _run_test_connection(self) -> Dict[str, Any]:
settings = self._resolve_settings()
if not settings.get("host"):