cmdlet refactor
This commit is contained in:
+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()
|
||||
Reference in New Issue
Block a user