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 return None
@staticmethod @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() 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"}: if canonical_cmd not in {"search-file", "add-file", "download-file"}:
return None return None
return CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin") return CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin")
@@ -778,7 +799,7 @@ class CmdletCompleter(Completer):
if not arg_names: if not arg_names:
return [] 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) plugin_name = self._selected_plugin_name(canonical_cmd, stage_tokens)
instance_choices = self._plugin_instance_choices(plugin_name, config) instance_choices = self._plugin_instance_choices(plugin_name, config)
has_named_instances = bool(instance_choices) has_named_instances = bool(instance_choices)
@@ -862,6 +883,7 @@ class CmdletCompleter(Completer):
return return
cmd_name = stage_tokens[0].replace("_", "-").lower() cmd_name = stage_tokens[0].replace("_", "-").lower()
effective_cmd = self._effective_cmd_name(cmd_name, stage_tokens)
if ends_with_space: if ends_with_space:
current_token = "" current_token = ""
prev_token = stage_tokens[-1].lower() prev_token = stage_tokens[-1].lower()
@@ -872,12 +894,12 @@ class CmdletCompleter(Completer):
config = self._config_loader.load_shared() config = self._config_loader.load_shared()
provider_name = None provider_name = None
if cmd_name == "search-file": if effective_cmd == "search-file":
provider_name = self._flag_value(stage_tokens, "-plugin", "--plugin") 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 query_flag_index = -1
for idx, tok in enumerate(stage_tokens): for idx, tok in enumerate(stage_tokens):
if str(tok or "").strip().lower() in {"-query", "--query"}: if str(tok or "").strip().lower() in {"-query", "--query"}:
@@ -923,7 +945,7 @@ class CmdletCompleter(Completer):
partial_lower = partial.strip().lower() partial_lower = partial.strip().lower()
inline_choices = [] 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) inline_choices = self._inline_query_choices(provider_name, field, config)
choice_pool = inline_choices or field_choices.get(field, []) choice_pool = inline_choices or field_choices.get(field, [])
@@ -948,7 +970,7 @@ class CmdletCompleter(Completer):
return return
if ( if (
cmd_name == "search-file" effective_cmd == "search-file"
and provider_name and provider_name
and not ends_with_space and not ends_with_space
and ":" in current_token and ":" in current_token
@@ -990,7 +1012,7 @@ class CmdletCompleter(Completer):
choices = self._plugin_instance_choices(selected_plugin, config) choices = self._plugin_instance_choices(selected_plugin, config)
if not choices: if not choices:
choices = self._arg_choices( choices = self._arg_choices(
cmd_name=cmd_name, cmd_name=effective_cmd,
arg_name=prev_token, arg_name=prev_token,
config=config, config=config,
force=False, force=False,
@@ -1016,12 +1038,12 @@ class CmdletCompleter(Completer):
return return
arg_names = self._filter_stage_arg_names( arg_names = self._filter_stage_arg_names(
cmd_name=cmd_name, cmd_name=effective_cmd,
stage_tokens=stage_tokens, stage_tokens=stage_tokens,
config=config, 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() logical_seen: Set[str] = set()
for arg in arg_names: for arg in arg_names:
arg_low = arg.lower() arg_low = arg.lower()
@@ -1228,6 +1250,26 @@ class CmdletExecutor:
emitted_items: Optional[List[Any]] = None, emitted_items: Optional[List[Any]] = None,
cmd_args: Optional[List[str]] = None, cmd_args: Optional[List[str]] = None,
) -> str: ) -> 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 = { title_map = {
"search-file": "Results", "search-file": "Results",
"search_file": "Results", "search_file": "Results",
@@ -1235,14 +1277,9 @@ class CmdletExecutor:
"download_data": "Downloads", "download_data": "Downloads",
"download-file": "Downloads", "download-file": "Downloads",
"download_file": "Downloads", "download_file": "Downloads",
"get-tag": "Tags", "metadata": "Tags",
"get_tag": "Tags",
"get-file": "Results", "get-file": "Results",
"get_file": "Results", "get_file": "Results",
"add-tags": "Results",
"add_tags": "Results",
"delete-tag": "Results",
"delete_tag": "Results",
"add-url": "Results", "add-url": "Results",
"add_url": "Results", "add_url": "Results",
"get-url": "url", "get-url": "url",
@@ -1266,7 +1303,7 @@ class CmdletExecutor:
"get-metadata": None, "get-metadata": None,
"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: if mapped is not None:
return mapped return mapped
@@ -1358,10 +1395,25 @@ class CmdletExecutor:
) -> None: ) -> None:
nonlocal progress_ui, pipe_idx 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. # Keep behavior consistent with pipeline runner exclusions.
# Some commands render their own Rich UI (tables/panels) and don't # Some commands render their own Rich UI (tables/panels) and don't
# play nicely with Live cursor control. # play nicely with Live cursor control.
if cmd_name_norm in { if effective_cmd in {
"get-relationship", "get-relationship",
"get-rel", "get-rel",
".pipe", ".pipe",
@@ -1375,8 +1427,7 @@ class CmdletExecutor:
return return
# add-file directory selector mode: show only the selection table, no Live progress. # add-file directory selector mode: show only the selection table, no Live progress.
if cmd_name_norm in {"add-file", if effective_cmd in {"add-file", "add_file"}:
"add_file"}:
try: try:
from pathlib import Path as _Path from pathlib import Path as _Path
@@ -1457,8 +1508,7 @@ class CmdletExecutor:
while i < len(toks): while i < len(toks):
t = str(toks[i]) t = str(toks[i])
low = t.lower().strip() low = t.lower().strip()
if (cmd_name_norm in {"add-file", if (effective_cmd in {"add-file", "add_file"} and low in {"-path",
"add_file"} and low in {"-path",
"--path", "--path",
"-p"} "-p"}
and i + 1 < len(toks)): and i + 1 < len(toks)):
@@ -1706,6 +1756,25 @@ class CmdletExecutor:
filtered_args 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 = { selectable_commands = {
"search-file", "search-file",
"download-data", "download-data",
@@ -1729,8 +1798,7 @@ class CmdletExecutor:
"get_metadata", "get_metadata",
} }
self_managing_commands = { self_managing_commands = {
"get-tag", "tag",
"get_tag",
"tags", "tags",
"get-metadata", "get-metadata",
"get_metadata", "get_metadata",
@@ -1740,9 +1808,10 @@ class CmdletExecutor:
"search_file", "search_file",
"add-file", "add-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 = ( table = (
ctx.get_display_table() ctx.get_display_table()
if hasattr(ctx, "get_display_table") else None if hasattr(ctx, "get_display_table") else None
@@ -1758,11 +1827,11 @@ class CmdletExecutor:
for emitted in emits: for emitted in emits:
table.add_result(emitted) table.add_result(emitted)
if cmd_name in selectable_commands: if effective_cmd in selectable_commands:
table.set_source_command(cmd_name, filtered_args) table.set_source_command(effective_cmd, filtered_args)
ctx.set_last_result_table(table, emits) ctx.set_last_result_table(table, emits)
ctx.set_current_stage_table(None) 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) ctx.set_last_result_items_only(emits)
else: else:
ctx.set_last_result_items_only(emits) 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.""" """True if tag writes should be deferred until after file ingest."""
return False 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: def add_file(self, file_path: Path, **kwargs: Any) -> str:
"""Ingest a file and return its canonical hash.""" """Ingest a file and return its canonical hash."""
raise NotImplementedError(f"Plugin '{self.name}' does not support add_file") 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: if not info.is_multi_instance:
continue continue
if not info.supported_cmdlets.intersection( if not info.supported_cmdlets.intersection(
{"add-file", "get-file", "get-tag", "add-tag"} {"add-file", "get-file", "tag"}
): ):
continue continue
try: try:
@@ -544,6 +544,9 @@ def get_plugin_capabilities(
"supports_upload": False, "supports_upload": False,
"supports_pipe_download": False, "supports_pipe_download": False,
"supports_delete_file": False, "supports_delete_file": False,
"supports_url_association": False,
"supports_note_association": False,
"supports_relationship_association": False,
"is_multi_instance": False, "is_multi_instance": False,
"configured_instances": [], "configured_instances": [],
} }
@@ -559,11 +562,20 @@ def get_plugin_capabilities(
supports_delete_file = callable(delete_method) and delete_method is not base_delete_method supports_delete_file = callable(delete_method) and delete_method is not base_delete_method
configured_instances: List[str] = [] configured_instances: List[str] = []
supports_url_association = False
supports_note_association = False
supports_relationship_association = False
try: try:
plugin_obj = info.plugin_class(config or {}) plugin_obj = info.plugin_class(config or {})
configured_instances = [str(v) for v in (plugin_obj.configured_instances() or []) if str(v).strip()] 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: except Exception:
configured_instances = [] configured_instances = []
supports_url_association = False
supports_note_association = False
supports_relationship_association = False
return { return {
"name": info.canonical_name, "name": info.canonical_name,
@@ -572,6 +584,9 @@ def get_plugin_capabilities(
"supports_upload": bool(info.supports_upload), "supports_upload": bool(info.supports_upload),
"supports_pipe_download": bool(supports_pipe_download), "supports_pipe_download": bool(supports_pipe_download),
"supports_delete_file": bool(supports_delete_file), "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), "is_multi_instance": bool(info.is_multi_instance),
"configured_instances": configured_instances, "configured_instances": configured_instances,
} }
+69 -26
View File
@@ -1282,15 +1282,30 @@ class PipelineExecutor:
def _norm(name: str) -> str: def _norm(name: str) -> str:
return str(name or "").replace("_", "-").strip().lower() 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] = [] names: List[str] = []
for stage in stages or []: for stage in stages or []:
if not stage: if not stage:
continue continue
names.append(_norm(stage[0])) 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"] 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: if not dl_idxs or not rel_idxs:
return True return True
@@ -1981,6 +1996,23 @@ class PipelineExecutor:
def _norm_cmd_name(value: Any) -> str: def _norm_cmd_name(value: Any) -> str:
return str(value or "").replace("_", "-").strip().lower() 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 # PHASE 2: Parse source command and table metadata
# ============================================================================ # ============================================================================
@@ -2333,8 +2365,8 @@ class PipelineExecutor:
# ==================================================================== # ====================================================================
if not stages: if not stages:
if isinstance(table_type, str) and table_type.startswith("metadata."): if isinstance(table_type, str) and table_type.startswith("metadata."):
print("Auto-applying metadata selection via get-tag") print("Auto-applying metadata selection via metadata -get")
stages.append(["get-tag"]) stages.append(["metadata", "-get"])
elif auto_stage: elif auto_stage:
try: try:
print(f"Auto-running selection via {auto_stage[0]}") print(f"Auto-running selection via {auto_stage[0]}")
@@ -2383,12 +2415,12 @@ class PipelineExecutor:
first_cmd_norm = _norm_cmd_name(first_cmd) first_cmd_norm = _norm_cmd_name(first_cmd)
inserted_provider_download = False 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, # If selected rows advertise an explicit download-file action,
# run download before add-file so add-file receives local files. # run download before add-file so add-file receives local files.
if len(selection_indices) == 1: if len(selection_indices) == 1:
row_action = _get_row_action(selection_indices[0], items_list) 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]) stages.insert(0, [str(x) for x in row_action if x is not None])
inserted_provider_download = True inserted_provider_download = True
debug("Auto-inserting row download-file action before add-file") debug("Auto-inserting row download-file action before add-file")
@@ -2401,24 +2433,24 @@ class PipelineExecutor:
has_download_row_action = False has_download_row_action = False
for idx in selection_indices: for idx in selection_indices:
row_action = _get_row_action(idx, items_list) 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 has_download_row_action = True
break break
if has_download_row_action: if has_download_row_action:
stages.insert(0, ["download-file"]) stages.insert(0, ["file", "-download"])
inserted_provider_download = True inserted_provider_download = True
debug("Auto-inserting download-file before add-file for provider selection") debug("Auto-inserting download-file before add-file for provider selection")
except Exception: except Exception:
pass pass
if isinstance(table_type, str) and table_type.startswith("metadata.") and first_cmd not in ( if isinstance(table_type, str) and table_type.startswith("metadata.") and first_cmd not in (
"get-tag", "metadata",
"get_tag", "tag",
".pipe", ".pipe",
".mpv", ".mpv",
): ):
print("Auto-inserting get-tag after metadata selection") print("Auto-inserting metadata -get after metadata selection")
stages.insert(0, ["get-tag"]) stages.insert(0, ["metadata", "-get"])
elif auto_stage: elif auto_stage:
first_cmd_norm = _norm_cmd_name(stages[0][0] if stages and stages[0] else None) 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]) 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 # add-file directory selector stage: avoid Live progress so the
# selection table renders cleanly. # selection table renders cleanly.
if name in {"add-file", if _stage_file_action(stage_tokens) == "add-file" or name in {"add_file"}:
"add_file"}:
try: try:
from pathlib import Path as _Path from pathlib import Path as _Path
@@ -2559,8 +2590,7 @@ class PipelineExecutor:
continue continue
# `delete-file` prints a Rich table directly; Live progress interferes and # `delete-file` prints a Rich table directly; Live progress interferes and
# can truncate/overwrite the output. # can truncate/overwrite the output.
if name in {"delete-file", if _stage_file_action(stage_tokens) == "delete-file" or name in {"del-file"}:
"del-file"}:
continue continue
pipe_stage_indices.append(idx) pipe_stage_indices.append(idx)
pipe_labels.append(name) pipe_labels.append(name)
@@ -2707,7 +2737,7 @@ class PipelineExecutor:
stage_args = stage_tokens[1:] stage_args = stage_tokens[1:]
if cmd_name == "@": 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. # underlying file subject once, not each emitted TagItem row.
try: try:
next_cmd = None next_cmd = None
@@ -2721,28 +2751,36 @@ class PipelineExecutor:
current_table = None current_table = None
source_cmd = str(getattr(current_table, "source_command", "") or "").replace("_", "-").strip().lower() 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() subject = ctx.get_last_result_subject()
if subject is not None: if subject is not None:
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 piped_result = subject
try: try:
subject_items = subject if isinstance(subject, list) else [subject] subject_items = subject if isinstance(subject, list) else [subject]
ctx.set_last_items(subject_items) ctx.set_last_items(subject_items)
except Exception: except Exception:
logger.exception("Failed to set last_items from get-tag subject during @ handling") logger.exception("Failed to set last_items from tag subject during @ handling")
if pipeline_session and worker_manager: if pipeline_session and worker_manager:
try: try:
worker_manager.log_step( worker_manager.log_step(
pipeline_session.worker_id, pipeline_session.worker_id,
"@ used get-tag table subject for add-tag" "@ used metadata table subject for metadata -add"
) )
except Exception: 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)) 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 continue
except Exception: 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) # Prefer piping the last emitted/visible items (e.g. add-file results)
# over the result-table subject. The subject can refer to older context # 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))): stage_is_last=(stage_index + 1 >= len(stages))):
return 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). # should batch into a single operation (one backend call).
next_cmd = None next_cmd = None
next_args: List[str] = []
try: try:
if stage_index + 1 < len(stages) and stages[stage_index + 1]: if stage_index + 1 < len(stages) and stages[stage_index + 1]:
next_cmd = str(stages[stage_index + 1][0] next_cmd = str(stages[stage_index + 1][0]
).replace("_", ).replace("_",
"-").lower() "-").lower()
next_args = [
str(x).replace("_", "-").strip().lower()
for x in stages[stage_index + 1][1:]
]
except Exception: except Exception:
logger.exception("Failed to determine next_cmd during selection expansion for stage_index %s", stage_index) logger.exception("Failed to determine next_cmd during selection expansion for stage_index %s", stage_index)
next_cmd = None next_cmd = None
next_args = []
def _is_tag_row(obj: Any) -> bool: def _is_tag_row(obj: Any) -> bool:
try: try:
@@ -2991,8 +3035,7 @@ class PipelineExecutor:
logger.exception("Failed to inspect dict tag_name while checking _is_tag_row") logger.exception("Failed to inspect dict tag_name while checking _is_tag_row")
return False return False
if (next_cmd in {"delete-tag", if (next_cmd == "tag" and ("-delete" in next_args or "--delete" in next_args) and len(filtered) > 1
"delete_tag"} and len(filtered) > 1
and all(_is_tag_row(x) for x in filtered)): and all(_is_tag_row(x) for x in filtered)):
from SYS.field_access import get_field 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.""" """True if the store prefers tags to be applied after the file is added."""
return False 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 @abstractmethod
def add_file(self, file_path: Path, **kwargs: Any) -> str: def add_file(self, file_path: Path, **kwargs: Any) -> str:
raise NotImplementedError raise NotImplementedError
+32 -33
View File
@@ -224,7 +224,7 @@ class TagEditorPopup(ModalScreen[None]):
if not tags: if not tags:
try: try:
runner: PipelineRunner = getattr(app, "executor") runner: PipelineRunner = getattr(app, "executor")
cmd = "@1 | get-tag" cmd = "@1 | metadata -get"
res = runner.run_pipeline(cmd, seeds=self._seeds, isolate=True) res = runner.run_pipeline(cmd, seeds=self._seeds, isolate=True)
tags = _extract_tag_names_from_table(getattr(res, "result_table", None)) tags = _extract_tag_names_from_table(getattr(res, "result_table", None))
if not tags: if not tags:
@@ -364,10 +364,10 @@ class TagEditorPopup(ModalScreen[None]):
if to_del: if to_del:
del_args = " ".join(json.dumps(t) for t in 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}"
_log_pipeline_command("delete-tag", del_cmd) _log_pipeline_command("metadata-delete", del_cmd)
del_res = runner.run_pipeline(del_cmd, seeds=self._seeds, isolate=True) 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): if not getattr(del_res, "success", False):
failures.append( failures.append(
str( str(
@@ -375,16 +375,16 @@ class TagEditorPopup(ModalScreen[None]):
"error", "error",
"") or getattr(del_res, "") or getattr(del_res,
"stderr", "stderr",
"") or "delete-tag failed" "") or "metadata -delete failed"
).strip() ).strip()
) )
if to_add: if to_add:
add_args = " ".join(json.dumps(t) for t in 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}"
_log_pipeline_command("add-tag", add_cmd) _log_pipeline_command("metadata-add", add_cmd)
add_res = runner.run_pipeline(add_cmd, seeds=self._seeds, isolate=True) 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): if not getattr(add_res, "success", False):
failures.append( failures.append(
str( str(
@@ -392,7 +392,7 @@ class TagEditorPopup(ModalScreen[None]):
"error", "error",
"") or getattr(add_res, "") or getattr(add_res,
"stderr", "stderr",
"") or "add-tag failed" "") or "metadata -add failed"
).strip() ).strip()
) )
@@ -1027,8 +1027,8 @@ class PipelineHubApp(App):
"""Apply store/path/tags UI fields to the pipeline text. """Apply store/path/tags UI fields to the pipeline text.
Rules (simple + non-destructive): Rules (simple + non-destructive):
- If output path is set and the first stage is download-file and has no -path/--path, append -path. - 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 add-file stage, append add-file -instance <name>. - If an instance is selected and pipeline has no file-add stage, append file -add -instance <name>.
""" """
base = str(pipeline_text or "").strip() base = str(pipeline_text or "").strip()
if not base: if not base:
@@ -1058,29 +1058,28 @@ class PipelineHubApp(App):
except Exception: except Exception:
first_stage_cmd = "" 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: if output_path:
first = stages[0] first = stages[0]
low = first.lower() low = first.lower()
if low.startswith("download-file" if (low.startswith("download-file") or (low.startswith("file ") and " -download" in low)) and " -path" not in low and " --path" not in low:
) and " -path" not in low and " --path" not in low:
stages[0] = f"{first} -path {json.dumps(output_path)}" stages[0] = f"{first} -path {json.dumps(output_path)}"
joined = " | ".join(stages) joined = " | ".join(stages)
low_joined = joined.lower() 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( should_auto_add_file = bool(
selected_store and ("add-file" not in low_joined) and ( selected_store and ("add-file" not in low_joined and "file -add" not in low_joined) and (
first_stage_cmd first_stage_cmd in {"download-file"}
in {"download-file"} or (first_stage_cmd == "file" and " -download" in stages[0].lower())
) )
) )
if should_auto_add_file: if should_auto_add_file:
store_token = json.dumps(selected_store) store_token = json.dumps(selected_store)
joined = f"{joined} | add-file -instance {store_token}" joined = f"{joined} | file -add -instance {store_token}"
return joined return joined
@@ -1092,13 +1091,13 @@ class PipelineHubApp(App):
return command return command
low = command.lower() low = command.lower()
if "add-tag" in low: if "metadata -add" in low:
# User already controls tag stage explicitly. # User already controls metadata tag stage explicitly.
self._pending_pipeline_tags_applied = False self._pending_pipeline_tags_applied = False
return command return command
# Apply draft tags when pipeline stores/emits files via add-file. # Apply draft tags when pipeline stores/emits files via file-add.
if "add-file" not in low: if "add-file" not in low and "file -add" not in low:
self._pending_pipeline_tags_applied = False self._pending_pipeline_tags_applied = False
return command return command
@@ -1109,7 +1108,7 @@ class PipelineHubApp(App):
self._pending_pipeline_tags_applied = True self._pending_pipeline_tags_applied = True
self.notify(f"Applying {len(pending)} pending tag(s) after pipeline", timeout=3) 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: def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if not self.results_table or event.control is not self.results_table: if not self.results_table or event.control is not self.results_table:
@@ -1656,27 +1655,27 @@ class PipelineHubApp(App):
try: try:
if to_del: if to_del:
del_args = " ".join(json.dumps(t) for t in 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) del_res = runner.run_pipeline(del_cmd, seeds=seeds, isolate=True)
if not getattr(del_res, "success", False): if not getattr(del_res, "success", False):
failures.append( failures.append(
str( str(
getattr(del_res, "error", "") getattr(del_res, "error", "")
or getattr(del_res, "stderr", "") or getattr(del_res, "stderr", "")
or "delete-tag failed" or "metadata -delete failed"
).strip() ).strip()
) )
if to_add: if to_add:
add_args = " ".join(json.dumps(t) for t in 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) add_res = runner.run_pipeline(add_cmd, seeds=seeds, isolate=True)
if not getattr(add_res, "success", False): if not getattr(add_res, "success", False):
failures.append( failures.append(
str( str(
getattr(add_res, "error", "") getattr(add_res, "error", "")
or getattr(add_res, "stderr", "") or getattr(add_res, "stderr", "")
or "add-tag failed" or "metadata -add failed"
).strip() ).strip()
) )
@@ -1835,7 +1834,7 @@ class PipelineHubApp(App):
if not store_name or not file_hash: if not store_name or not file_hash:
return return
try: try:
from cmdlet.get_tag import _emit_tags_as_table from cmdlet.metadata.tag_get import _emit_tags_as_table
except Exception: except Exception:
return return
@@ -2358,7 +2357,7 @@ class PipelineHubApp(App):
self.notify("Delete action requires store + hash", severity="warning", timeout=3) self.notify("Delete action requires store + hash", severity="warning", timeout=3)
return return
query = f"hash:{hash_value}" 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) self._start_pipeline_execution(cmd)
return return
@@ -2398,11 +2397,11 @@ class PipelineHubApp(App):
query = f"hash:{hash_value}" query = f"hash:{hash_value}"
base_copy = ( base_copy = (
f"search-file -instance {json.dumps(store_name)} {json.dumps(query)}" f"file -search -instance {json.dumps(store_name)} {json.dumps(query)}"
f" | add-file -instance {json.dumps(selected_store)}" f" | file -add -instance {json.dumps(selected_store)}"
) )
if action == "move_to_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}" cmd = f"{base_copy} | @ | {delete_cmd}"
else: else:
cmd = base_copy cmd = base_copy
+5 -5
View File
@@ -30,21 +30,21 @@ PIPELINE_PRESETS: List[PipelinePreset] = [
PipelinePreset( PipelinePreset(
label="Download → Merge → Local", label="Download → Merge → Local",
description= 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= 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( PipelinePreset(
label="Download → Hydrus", label="Download → Hydrus",
description="Fetch media, auto-tag, and push directly into Hydrus.", description="Fetch media, auto-tag, and push directly into Hydrus.",
pipeline= 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( PipelinePreset(
label="Search Local Library", label="Search Local Library",
description= description=
"Run search-file against the local library and emit a result table for further piping.", "Run file -search against the local library and emit a result table for further piping.",
pipeline='search-file -library local -query "<keywords>"', pipeline='file -search -library local -query "<keywords>"',
), ),
] ]
+28 -27
View File
@@ -404,7 +404,7 @@ class DownloadModal(ModalScreen):
download_succeeded = False download_succeeded = False
download_stderr_text = "" # Store for merge stage download_stderr_text = "" # Store for merge stage
if download_enabled: if download_enabled:
download_cmdlet_name = "download-file" download_cmdlet_name = "file"
download_cmdlet = get_cmdlet(download_cmdlet_name) download_cmdlet = get_cmdlet(download_cmdlet_name)
if download_cmdlet: if download_cmdlet:
logger.info(f"📥 Executing {download_cmdlet_name} stage") logger.info(f"📥 Executing {download_cmdlet_name} stage")
@@ -416,7 +416,7 @@ class DownloadModal(ModalScreen):
worker.log_step(f"Starting {download_cmdlet_name} stage...") worker.log_step(f"Starting {download_cmdlet_name} stage...")
# Build yt-dlp playlist arguments for download-file streaming (if applicable). # Build yt-dlp playlist arguments for download-file streaming (if applicable).
cmdlet_args = [] cmdlet_args = ["-download"]
if self.is_playlist: if self.is_playlist:
# Always use yt-dlp's native --playlist-items for playlists # Always use yt-dlp's native --playlist-items for playlists
if playlist_selection: if playlist_selection:
@@ -807,7 +807,7 @@ class DownloadModal(ModalScreen):
# Stage 2: Merge files if enabled and this is a playlist (BEFORE tagging) # Stage 2: Merge files if enabled and this is a playlist (BEFORE tagging)
merged_file_path = None merged_file_path = None
if merge_enabled and download_succeeded and self.is_playlist: if merge_enabled and download_succeeded and self.is_playlist:
merge_cmdlet = get_cmdlet("merge-file") merge_cmdlet = get_cmdlet("file")
if merge_cmdlet: if merge_cmdlet:
from pathlib import Path from pathlib import Path
@@ -818,6 +818,7 @@ class DownloadModal(ModalScreen):
worker.log_step("Starting merge-file stage...") worker.log_step("Starting merge-file stage...")
merge_args = [ merge_args = [
"-merge",
"-delete", "-delete",
"-format", "-format",
"mka", "mka",
@@ -963,10 +964,10 @@ class DownloadModal(ModalScreen):
else: else:
logger.info("merge-file cmdlet not found") 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 merge succeeded, result_obj now points to merged file
if tags and (download_succeeded or not download_enabled): 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: if add_tags_cmdlet:
logger.info(f"Executing add-tags stage with {len(tags)} tags") logger.info(f"Executing add-tags stage with {len(tags)} tags")
logger.info(f" Tags: {tags}") logger.info(f" Tags: {tags}")
@@ -980,9 +981,9 @@ class DownloadModal(ModalScreen):
f"Starting add-tags stage with {len(tags)} tags..." 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 = ( tag_args = (
["-instance", ["-add", "-instance",
"local"] + [str(t) for t in tags] + ["--source", "local"] + [str(t) for t in tags] + ["--source",
str(source)] str(source)]
) )
@@ -1051,7 +1052,7 @@ class DownloadModal(ModalScreen):
self.app.call_from_thread(self._hide_progress) self.app.call_from_thread(self._hide_progress)
return return
else: else:
logger.error("add-tags cmdlet not found") logger.error("metadata cmdlet not found for add stage")
else: else:
if tags and download_enabled and not download_succeeded: if tags and download_enabled and not download_succeeded:
skip_msg = "⚠️ Skipping add-tags stage because download failed" skip_msg = "⚠️ Skipping add-tags stage because download failed"
@@ -1455,7 +1456,7 @@ class DownloadModal(ModalScreen):
# Tag the file if tags provided # Tag the file if tags provided
if tags and get_cmdlet: if tags and get_cmdlet:
tag_cmdlet = get_cmdlet("add-tags") tag_cmdlet = get_cmdlet("metadata")
if tag_cmdlet: if tag_cmdlet:
logger.info(f"Tagging merged PDF with {len(tags)} tags") logger.info(f"Tagging merged PDF with {len(tags)} tags")
@@ -1475,7 +1476,7 @@ class DownloadModal(ModalScreen):
stdout_buf = io.StringIO() stdout_buf = io.StringIO()
stderr_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): with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
tag_returncode = tag_cmdlet( tag_returncode = tag_cmdlet(
result_obj, result_obj,
@@ -1548,7 +1549,7 @@ class DownloadModal(ModalScreen):
wipe_tags_and_source: bool = False, wipe_tags_and_source: bool = False,
skip_tag_scraping: bool = False skip_tag_scraping: bool = False
) -> None: ) -> None:
"""Background worker to scrape metadata using get-tag cmdlet. """Background worker to scrape metadata using metadata -get.
Args: Args:
url: URL to scrape metadata from url: URL to scrape metadata from
@@ -1558,7 +1559,7 @@ class DownloadModal(ModalScreen):
try: try:
logger.info(f"Metadata worker started for: {url}") 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: if not get_cmdlet:
logger.error("cmdlet module not available") logger.error("cmdlet module not available")
self.app.call_from_thread( self.app.call_from_thread(
@@ -1569,13 +1570,13 @@ class DownloadModal(ModalScreen):
) )
return return
# Get the get-tag cmdlet # Get the metadata cmdlet
get_tag_cmdlet = get_cmdlet("get-tag") get_tag_cmdlet = get_cmdlet("metadata")
if not get_tag_cmdlet: 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.call_from_thread(
self.app.notify, self.app.notify,
"get-tag cmdlet not found", "metadata cmdlet not found",
title="Error", title="Error",
severity="error" severity="error"
) )
@@ -1591,7 +1592,7 @@ class DownloadModal(ModalScreen):
result_obj = URLResult(url) 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 import io
from contextlib import redirect_stdout, redirect_stderr from contextlib import redirect_stdout, redirect_stderr
@@ -1599,7 +1600,7 @@ class DownloadModal(ModalScreen):
error_buffer = io.StringIO() error_buffer = io.StringIO()
# Only scrape if not skipping tag scraping # 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): with redirect_stdout(output_buffer), redirect_stderr(error_buffer):
returncode = get_tag_cmdlet(result_obj, returncode = get_tag_cmdlet(result_obj,
@@ -1608,7 +1609,7 @@ class DownloadModal(ModalScreen):
if returncode != 0: if returncode != 0:
error_msg = error_buffer.getvalue() error_msg = error_buffer.getvalue()
logger.error(f"get-tag cmdlet failed: {error_msg}") logger.error(f"metadata cmdlet failed: {error_msg}")
try: try:
self.app.call_from_thread( self.app.call_from_thread(
self.app.notify, self.app.notify,
@@ -1623,11 +1624,11 @@ class DownloadModal(ModalScreen):
# Parse the JSON output # Parse the JSON output
output = output_buffer.getvalue().strip() output = output_buffer.getvalue().strip()
if not output: if not output:
logger.warning("get-tag returned no output") logger.warning("metadata -get returned no output")
try: try:
self.app.call_from_thread( self.app.call_from_thread(
self.app.notify, self.app.notify,
"No metadata returned from get-tag", "No metadata returned from metadata -get",
title="Error", title="Error",
severity="error", severity="error",
) )
@@ -1635,7 +1636,7 @@ class DownloadModal(ModalScreen):
logger.debug(f"Could not notify user: {e}") logger.debug(f"Could not notify user: {e}")
return 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 json_line = None
for line in output.split("\n"): for line in output.split("\n"):
if line.strip().startswith("{"): if line.strip().startswith("{"):
@@ -1643,7 +1644,7 @@ class DownloadModal(ModalScreen):
break break
if not json_line: 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}") logger.debug(f"Raw output: {output}")
try: try:
self.app.call_from_thread( self.app.call_from_thread(
@@ -1832,7 +1833,7 @@ class DownloadModal(ModalScreen):
# Stage 1: Download data if enabled # Stage 1: Download data if enabled
if download_enabled: if download_enabled:
download_cmdlet_name = "download-file" download_cmdlet_name = "file"
download_cmdlet = get_cmdlet(download_cmdlet_name) download_cmdlet = get_cmdlet(download_cmdlet_name)
if download_cmdlet: if download_cmdlet:
stage_msg = f"📥 Executing {download_cmdlet_name} stage" stage_msg = f"📥 Executing {download_cmdlet_name} stage"
@@ -1853,7 +1854,7 @@ class DownloadModal(ModalScreen):
) )
if isinstance(cmd_config, dict): if isinstance(cmd_config, dict):
cmd_config["_quiet_background_output"] = True 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() stdout_text = stdout_buf.getvalue()
stderr_text = stderr_buf.getvalue() stderr_text = stderr_buf.getvalue()
@@ -1902,14 +1903,14 @@ class DownloadModal(ModalScreen):
# Stage 2: Tag the file if tags provided # Stage 2: Tag the file if tags provided
if tags: if tags:
tag_cmdlet = get_cmdlet("add-tags") tag_cmdlet = get_cmdlet("metadata")
if tag_cmdlet and result_obj.get("path"): if tag_cmdlet and result_obj.get("path"):
stage_msg = f"🏷️ Tagging with {len(tags)} tags" stage_msg = f"🏷️ Tagging with {len(tags)} tags"
logger.info(stage_msg) logger.info(stage_msg)
if worker: if worker:
worker.append_stdout(f"{stage_msg}\n") worker.append_stdout(f"{stage_msg}\n")
try: try:
tag_args = tags tag_args = ["-add"] + [str(t) for t in tags]
import io import io
from contextlib import redirect_stdout, redirect_stderr 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: def _load_helper_modules() -> None:
# Provider-specific module pre-loading removed; providers are loaded lazily # Provider-specific module pre-loading removed; providers are loaded lazily
# through ProviderCore.registry when first referenced. # 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: 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: if on_store_error is not None and exc is not None:
on_store_error(store_name, exc) on_store_error(store_name, exc)
continue 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 not hasattr(backend, "set_note"):
if on_unsupported_store is not None: if on_unsupported_store is not None:
on_unsupported_store(store_name) on_unsupported_store(store_name)
+3
View File
@@ -0,0 +1,3 @@
"""File action cmdlets package."""
__all__ = []
+125 -18
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 SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS
from Store import Store from Store import Store
from API.HTTP import _download_direct_file from API.HTTP import _download_direct_file
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg CmdletArg = sh.CmdletArg
@@ -862,7 +862,7 @@ class Add_File(Cmdlet):
subject = collected_payloads[0] if len(collected_payloads) == 1 else collected_payloads subject = collected_payloads[0] if len(collected_payloads) == 1 else collected_payloads
# Use helper to display items and make them @-selectable # 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) display_and_persist_items(collected_payloads, title="Result", subject=subject)
try: try:
@@ -911,7 +911,7 @@ class Add_File(Cmdlet):
return None return None
try: 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) query = "hash:" + ",".join(hashes)
args = ["-instance", str(instance), "-internal-refresh", query] args = ["-instance", str(instance), "-internal-refresh", query]
@@ -1135,6 +1135,9 @@ class Add_File(Cmdlet):
except Exception: except Exception:
continue continue
if not bool(getattr(backend, "supports_relationship_association", False)):
continue
setter = getattr(backend, "set_relationship", None) setter = getattr(backend, "set_relationship", None)
if not callable(setter): if not callable(setter):
continue continue
@@ -1981,7 +1984,7 @@ class Add_File(Cmdlet):
return return
try: try:
from ._shared import display_and_persist_items from .._shared import display_and_persist_items
display_and_persist_items([payload], title="Result", subject=payload) display_and_persist_items([payload], title="Result", subject=payload)
except Exception: except Exception:
@@ -2045,7 +2048,7 @@ class Add_File(Cmdlet):
Returns the emitted search-file payload items on success, else None. Returns the emitted search-file payload items on success, else None.
""" """
try: 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)}"] args = ["-instance", str(instance), f"hash:{str(hash_value)}"]
@@ -2233,6 +2236,63 @@ class Add_File(Cmdlet):
pipe_obj.extra["url"] = merged_url pipe_obj.extra["url"] = merged_url
return merged_tags, merged_url, preferred_title, file_hash 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 @staticmethod
def _handle_local_export( def _handle_local_export(
media_path: Path, media_path: Path,
@@ -2383,8 +2443,6 @@ class Add_File(Cmdlet):
list_plugins_with_capability, list_plugins_with_capability,
) )
log(f"Uploading via {plugin_name}: {media_path.name}", file=sys.stderr)
try: try:
file_provider = get_plugin_with_capability(plugin_name, "upload", config) file_provider = get_plugin_with_capability(plugin_name, "upload", config)
if not file_provider: if not file_provider:
@@ -2406,7 +2464,36 @@ class Add_File(Cmdlet):
pipe_obj=pipe_obj, pipe_obj=pipe_obj,
instance=instance_name, 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) f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
@@ -2505,11 +2592,21 @@ class Add_File(Cmdlet):
try: try:
store = store_instance if store_instance is not None else Store(config) 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. # Use backend properties to drive metadata deferral behavior.
is_remote_backend = getattr(backend, "is_remote", False) is_remote_backend = getattr(backend, "is_remote", False)
prefer_defer_tags = getattr(backend, "prefer_defer_tags", 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 # Prepare metadata from pipe_obj and sidecars
@@ -2573,7 +2670,7 @@ class Add_File(Cmdlet):
pass pass
# Collect relationship pairs for post-ingest DB/API persistence. # 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) rels = Add_File._get_relationships(result, pipe_obj)
if isinstance(rels, dict) and rels: if isinstance(rels, dict) and rels:
king_hash, alt_hashes = Add_File._parse_relationships_king_alts(rels) king_hash, alt_hashes = Add_File._parse_relationships_king_alts(rels)
@@ -2622,14 +2719,21 @@ class Add_File(Cmdlet):
except Exception: except Exception:
pass pass
# Call backend's add_file with full metadata 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 # 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. # (which came from download-file emit), pass it to skip re-hashing large files.
file_identifier = backend.add_file( file_identifier = backend.add_file(
media_path, media_path,
title=title, title=title,
tag=upload_tags, tag=upload_tags,
url=[] if (defer_url_association and url) else url, url=[] if ((defer_url_association and url) or (not supports_url_association)) else url,
file_hash=f_hash, file_hash=f_hash,
) )
##log(f"✓ File added to '{backend_name}': {file_identifier}", file=sys.stderr) ##log(f"✓ File added to '{backend_name}': {file_identifier}", file=sys.stderr)
@@ -2687,7 +2791,7 @@ class Add_File(Cmdlet):
# If we have url(s), ensure they get associated with the destination file. # If we have url(s), ensure they get associated with the destination file.
# This mirrors `add-url` behavior but avoids emitting extra pipeline noise. # 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: if defer_url_association and pending_url_associations is not None:
try: try:
pending_url_associations.setdefault( 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), # If a subtitle note was provided upstream (e.g., download-media writes notes.sub),
# persist it automatically like add-note would. # persist it automatically like add-note would.
sub_note = Add_File._get_note_text(result, pipe_obj, "sub") sub_note = Add_File._get_note_text(result, pipe_obj, "sub")
if sub_note: if sub_note and supports_note_association:
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
@@ -2726,7 +2830,7 @@ class Add_File(Cmdlet):
) )
lyric_note = Add_File._get_note_text(result, pipe_obj, "lyric") lyric_note = Add_File._get_note_text(result, pipe_obj, "lyric")
if lyric_note: if lyric_note and supports_note_association:
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
@@ -2744,7 +2848,7 @@ class Add_File(Cmdlet):
) )
chapters_note = Add_File._get_note_text(result, pipe_obj, "chapters") chapters_note = Add_File._get_note_text(result, pipe_obj, "chapters")
if chapters_note: if chapters_note and supports_note_association:
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
@@ -2762,7 +2866,7 @@ class Add_File(Cmdlet):
) )
caption_note = Add_File._get_note_text(result, pipe_obj, "caption") caption_note = Add_File._get_note_text(result, pipe_obj, "caption")
if caption_note: if caption_note and supports_note_association:
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
@@ -2905,6 +3009,9 @@ class Add_File(Cmdlet):
if backend is None: if backend is None:
continue continue
if not bool(getattr(backend, "supports_url_association", False)):
continue
items = sh.coalesce_hash_value_pairs(pairs) items = sh.coalesce_hash_value_pairs(pairs)
if not items: if not items:
continue continue
@@ -8,7 +8,7 @@ import re
from SYS.logger import log from SYS.logger import log
from SYS import pipeline as ctx from SYS import pipeline as ctx
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg CmdletArg = sh.CmdletArg
@@ -213,6 +213,12 @@ class Add_Note(Cmdlet):
) )
if backend is None: if backend is None:
raise exc or KeyError(store_override) 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( ok = bool(
backend.set_note( backend.set_note(
str(hash_override), 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 ProviderCore.registry import get_plugin
from SYS import pipeline as ctx from SYS import pipeline as ctx
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg CmdletArg = sh.CmdletArg
@@ -603,6 +603,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
if store_name: if store_name:
backend, _store_registry, _exc = sh.get_store_backend(config, str(store_name)) backend, _store_registry, _exc = sh.get_store_backend(config, str(store_name))
if backend is not None: 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) loc = getattr(backend, "location", None)
if callable(loc): if callable(loc):
is_folder_store = True is_folder_store = True
+20 -2
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, Sequence, Tuple
import sys import sys
from SYS import pipeline as ctx from SYS import pipeline as ctx
from . import _shared as sh from .. import _shared as sh
from SYS.logger import log from SYS.logger import log
from Store import Store from Store import Store
@@ -135,10 +135,25 @@ class Add_Url(sh.Cmdlet):
on_warning=_warn, 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. # Execute per-instance batches.
storage, batch_stats = sh.run_store_hash_value_batches( storage, batch_stats = sh.run_store_hash_value_batches(
config, config,
batch, supported_batch,
bulk_method_name="add_url_bulk", bulk_method_name="add_url_bulk",
single_method_name="add_url", single_method_name="add_url",
store_registry=storage, store_registry=storage,
@@ -166,6 +181,9 @@ class Add_Url(sh.Cmdlet):
if backend is None: if backend is None:
log(f"Error: Storage backend '{store_name}' not configured") log(f"Error: Storage backend '{store_name}' not configured")
return 1 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) backend.add_url(str(file_hash), urls, config=config)
ctx.print_if_visible( ctx.print_if_visible(
f"✓ add-url: {len(urls)} url(s) added", 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 import pipeline as ctx
from SYS.config import resolve_output_dir from SYS.config import resolve_output_dir
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg CmdletArg = sh.CmdletArg
@@ -9,7 +9,7 @@ import subprocess
from SYS.logger import log, debug from SYS.logger import log, debug
from SYS.payload_builders import build_file_result_payload from SYS.payload_builders import build_file_result_payload
from SYS.utils import sha256_file from SYS.utils import sha256_file
from . import _shared as sh from .. import _shared as sh
from SYS import pipeline as ctx from SYS import pipeline as ctx
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
@@ -10,7 +10,7 @@ from pathlib import Path
from SYS.logger import debug, log from SYS.logger import debug, log
from ProviderCore.registry import get_plugin from ProviderCore.registry import get_plugin
from Store import Store from Store import Store
from . import _shared as sh from .. import _shared as sh
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.result_table_helpers import add_row_columns from SYS.result_table_helpers import add_row_columns
from SYS.result_table import Table, _format_size 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.result_table import Table
from SYS.rich_display import stderr_console as get_stderr_console from SYS.rich_display import stderr_console as get_stderr_console
from SYS import pipeline as pipeline_context 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 # SYS.metadata import deferred: normalize_urls loaded lazily at call site to avoid
# pulling in Cryptodome (~900ms) at module import time. # pulling in Cryptodome (~900ms) at module import time.
from SYS.selection_builder import ( from SYS.selection_builder import (
@@ -38,7 +39,7 @@ try:
except Exception: # pragma: no cover - optional dependency for tests/runtime wrappers except Exception: # pragma: no cover - optional dependency for tests/runtime wrappers
YtDlpTool = None # type: ignore YtDlpTool = None # type: ignore
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg CmdletArg = sh.CmdletArg
@@ -936,7 +937,7 @@ class Download_File(Cmdlet):
try: try:
subject = emitted_items[0] if len(emitted_items) == 1 else list(emitted_items) subject = emitted_items[0] if len(emitted_items) == 1 else list(emitted_items)
# Use helper to display items and make them @-selectable # 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) display_and_persist_items(list(emitted_items), title="Result", subject=subject)
except Exception: except Exception:
pass pass
@@ -984,56 +985,13 @@ class Download_File(Cmdlet):
def _find_existing_hash_for_url( def _find_existing_hash_for_url(
cls, storage: Any, canonical_url: str, *, hydrus_available: bool cls, storage: Any, canonical_url: str, *, hydrus_available: bool
) -> Optional[str]: ) -> Optional[str]:
if storage is None or not canonical_url: hashes = cls._find_existing_hashes_for_url(
return None storage,
hydrus_provider = None canonical_url,
try: hydrus_available=hydrus_available,
registry_helpers = cls._load_provider_registry() config={},
get_plugin = registry_helpers.get("get_plugin") )
if callable(get_plugin): return hashes[0] if hashes else None
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
@staticmethod @staticmethod
def _init_storage(config: Dict[str, Any]) -> tuple[Any, bool]: 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, 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 @staticmethod
def _format_timecode(seconds: int, *, force_hours: bool) -> str: def _format_timecode(seconds: int, *, force_hours: bool) -> str:
total = max(0, int(seconds)) total = max(0, int(seconds))
@@ -1739,8 +1896,18 @@ class Download_File(Cmdlet):
items_preview=preview 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 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( urls_downloaded, early_exit = self._process_explicit_urls(
raw_urls=raw_url, raw_urls=raw_url,
final_output_dir=final_output_dir, 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 urllib.request import pathname2url
from SYS import pipeline as ctx 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.item_accessors import get_result_title
from SYS.logger import log, debug, debug_panel from SYS.logger import log, debug, debug_panel
from SYS.config import resolve_output_dir from SYS.config import resolve_output_dir
@@ -13,7 +13,7 @@ import re as _re
from SYS.config import resolve_output_dir from SYS.config import resolve_output_dir
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg CmdletArg = sh.CmdletArg
@@ -23,7 +23,7 @@ from SYS.item_accessors import extract_item_tags, get_result_title
from API.HTTP import HTTPClient from API.HTTP import HTTPClient
from SYS.pipeline_progress import PipelineProgress from SYS.pipeline_progress import PipelineProgress
from SYS.utils import ensure_directory, sha256_file, unique_path, unique_preserve_order 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 Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg 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.selection_builder import build_default_selection
from SYS.result_publication import publish_result_table from SYS.result_publication import publish_result_table
from ._shared import ( from .._shared import (
Cmdlet, Cmdlet,
CmdletArg, CmdletArg,
SharedArgs, SharedArgs,
+1 -1
View File
@@ -14,7 +14,7 @@ from urllib.parse import urlparse
from SYS.logger import log, debug from SYS.logger import log, debug
from SYS.item_accessors import get_store_name from SYS.item_accessors import get_store_name
from SYS.utils import sha256_file from SYS.utils import sha256_file
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg 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.result_table_helpers import add_row_columns
from SYS import pipeline as ctx from SYS import pipeline as ctx
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg 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.result_publication import publish_result_table
from SYS import pipeline as ctx from SYS import pipeline as ctx
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg 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 models
from SYS import pipeline as ctx 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 from Store import Store # retained for test monkeypatch compatibility
normalize_result_input = sh.normalize_result_input normalize_result_input = sh.normalize_result_input
@@ -411,7 +411,7 @@ def _refresh_tag_view(
get_tag = None get_tag = None
try: try:
get_tag = get_cmdlet("get-tag") get_tag = get_cmdlet("metadata")
except Exception: except Exception:
get_tag = None get_tag = None
if not callable(get_tag): 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): if not subject or not _matches_target(subject, target_hash, target_path, store_name):
return 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. # Build a lean subject so get-tag fetches fresh tags instead of reusing cached payloads.
def _build_refresh_subject() -> Dict[str, Any]: def _build_refresh_subject() -> Dict[str, Any]:
@@ -463,14 +463,14 @@ def _refresh_tag_view(
class Add_Tag(Cmdlet): 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__( super().__init__(
name="add-tag", name="tag",
summary="Add tag to a file in a store.", summary="Add tag to a file in a store.",
usage= 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=[ arg=[
CmdletArg( CmdletArg(
"tag", "tag",
@@ -519,21 +519,22 @@ class Add_Tag(Cmdlet):
"- If -query is not provided, uses the piped item's hash (or derives from its path when possible).", "- 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.", "- Multiple tag can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult", "- 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:", "- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)", " Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)", " Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.", "- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).", "- 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 -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 #(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. add-tag \"code:e<padding(00,#(episode))>\".", "- 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.", "- 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.", "- 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.", "- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
], ],
exec=self.run, exec=self.run,
) )
if register_cmdlet:
self.register() self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
@@ -1215,4 +1216,3 @@ class Add_Tag(Cmdlet):
return 0 return 0
CMDLET = Add_Tag()
@@ -7,7 +7,7 @@ from SYS import pipeline as ctx
from SYS.item_accessors import set_field from SYS.item_accessors import set_field
from SYS.payload_builders import extract_title_tag_value from SYS.payload_builders import extract_title_tag_value
from SYS.result_publication import publish_result_table from SYS.result_publication import publish_result_table
from . import _shared as sh from .. import _shared as sh
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
CmdletArg = sh.CmdletArg CmdletArg = sh.CmdletArg
@@ -203,7 +203,7 @@ def _refresh_tag_view_if_current(
get_tag = None get_tag = None
try: try:
get_tag = get_cmdlet("get-tag") get_tag = get_cmdlet("metadata")
except Exception: except Exception:
get_tag = None get_tag = None
if not callable(get_tag): if not callable(get_tag):
@@ -245,7 +245,7 @@ def _refresh_tag_view_if_current(
if not is_match: if not is_match:
return return
refresh_args: list[str] = [] refresh_args: list[str] = ["-get"]
if file_hash: if file_hash:
refresh_args.extend(["-query", f"hash:{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 return tags
CMDLET = Cmdlet( _DELETE_TAG_CMDLET = Cmdlet(
name="delete-tag", name="tag",
summary="Remove tags from a file in a store.", 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=[ arg=[
SharedArgs.QUERY, SharedArgs.QUERY,
SharedArgs.INSTANCE, SharedArgs.INSTANCE,
@@ -401,8 +401,8 @@ CMDLET = Cmdlet(
detail=[ detail=[
"- Requires a Hydrus file (hash present) or explicit -query override.", "- Requires a Hydrus file (hash present) or explicit -query override.",
"- Multiple tags can be comma-separated or space-separated.", "- 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)\".", "- Use #(namespace) inside a tag value to remove a derived tag, e.g. metadata -delete \"title:#(track) - #(series)\".",
"- Angle-bracket transforms match add-tag syntax, e.g. delete-tag \"code:e<padding(00,#(episode))>\".", "- 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.", "- 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.", "- 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.", "- 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: def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Help # Help
if should_show_help(args): 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 return 0
def _looks_like_tag_row(obj: Any) -> bool: 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 @ reaches here as a literal argument, it's almost certainly user error.
if rest and str(rest[0] if rest and str(rest[0]
).startswith("@") and not (has_piped_tag or has_piped_tag_list): ).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 return 1
# Special case: grouped tag selection created by the pipeline runner. # 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] remaining_titles = [t for t in current_titles if t.lower() not in del_title_set]
if current_titles and not remaining_titles: if current_titles and not remaining_titles:
log( 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, file=sys.stderr,
) )
return False return False
@@ -803,6 +803,3 @@ def _process_deletion(
return False 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.payload_builders import extract_title_tag_value
from SYS.result_publication import publish_result_table from SYS.result_publication import publish_result_table
from SYS.result_table_helpers import add_row_columns 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 from SYS.field_access import get_field
normalize_hash = sh.normalize_hash 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. """Get tags from Hydrus, local sidecar, or URL metadata.
Usage: Usage:
get-tag [-query "hash:<sha256>"] [--instance <key>] [--emit] metadata -get [-query "hash:<sha256>"] [--instance <key>] [--emit]
get-tag -scrape <url|provider> metadata -get -scrape <url|provider>
Options: Options:
-query "hash:<sha256>": Override hash to use instead of result's hash -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: 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 emit_mode = False
is_store_backed = False is_store_backed = False
args_list = [str(arg) for arg in (args or [])] 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) return getattr(obj, field, default)
# Parse arguments using shared parser # 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) # Detect if -scrape flag was provided without a value (parse_cmdlet_args skips missing values)
scrape_flag_present = any( 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 = Table(f"Metadata: {provider.name}")
table.set_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 = [] selection_payload = []
hash_for_payload = normalize_hash(hash_override) or normalize_hash( hash_for_payload = normalize_hash(hash_override) or normalize_hash(
get_field(result, get_field(result,
@@ -956,15 +956,15 @@ _SCRAPE_CHOICES = [
class Get_Tag(Cmdlet): 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: def __init__(self, *, register_cmdlet: bool = True) -> None:
"""Initialize get-tag cmdlet.""" """Initialize metadata -get handler."""
super().__init__( super().__init__(
name="get-tag", name="tag",
summary="Get tag values from Hydrus or local sidecar metadata", summary="Get tag values from Hydrus or local sidecar metadata",
usage= usage=
'get-tag [-query "hash:<sha256>"] [--instance <key>] [--emit] [-scrape <url|provider>]', 'metadata -get [-query "hash:<sha256>"] [--instance <key>] [--emit] [-scrape <url|provider>]',
alias=[], alias=[],
arg=[ arg=[
SharedArgs.QUERY, SharedArgs.QUERY,
@@ -1001,12 +1001,11 @@ class Get_Tag(Cmdlet):
], ],
exec=self.run, exec=self.run,
) )
if register_cmdlet:
self.register() self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: 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) 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 # Check for -add flag
if "-add" in remaining_args: if "-add" in remaining_args:
# .adjective category -add tag # .adjective category -add <value>
# or .adjective category tag -add # or .adjective category <value> -add
add_idx = remaining_args.index("-add") add_idx = remaining_args.index("-add")
# Tag could be before or after # Tag could be before or after
tag = None tag = None
@@ -126,8 +126,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Check for -delete flag # Check for -delete flag
elif "-delete" in remaining_args: elif "-delete" in remaining_args:
# .adjective category -delete tag # .adjective category -delete <value>
# or .adjective category tag -delete # or .adjective category <value> -delete
del_idx = remaining_args.index("-delete") del_idx = remaining_args.index("-delete")
tag = None tag = None
if del_idx + 1 < len(remaining_args): if del_idx + 1 < len(remaining_args):
+23 -23
View File
@@ -1,6 +1,6 @@
# Tag Template Syntax # 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. 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: The shared template resolver currently applies to:
- `add-tag` - `metadata -add`
- `delete-tag` - `metadata -delete`
Templates are resolved per item against that item's current tag set and lightweight result fields such as the current title. 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: Examples:
```powershell ```powershell
add-tag "title:#(track) - #(series)" metadata -add "title:#(track) - #(series)"
add-tag "album:#(series)" metadata -add "album:#(series)"
delete-tag "title:#(track) - #(series)" metadata -delete "title:#(track) - #(series)"
``` ```
If an item has: If an item has:
@@ -53,8 +53,8 @@ title:9 - ancient greek intensive course
Examples: Examples:
```powershell ```powershell
add-tag "title:#(track #) - #(series)" metadata -add "title:#(track #) - #(series)"
add-tag "code:#(disc number)" metadata -add "code:#(disc number)"
``` ```
## Transform Syntax ## Transform Syntax
@@ -74,9 +74,9 @@ Use `padding`, `pad`, or `zfill` to zero-pad a value.
Examples: Examples:
```powershell ```powershell
add-tag "code:e<padding(00,#(episode))>" metadata -add "code:e<padding(00,#(episode))>"
add-tag "code:e<pad(2,#(episode))>" metadata -add "code:e<pad(2,#(episode))>"
add-tag "code:e<zfill(2,#(episode))>" metadata -add "code:e<zfill(2,#(episode))>"
``` ```
If `episode:3` exists, each example resolves to: If `episode:3` exists, each example resolves to:
@@ -99,8 +99,8 @@ Use `default(value,fallback)` when a namespace may be missing.
Examples: Examples:
```powershell ```powershell
add-tag "season:<default(#(season),0)>" metadata -add "season:<default(#(season),0)>"
add-tag "disc:<default(#(disc),1)>" metadata -add "disc:<default(#(disc),1)>"
``` ```
If `season:` is missing, the first example resolves to: If `season:` is missing, the first example resolves to:
@@ -116,8 +116,8 @@ Use `replace(value,old,new)` for simple substring replacement.
Examples: Examples:
```powershell ```powershell
add-tag "slug:<replace(#(title),' ',_)>" metadata -add "slug:<replace(#(title),' ',_)>"
add-tag "slug:<replace(#(series),-,_)>" metadata -add "slug:<replace(#(series),-,_)>"
``` ```
If `title:ancient greek intensive course` exists, the first example resolves to: 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: Examples:
```powershell ```powershell
add-tag "episode_next:<increment(#(episode),1)>" metadata -add "episode_next:<increment(#(episode),1)>"
add-tag "disc_next:<increment(#(disc),1)>" metadata -add "disc_next:<increment(#(disc),1)>"
``` ```
If `episode:3` exists, the first example resolves to: 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: This means the following stays as two tags, not three fragments:
```powershell ```powershell
add-tag "code:e<padding(00,#(episode))>,title:#(series)" metadata -add "code:e<padding(00,#(episode))>,title:#(series)"
``` ```
## Combining With `-extract` ## Combining With `-extract`
@@ -164,7 +164,7 @@ Templates are especially useful after deriving tags from a title.
Example: Example:
```powershell ```powershell
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)" metadata -add -extract "(series) - part (track)" "title:#(track) - #(series)"
``` ```
For a title like: For a title like:
@@ -197,25 +197,25 @@ The command logs a warning summary for skipped unresolved templates.
Episode-style numbering: Episode-style numbering:
```powershell ```powershell
add-tag "code:e<padding(00,#(episode))>" metadata -add "code:e<padding(00,#(episode))>"
``` ```
Title synthesis from extracted tags: Title synthesis from extracted tags:
```powershell ```powershell
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)" metadata -add -extract "(series) - part (track)" "title:#(track) - #(series)"
``` ```
Delete a derived title tag: Delete a derived title tag:
```powershell ```powershell
delete-tag "title:#(track) - #(series)" metadata -delete "title:#(track) - #(series)"
``` ```
Reuse an existing value under a new namespace: Reuse an existing value under a new namespace:
```powershell ```powershell
add-tag "album:#(series)" metadata -add "album:#(series)"
``` ```
## Mass Tagging Recipes ## Mass Tagging Recipes
+1 -1
View File
@@ -392,7 +392,7 @@ def _dispatch_alldebrid_magnet_search(
config: Dict[str, Any], config: Dict[str, Any],
) -> None: ) -> None:
try: 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) exec_fn = getattr(_SEARCH_FILE_CMDLET, "exec", None)
if callable(exec_fn): 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(): if not local_path.exists() or not local_path.is_file():
raise FileNotFoundError(f"File not found: {local_path}") raise FileNotFoundError(f"File not found: {local_path}")
pipe_obj = kwargs.get("pipe_obj")
settings = self._resolve_settings( settings = self._resolve_settings(
instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None, instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None,
require_explicit=bool(kwargs.get("instance") or kwargs.get("store")), require_explicit=bool(kwargs.get("instance") or kwargs.get("store")),
@@ -569,6 +571,19 @@ class FTP(Provider):
ftp = self._connect(settings=settings) ftp = self._connect(settings=settings)
try: try:
self._ensure_directory(ftp, remote_dir, base_path=str(settings.get("base_path") or "/")) 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: with local_path.open("rb") as handle:
ftp.storbinary(f"STOR {remote_path}", handle) ftp.storbinary(f"STOR {remote_path}", handle)
finally: finally:
@@ -930,6 +945,36 @@ class FTP(Provider):
if not self._is_directory(ftp, partial): if not self._is_directory(ftp, partial):
raise 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]: def _item_metadata(self, item: Any, *, pipe_obj: Any = None) -> Dict[str, Any]:
metadata: Dict[str, Any] = {} metadata: Dict[str, Any] = {}
for source in (item, pipe_obj): 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 if screenshot_url == '' or not screenshot_url:match('^https?://') then
screenshot_url = '' screenshot_url = ''
end 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) .. ' -path ' .. quote_pipeline_arg(out_path)
if screenshot_url ~= '' then if screenshot_url ~= '' then
cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url) 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 '' local tag_suffix = (#tag_list > 0) and (' | tags: ' .. tostring(#tag_list)) or ''
if #tag_list > 0 then if #tag_list > 0 then
local tag_string = table.concat(tag_list, ',') 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 end
local queue_target = is_named_store and ('store ' .. store) or 'folder' local queue_target = is_named_store and ('store ' .. store) or 'folder'
@@ -5539,7 +5539,7 @@ local function _start_download_flow_for_current()
end end
ensure_mpv_ipc_server() 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( _queue_pipeline_in_repl(
pipeline_cmd, pipeline_cmd,
'Queued in REPL: store copy', 'Queued in REPL: store copy',
@@ -5835,9 +5835,9 @@ mp.register_script_message('medios-download-pick-store', function(json)
end end
local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' 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) .. ' -query ' .. quote_pipeline_arg(query)
.. ' | add-file -store ' .. quote_pipeline_arg(store) .. ' | file -add -store ' .. quote_pipeline_arg(store)
_set_selected_store(store) _set_selected_store(store)
_queue_pipeline_in_repl( _queue_pipeline_in_repl(
@@ -5901,9 +5901,9 @@ mp.register_script_message('medios-download-pick-path', function()
end end
local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' 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) .. ' -query ' .. quote_pipeline_arg(query)
.. ' | add-file -path ' .. quote_pipeline_arg(folder) .. ' | file -add -path ' .. quote_pipeline_arg(folder)
_queue_pipeline_in_repl( _queue_pipeline_in_repl(
pipeline_cmd, pipeline_cmd,
@@ -6137,7 +6137,7 @@ function M.delete_current_file()
local seed = {{path = path}} local seed = {{path = path}}
M.run_pipeline('delete-file', seed, function(_, err) M.run_pipeline('file -delete', seed, function(_, err)
if err then if err then
mp.osd_message('Delete failed: ' .. tostring(err), 3) mp.osd_message('Delete failed: ' .. tostring(err), 3)
return return
@@ -6302,17 +6302,17 @@ local function _start_trim_with_range(range)
_lua_log('trim: building store file pipeline (original from store)') _lua_log('trim: building store file pipeline (original from store)')
if selected_store then if selected_store then
pipeline_cmd = 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) .. ' -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 .. '"' .. ' -store "' .. selected_store .. '"' ..
' | add-relationship -store "' .. selected_store .. '"' .. ' | add-relationship -store "' .. selected_store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash) ' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
else else
pipeline_cmd = 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) .. ' -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 .. '"' .. ' -store "' .. store_hash.store .. '"' ..
' | add-relationship -store "' .. store_hash.store .. '"' .. ' | add-relationship -store "' .. store_hash.store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash) ' -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 -- Local file: save to selected store if available
_lua_log('trim: local file pipeline (not from store)') _lua_log('trim: local file pipeline (not from store)')
if selected_store then 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 -- 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 .. '"' ' -store "' .. selected_store .. '"'
_lua_log('trim: pipeline_cmd=' .. pipeline_cmd) _lua_log('trim: pipeline_cmd=' .. pipeline_cmd)
else else
+3 -3
View File
@@ -470,11 +470,11 @@ class MPV:
def _q(s: str) -> str: def _q(s: str) -> str:
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' 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: if store:
pipeline += f" | add-file -instance {_q(store)}" pipeline += f" | file -add -instance {_q(store)}"
else: else:
pipeline += f" | add-file -path {_q(path or '')}" pipeline += f" | file -add -path {_q(path or '')}"
try: try:
from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 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(): if not local_path.exists() or not local_path.is_file():
raise FileNotFoundError(f"File not found: {local_path}") raise FileNotFoundError(f"File not found: {local_path}")
pipe_obj = kwargs.get("pipe_obj")
settings = self._resolve_settings( settings = self._resolve_settings(
instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None, instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None,
require_explicit=bool(kwargs.get("instance") or kwargs.get("store")), 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): if not self._is_sftp_negotiation_error(exc):
raise raise
self._ensure_directory_via_ssh(ssh, remote_dir) 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: else:
self._ensure_directory(sftp, remote_dir, base_path=str(settings.get("base_path") or "/")) 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 = self._open_scp(ssh)
scp_client.put(str(local_path), remote_path=remote_path) scp_client.put(str(local_path), remote_path=remote_path)
finally: finally:
@@ -620,6 +644,19 @@ class SCP(Provider):
return self._build_url(remote_path, settings=settings) 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]: def _run_test_connection(self) -> Dict[str, Any]:
settings = self._resolve_settings() settings = self._resolve_settings()
if not settings.get("host"): if not settings.get("host"):