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